From 429f36efeb9d6da27062be32bec30d25fbd86aa7 Mon Sep 17 00:00:00 2001 From: Petr Lavrov Date: Fri, 21 Mar 2025 03:36:54 +0300 Subject: [PATCH 1/3] update CLAUDE.md --- CLAUDE.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 3dfad56..554adee 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,3 +1,21 @@ +# Settings Menu + +- **Main Feature**: Provides a configurable UI for managing bot settings with two modes - admin settings (configure botspot components) and user settings (customizable per-user preferences). +- **Demo Bot**: `/settings` command that displays an inline keyboard or web app with settings options, allowing users to toggle, select, or input different configuration values. +- **Useful Bot**: **Config Bot** - Enables users to personalize their experience via simple settings UI, while admins can adjust system parameters (like LLM provider settings) without editing code files. + +## Developer Notes + +The idea of a settings menu is two-fold: + +1) Idea 1 - have a menu for configuring botspot settings. I guess that can be admin-only. Specifically that is necessary for llm provider - which model to use - etc. Well, for single-user mode that will be for admins. + +2) Idea 2 - user settings. I guess that can be integrated with UserManager and having custom fields for configuration in User class. But need to figure out how to deal with booleans, enums and all that stuff. + +3) Then - form factor. + - Option 1 - web app / mini-app. + - Option 2 - inline keyboard. I haven't done either yet. Well, i've done inline keyboard, but i think maybe i will need aiogram-dialogue for that. But I've also heard it's better to just do it myself properly. So need to try and see how this will work. + # Botspot Development Guide ## Build & Test Commands From eaddcad292989efa3244ceb3b3345a46ece756ad Mon Sep 17 00:00:00 2001 From: Petr Lavrov Date: Mon, 24 Mar 2025 01:38:24 +0300 Subject: [PATCH 2/3] settings menu: Spec and workalong --- CLAUDE.md | 17 ------ .../rework_protocol.md | 58 +++++++++++++++++++ dev/workalong_settings_menu/spec.md | 17 ++++++ dev/workalong_settings_menu/workalong.md | 0 4 files changed, 75 insertions(+), 17 deletions(-) create mode 100644 dev/workalong_settings_menu/rework_protocol.md create mode 100644 dev/workalong_settings_menu/spec.md create mode 100644 dev/workalong_settings_menu/workalong.md diff --git a/CLAUDE.md b/CLAUDE.md index 554adee..327d0bc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,20 +1,3 @@ -# Settings Menu - -- **Main Feature**: Provides a configurable UI for managing bot settings with two modes - admin settings (configure botspot components) and user settings (customizable per-user preferences). -- **Demo Bot**: `/settings` command that displays an inline keyboard or web app with settings options, allowing users to toggle, select, or input different configuration values. -- **Useful Bot**: **Config Bot** - Enables users to personalize their experience via simple settings UI, while admins can adjust system parameters (like LLM provider settings) without editing code files. - -## Developer Notes - -The idea of a settings menu is two-fold: - -1) Idea 1 - have a menu for configuring botspot settings. I guess that can be admin-only. Specifically that is necessary for llm provider - which model to use - etc. Well, for single-user mode that will be for admins. - -2) Idea 2 - user settings. I guess that can be integrated with UserManager and having custom fields for configuration in User class. But need to figure out how to deal with booleans, enums and all that stuff. - -3) Then - form factor. - - Option 1 - web app / mini-app. - - Option 2 - inline keyboard. I haven't done either yet. Well, i've done inline keyboard, but i think maybe i will need aiogram-dialogue for that. But I've also heard it's better to just do it myself properly. So need to try and see how this will work. # Botspot Development Guide diff --git a/dev/workalong_settings_menu/rework_protocol.md b/dev/workalong_settings_menu/rework_protocol.md new file mode 100644 index 0000000..e28f6e4 --- /dev/null +++ b/dev/workalong_settings_menu/rework_protocol.md @@ -0,0 +1,58 @@ +This is a protocol for refactoring / reworking code components with claude-code or other +ai tools. +The main goal of this protocol is to ensure the codebase doesn't bloat and grow riddled +with duplicated code during AI refactoring / reworks. + +# Pre-rework + +- Commit. Make sure everything is committed + - Optional: Create a new branch for the rework +- Optional: Check tests, if present. + +# Simple protocol: + +If the task is clear, go directly to coding phase + +# Complex protocol: + +If there's a complex task - first, read spec.md or ask user for a list of requirements + +- After that, evaluate the current solution on the match with specs + - Which additional featuers were implemented that are not present in the spec + - What is the likely reason were they added + - + - Which components / features explicitly listed in the spec are missing + - How difficult it is to add this + - write to workalong.md + - proceed to coding + +## Coding: + +- Before coding, lay out a plan to the user in clear terms. + - Which components / features will be added + - Which modified + - Which moved / removed + - Make an explicit enumeration for user to specify which steps are approved and + which are declined + - Each item should be formulated in as simple terms as possible, 1 line maximum per + item, not longer than a few words +- Always remove duplicate code or alternative / previous implementations of the same + feature + - After making a change, call git diff and make sure file is in a desired target + state and the changes you planned are correctly reflected +- proceed with implementing each item one by one and track progress with checkboxes in + workalong.md + - [x] item 1 - keep to original item descriptions, DO NOT ADD SUB-ITEMS. List which + files were affected for this feature. + +AI Issue resolution + +1) Failed deletions + Sometimes AI applier fails to delete code according to instructions + This results in really confusing situations with duplicate functions / methods + present in multiple places across the codebase + To mitigate that + +- Check file diff after each modifications and make sure it reflects the changes you've + made + diff --git a/dev/workalong_settings_menu/spec.md b/dev/workalong_settings_menu/spec.md new file mode 100644 index 0000000..74a3222 --- /dev/null +++ b/dev/workalong_settings_menu/spec.md @@ -0,0 +1,17 @@ +# Settings Menu + +- **Main Feature**: Provides a configurable UI for managing bot settings with two modes - admin settings (configure botspot components) and user settings (customizable per-user preferences). +- **Demo Bot**: `/settings` command that displays an inline keyboard or web app with settings options, allowing users to toggle, select, or input different configuration values. +- **Useful Bot**: **Config Bot** - Enables users to personalize their experience via simple settings UI, while admins can adjust system parameters (like LLM provider settings) without editing code files. + +## Developer Notes + +The idea of a settings menu is two-fold: + +1) Idea 1 - have a menu for configuring botspot settings. I guess that can be admin-only. Specifically that is necessary for llm provider - which model to use - etc. Well, for single-user mode that will be for admins. + +2) Idea 2 - user settings. I guess that can be integrated with UserManager and having custom fields for configuration in User class. But need to figure out how to deal with booleans, enums and all that stuff. + +3) Then - form factor. + - Option 1 - web app / mini-app. + - Option 2 - inline keyboard. I haven't done either yet. Well, i've done inline keyboard, but i think maybe i will need aiogram-dialogue for that. But I've also heard it's better to just do it myself properly. So need to try and see how this will work. diff --git a/dev/workalong_settings_menu/workalong.md b/dev/workalong_settings_menu/workalong.md new file mode 100644 index 0000000..e69de29 From e6d0e9f714e4787616ca20423286bd3e5ac5a392 Mon Sep 17 00:00:00 2001 From: Petr Lavrov Date: Mon, 24 Mar 2025 01:40:15 +0300 Subject: [PATCH 3/3] settings menu: dump changes --- botspot/components/data/user_data.py | 336 ++++-- botspot/components/qol/__init__.py | 13 + botspot/components/qol/settings_menu.py | 1053 +++++++++++++++++ botspot/core/bot_manager.py | 8 +- botspot/core/botspot_settings.py | 2 + botspot/core/dependency_manager.py | 12 + botspot/utils/deps_getters.py | 11 + .../settings_menu_demo/README.md | 36 + .../settings_menu_demo/bot.py | 147 +++ .../settings_menu_demo/sample.env | 24 + 10 files changed, 1549 insertions(+), 93 deletions(-) create mode 100644 botspot/components/qol/settings_menu.py create mode 100644 examples/components_examples/settings_menu_demo/README.md create mode 100644 examples/components_examples/settings_menu_demo/bot.py create mode 100644 examples/components_examples/settings_menu_demo/sample.env diff --git a/botspot/components/data/user_data.py b/botspot/components/data/user_data.py index 2e1898d..9eb1e39 100644 --- a/botspot/components/data/user_data.py +++ b/botspot/components/data/user_data.py @@ -42,7 +42,7 @@ class User(BaseModel): user_type: UserType = UserType.REGULAR created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) last_active: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) - # settings: dict = Field(default_factory=dict) # For component-specific settings + settings: Optional[dict] = Field(default_factory=dict) # For component-specific settings @property def full_name(self) -> str: @@ -57,7 +57,7 @@ class UserManager: def __init__( self, - db: "AsyncIOMotorDatabase", + db: Optional["AsyncIOMotorDatabase"], collection: str, user_class: Type[User], settings: Optional["BotspotSettings"] = None, @@ -65,8 +65,10 @@ def __init__( self.db = db self.collection = collection self.user_class = user_class + self.memory_mode = False + self._memory_users = {} # In-memory storage for users when not using MongoDB + from botspot.core.dependency_manager import get_dependency_manager - self.settings = settings or get_dependency_manager().botspot_settings # todo: add functionality for searching users - by name etc. @@ -85,9 +87,14 @@ async def add_user(self, user: User) -> bool: if existing: raise ValueError("User already exists - cannot add") - await self.users_collection.update_one( - {"user_id": user.user_id}, {"$set": user.model_dump()}, upsert=True - ) + if self.memory_mode: + # Store in memory + self._memory_users[user.user_id] = user + else: + # Store in MongoDB + await self.users_collection.update_one( + {"user_id": user.user_id}, {"$set": user.model_dump()}, upsert=True + ) return True except Exception as e: logger.error(f"Failed to add user {user.user_id}: {e}") @@ -97,7 +104,32 @@ async def add_user(self, user: User) -> bool: async def update_user(self, user_id: int, field: str, value: Any) -> bool: """Update a user's field""" try: - await self.users_collection.update_one({"user_id": user_id}, {"$set": {field: value}}) + if self.memory_mode: + # Update in memory + user = self._memory_users.get(user_id) + if not user: + return False + + # Update the field + user_dict = user.model_dump() + if '.' in field: + # Handle nested fields (e.g., "settings.theme") + parts = field.split('.') + current = user_dict + for part in parts[:-1]: + if part not in current: + current[part] = {} + current = current[part] + current[parts[-1]] = value + else: + user_dict[field] = value + + # Create updated user + self._memory_users[user_id] = self.user_class(**user_dict) + else: + # Update in MongoDB + await self.users_collection.update_one({"user_id": user_id}, {"$set": {field: value}}) + logger.debug(f"Updated user {user_id} field {field} to {value}") return True except Exception as e: @@ -107,8 +139,13 @@ async def update_user(self, user_id: int, field: str, value: Any) -> bool: # todo: make sure this works with username as well async def get_user(self, user_id: int) -> Optional[User]: """Get user by ID""" - data = await self.users_collection.find_one({"user_id": user_id}) - return self.user_class(**data) if data else None + if self.memory_mode: + # Get from memory + return self._memory_users.get(user_id) + else: + # Get from MongoDB + data = await self.users_collection.find_one({"user_id": user_id}) + return self.user_class(**data) if data else None async def has_user(self, user_id: int) -> bool: """Check if user exists in the database""" @@ -116,6 +153,8 @@ async def has_user(self, user_id: int) -> bool: @property def users_collection(self) -> "AsyncIOMotorCollection": + if self.memory_mode: + raise RuntimeError("Cannot access users_collection in memory mode") return self.db[self.collection] async def find_user( @@ -130,41 +169,70 @@ async def find_user( if not any([user_id, username, phone, first_name, last_name]): raise ValueError("find_user requires at least one of the fields") - # Build query conditions - query = {} - - # If direct fields are provided - use them - if any([user_id, username, phone]): - or_conditions = [] - if user_id: - or_conditions.append({"user": user_id}) - if username: - or_conditions.append({"username": username}) - if phone: - or_conditions.append({"phone": phone}) - - if or_conditions: - query["$or"] = or_conditions - # If not - use match on name fields - elif first_name or last_name: - if first_name: - query["first_name"] = first_name - if last_name: - query["last_name"] = last_name - - # Execute query - result = await self.users_collection.find_one(query) - - # Convert to User model if result found - if result: - return self.user_class(**result) - return None + if self.memory_mode: + # Search in memory + for user in self._memory_users.values(): + # Check direct match + if user_id and user.user_id == user_id: + return user + if username and user.username == username: + return user + + # Check names + if first_name and last_name: + if user.first_name == first_name and user.last_name == last_name: + return user + elif first_name and user.first_name == first_name: + return user + elif last_name and user.last_name == last_name: + return user + + return None + else: + # Build query conditions + query = {} + + # If direct fields are provided - use them + if any([user_id, username, phone]): + or_conditions = [] + if user_id: + or_conditions.append({"user": user_id}) + if username: + or_conditions.append({"username": username}) + if phone: + or_conditions.append({"phone": phone}) + + if or_conditions: + query["$or"] = or_conditions + # If not - use match on name fields + elif first_name or last_name: + if first_name: + query["first_name"] = first_name + if last_name: + query["last_name"] = last_name + + # Execute query + result = await self.users_collection.find_one(query) + + # Convert to User model if result found + if result: + return self.user_class(**result) + return None async def update_last_active(self, user_id: int) -> None: """Update user's last active timestamp""" - await self.users_collection.update_one( - {"user_id": user_id}, {"$set": {"last_active": datetime.now(timezone.utc)}} - ) + now = datetime.now(timezone.utc) + + if self.memory_mode: + user = self._memory_users.get(user_id) + if user: + user_dict = user.model_dump() + user_dict["last_active"] = now + self._memory_users[user_id] = self.user_class(**user_dict) + else: + await self.users_collection.update_one( + {"user_id": user_id}, {"$set": {"last_active": now}} + ) # todo: make sure this works with username as well async def make_friend(self, user_id: int) -> bool: @@ -174,10 +242,15 @@ async def make_friend(self, user_id: int) -> bool: if not user: return False - user.user_type = UserType.FRIEND - await self.users_collection.update_one( - {"user_id": user_id}, {"$set": {"user_type": UserType.FRIEND}} - ) + if self.memory_mode: + user_dict = user.model_dump() + user_dict["user_type"] = UserType.FRIEND + self._memory_users[user_id] = self.user_class(**user_dict) + else: + await self.users_collection.update_one( + {"user_id": user_id}, {"$set": {"user_type": UserType.FRIEND}} + ) + return True except Exception: return False @@ -190,30 +263,73 @@ async def sync_user_types(self) -> None: - Friends: promote only (no automatic demotion) """ try: - # Update admins - if self.settings.admins: - # Promote current admins - result = await self.users_collection.update_many( - {"user_id": {"$in": list(self.settings.admins)}}, - {"$set": {"user_type": UserType.ADMIN}}, - ) - if result.modified_count: - logger.info(f"Promoted {result.modified_count} users to admin") - - # Demote former admins - result = await self.users_collection.update_many( - { - "user_id": {"$nin": list(self.settings.admins)}, - "user_type": UserType.ADMIN, - }, - {"$set": {"user_type": UserType.REGULAR}}, - ) - if result.modified_count: - logger.info(f"Demoted {result.modified_count} admins to regular users") - - # Update friends (only promote, don't demote) - if self.settings.friends: + if self.memory_mode: + # Memory mode implementation + admin_ids = [int(a) if str(a).isdigit() else a for a in self.settings.admins] + friend_ids = [int(f) if str(f).isdigit() else f for f in self.settings.friends] + + promoted_admins = 0 + demoted_admins = 0 + + # Process each user + for user_id, user in self._memory_users.items(): + user_dict = user.model_dump() + + # Check if user should be admin + is_admin = False + for admin in admin_ids: + if isinstance(admin, int) and user.user_id == admin: + is_admin = True + break + elif isinstance(admin, str) and user.username == admin.lstrip('@'): + is_admin = True + break + + # Promote to admin if needed + if is_admin and user.user_type != UserType.ADMIN: + user_dict["user_type"] = UserType.ADMIN + self._memory_users[user_id] = self.user_class(**user_dict) + promoted_admins += 1 + + # Demote from admin if needed + elif not is_admin and user.user_type == UserType.ADMIN: + user_dict["user_type"] = UserType.REGULAR + self._memory_users[user_id] = self.user_class(**user_dict) + demoted_admins += 1 + + if promoted_admins: + logger.info(f"Promoted {promoted_admins} users to admin") + if demoted_admins: + logger.info(f"Demoted {demoted_admins} admins to regular users") + + # Promote friends (no demotion) await self._promote_friends() + else: + # MongoDB implementation + # Update admins + if self.settings.admins: + # Promote current admins + result = await self.users_collection.update_many( + {"user_id": {"$in": list(self.settings.admins)}}, + {"$set": {"user_type": UserType.ADMIN}}, + ) + if result.modified_count: + logger.info(f"Promoted {result.modified_count} users to admin") + + # Demote former admins + result = await self.users_collection.update_many( + { + "user_id": {"$nin": list(self.settings.admins)}, + "user_type": UserType.ADMIN, + }, + {"$set": {"user_type": UserType.REGULAR}}, + ) + if result.modified_count: + logger.info(f"Demoted {result.modified_count} admins to regular users") + + # Update friends (only promote, don't demote) + if self.settings.friends: + await self._promote_friends() except Exception as e: logger.error(f"Failed to sync user types: {e}") raise # Re-raise to handle in startup @@ -227,27 +343,53 @@ async def _promote_friends(self): friends_ids.append(int(friend)) except ValueError: friends_usernames.append(friend.lstrip("@")) - res = 0 - if friends_ids: - result = await self.users_collection.update_many( - { - "user_id": {"$in": friends_ids}, - "user_type": UserType.REGULAR, # Only update if regular user - }, - {"$set": {"user_type": UserType.FRIEND}}, - ) - res += result.modified_count - if friends_usernames: - result = await self.users_collection.update_many( - { - "username": {"$in": friends_usernames}, - "user_type": UserType.REGULAR, # Only update if regular user - }, - {"$set": {"user_type": UserType.FRIEND}}, - ) - res += result.modified_count - if res: - logger.info(f"Promoted {res} users to friends") + + if self.memory_mode: + # Memory mode implementation + promoted = 0 + for user_id, user in self._memory_users.items(): + if user.user_type != UserType.REGULAR: + continue # Skip non-regular users + + # Check if user should be a friend + is_friend = False + if user.user_id in friends_ids: + is_friend = True + elif user.username and user.username in friends_usernames: + is_friend = True + + # Promote to friend if needed + if is_friend: + user_dict = user.model_dump() + user_dict["user_type"] = UserType.FRIEND + self._memory_users[user_id] = self.user_class(**user_dict) + promoted += 1 + + if promoted: + logger.info(f"Promoted {promoted} users to friends") + else: + # MongoDB implementation + res = 0 + if friends_ids: + result = await self.users_collection.update_many( + { + "user_id": {"$in": friends_ids}, + "user_type": UserType.REGULAR, # Only update if regular user + }, + {"$set": {"user_type": UserType.FRIEND}}, + ) + res += result.modified_count + if friends_usernames: + result = await self.users_collection.update_many( + { + "username": {"$in": friends_usernames}, + "user_type": UserType.REGULAR, # Only update if regular user + }, + {"$set": {"user_type": UserType.FRIEND}}, + ) + res += result.modified_count + if res: + logger.info(f"Promoted {res} users to friends") class UserTrackingMiddleware(BaseMiddleware): @@ -307,6 +449,7 @@ class UserDataSettings(BaseSettings): collection: str = "botspot_users" cache_ttl: int = 300 # Cache TTL in seconds (5 minutes by default) user_types_enabled: bool = True # New setting + storage_type: str = "mongodb" # Can be "mongodb" or "memory" class Config: env_prefix = "BOTSPOT_USER_DATA_" @@ -317,17 +460,26 @@ class Config: def initialize(settings: "BotspotSettings", user_class=None) -> UserManager: """Initialize the user data component""" - db = get_database() - + # Get database or use None for memory mode + db = None + if settings.user_data.storage_type == "mongodb": + db = get_database() + if user_class is None: user_class = User - return UserManager( + manager = UserManager( db=db, collection=settings.user_data.collection, user_class=user_class, settings=settings, ) + + # Set memory mode if storage type is memory + if settings.user_data.storage_type == "memory": + manager.memory_mode = True + + return manager def get_user_manager(): diff --git a/botspot/components/qol/__init__.py b/botspot/components/qol/__init__.py index e69de29..e5ad8bc 100644 --- a/botspot/components/qol/__init__.py +++ b/botspot/components/qol/__init__.py @@ -0,0 +1,13 @@ +""" +Quality of life components for Botspot + +These components enhance the bot's usability and user experience without being +core functionality. +""" + +__all__ = [ + "bot_commands_menu", + "bot_info", + "print_bot_url", + "settings_menu", +] \ No newline at end of file diff --git a/botspot/components/qol/settings_menu.py b/botspot/components/qol/settings_menu.py new file mode 100644 index 0000000..7cd1cad --- /dev/null +++ b/botspot/components/qol/settings_menu.py @@ -0,0 +1,1053 @@ +"""Settings Menu Component for Botspot + +This module provides a configurable UI for managing bot settings with two modes: +1. Admin settings - configure botspot components (admin-only) +2. User settings - customizable per-user preferences + +It supports both inline keyboard and web app interfaces. +""" + +from enum import Enum +from typing import Any, Dict, List, Optional, Type, Union + +from aiogram import Dispatcher, F, Router +from aiogram.filters import Command +from aiogram.fsm.context import FSMContext +from aiogram.fsm.state import State, StatesGroup +from aiogram.types import ( + CallbackQuery, + InlineKeyboardButton, + InlineKeyboardMarkup, + Message, +) +from pydantic import BaseModel, Field +from pydantic_settings import BaseSettings + +from botspot.components.data.user_data import User, get_user_manager +from botspot.utils.admin_filter import AdminFilter +from botspot.utils.internal import get_logger + +logger = get_logger() + + +# --------------------------------------------- +# region Settings Types and Classes +# --------------------------------------------- + + +class SettingType(Enum): + """Types of settings that can be configured""" + + BOOLEAN = "boolean" # True/False toggle + OPTION = "option" # Select from predefined options + TEXT = "text" # Free text input + NUMBER = "number" # Numeric input + MODEL = "model" # Special case for model selection + + +class SettingCategory(Enum): + """Categories of settings""" + + GENERAL = "general" + APPEARANCE = "appearance" + NOTIFICATIONS = "notifications" + PRIVACY = "privacy" + MODELS = "models" + ADVANCED = "advanced" + + +class SettingVisibility(Enum): + """Who can see/modify the setting""" + + ADMIN = "admin" # Only admins can see/modify + USER = "user" # Users can see/modify their own settings + ALL = "all" # All users can see/modify + + +class Setting(BaseModel): + """Model representing a configurable setting""" + + key: str + name: str + description: str + type: SettingType + category: SettingCategory + visibility: SettingVisibility + default_value: Any + options: Optional[List[Any]] = None # For OPTION type + min_value: Optional[float] = None # For NUMBER type + max_value: Optional[float] = None # For NUMBER type + component_name: Optional[str] = None # Related component name + + +class SettingsMenuSettings(BaseSettings): + """Settings for the Settings Menu component""" + + enabled: bool = False + use_web_app: bool = False # Whether to use Telegram Web App + web_app_url: str = "" # URL for the settings web app + command_name: str = "settings" # Command to open settings + admin_command_name: str = "admin_settings" # Command for admin settings + + class Config: + env_prefix = "BOTSPOT_SETTINGS_MENU_" + env_file = ".env" + env_file_encoding = "utf-8" + extra = "ignore" + + +# --------------------------------------------- +# endregion Settings Types and Classes +# --------------------------------------------- + + +# --------------------------------------------- +# region FSM States +# --------------------------------------------- + + +class SettingsStates(StatesGroup): + """States for settings menu navigation""" + + main_menu = State() # Main menu (admin or user) + category_selection = State() # Selecting a category + setting_selection = State() # Selecting a specific setting + boolean_setting = State() # Toggling boolean setting + option_setting = State() # Selecting from options + text_input = State() # Entering text value + number_input = State() # Entering numeric value + model_selection = State() # Special case for model selection + confirmation = State() # Confirming changes + + +# --------------------------------------------- +# endregion FSM States +# --------------------------------------------- + + +# --------------------------------------------- +# region Settings Registry +# --------------------------------------------- + + +class SettingsRegistry: + """Registry for all available settings in the system""" + + def __init__(self): + self.admin_settings: Dict[str, Setting] = {} + self.user_settings: Dict[str, Setting] = {} + self._initialize_default_settings() + + def _initialize_default_settings(self): + """Initialize default settings for admin and user""" + # Admin settings - primarily for components + self._register_admin_setting( + Setting( + key="llm_provider_model", + name="Default LLM Model", + description="Default model to use for LLM requests", + type=SettingType.OPTION, + category=SettingCategory.MODELS, + visibility=SettingVisibility.ADMIN, + default_value="claude-3.7", + options=[ + "claude-3.7", "claude-3-haiku", "claude-3-sonnet", "claude-3-opus", + "gpt-4o", "gpt-4-turbo", "gpt-3.5-turbo", "gemini-pro", "gemini-1.5-pro" + ], + component_name="llm_provider" + ) + ) + + self._register_admin_setting( + Setting( + key="llm_provider_temperature", + name="LLM Temperature", + description="Temperature parameter for LLM requests (0.0-1.0)", + type=SettingType.NUMBER, + category=SettingCategory.MODELS, + visibility=SettingVisibility.ADMIN, + default_value=0.7, + min_value=0.0, + max_value=1.0, + component_name="llm_provider" + ) + ) + + self._register_admin_setting( + Setting( + key="llm_provider_allow_everyone", + name="Allow Everyone to Use LLM", + description="Allow all users to use LLM features, not just admins and friends", + type=SettingType.BOOLEAN, + category=SettingCategory.PRIVACY, + visibility=SettingVisibility.ADMIN, + default_value=False, + component_name="llm_provider" + ) + ) + + # User settings + self._register_user_setting( + Setting( + key="notifications_enabled", + name="Enable Notifications", + description="Receive notifications from the bot", + type=SettingType.BOOLEAN, + category=SettingCategory.NOTIFICATIONS, + visibility=SettingVisibility.USER, + default_value=True + ) + ) + + self._register_user_setting( + Setting( + key="user_preferred_language", + name="Preferred Language", + description="Language for bot responses", + type=SettingType.OPTION, + category=SettingCategory.APPEARANCE, + visibility=SettingVisibility.USER, + default_value="en", + options=["en", "ru", "es", "fr", "de"] + ) + ) + + def _register_admin_setting(self, setting: Setting): + """Register an admin setting""" + self.admin_settings[setting.key] = setting + + def _register_user_setting(self, setting: Setting): + """Register a user setting""" + self.user_settings[setting.key] = setting + + def register_setting(self, setting: Setting): + """Register a setting based on its visibility""" + if setting.visibility == SettingVisibility.ADMIN: + self._register_admin_setting(setting) + else: + self._register_user_setting(setting) + + def get_admin_settings(self, category: Optional[SettingCategory] = None) -> Dict[str, Setting]: + """Get admin settings, optionally filtered by category""" + if category is None: + return self.admin_settings + + return {k: v for k, v in self.admin_settings.items() if v.category == category} + + def get_user_settings(self, category: Optional[SettingCategory] = None) -> Dict[str, Setting]: + """Get user settings, optionally filtered by category""" + if category is None: + return self.user_settings + + return {k: v for k, v in self.user_settings.items() if v.category == category} + + def get_setting(self, key: str) -> Optional[Setting]: + """Get a setting by key""" + if key in self.admin_settings: + return self.admin_settings[key] + if key in self.user_settings: + return self.user_settings[key] + return None + + def get_categories(self, is_admin: bool = False) -> List[SettingCategory]: + """Get unique categories for admin or user settings""" + settings = self.admin_settings if is_admin else self.user_settings + return list({s.category for s in settings.values()}) + + +# Global registry instance +settings_registry = SettingsRegistry() + + +# --------------------------------------------- +# endregion Settings Registry +# --------------------------------------------- + + +# --------------------------------------------- +# region Settings Storage +# --------------------------------------------- + + +class SettingsManager: + """Manage storage and retrieval of setting values""" + + def __init__(self): + self.registry = settings_registry + self._admin_values: Dict[str, Any] = {} # In-memory cache for admin settings + + async def get_admin_setting(self, key: str) -> Any: + """Get admin setting value""" + from botspot.core.dependency_manager import get_dependency_manager + + deps = get_dependency_manager() + setting = self.registry.get_setting(key) + + if not setting: + return None + + # Check if component-specific setting + if setting.component_name: + # Special handling for components + if setting.component_name == "llm_provider": + if key == "llm_provider_model": + return deps.botspot_settings.llm_provider.default_model + elif key == "llm_provider_temperature": + return deps.botspot_settings.llm_provider.default_temperature + elif key == "llm_provider_allow_everyone": + return deps.botspot_settings.llm_provider.allow_everyone + + # Add other components as needed + + # Use in-memory cache for other admin settings + if key in self._admin_values: + return self._admin_values[key] + + # Return default if not found + return setting.default_value + + async def set_admin_setting(self, key: str, value: Any) -> bool: + """Set admin setting value""" + from botspot.core.dependency_manager import get_dependency_manager + + deps = get_dependency_manager() + setting = self.registry.get_setting(key) + + if not setting: + return False + + # Check if component-specific setting + if setting.component_name: + # Special handling for components + if setting.component_name == "llm_provider": + if key == "llm_provider_model": + deps.botspot_settings.llm_provider.default_model = value + deps.llm_provider.settings.default_model = value + return True + elif key == "llm_provider_temperature": + deps.botspot_settings.llm_provider.default_temperature = value + deps.llm_provider.settings.default_temperature = value + return True + elif key == "llm_provider_allow_everyone": + deps.botspot_settings.llm_provider.allow_everyone = value + deps.llm_provider.settings.allow_everyone = value + return True + + # Add other components as needed + + # Use in-memory cache for other admin settings + self._admin_values[key] = value + return True + + async def get_user_setting(self, user_id: int, key: str) -> Any: + """Get user setting value""" + user_manager = get_user_manager() + user = await user_manager.get_user(user_id) + + if not user: + setting = self.registry.get_setting(key) + return setting.default_value if setting else None + + # Use custom fields for user settings + # First check if user has settings attribute + if not hasattr(user, "settings") or not user.settings: + setting = self.registry.get_setting(key) + return setting.default_value if setting else None + + # Check if key exists in user settings + if key in user.settings: + return user.settings[key] + + # Return default if not found + setting = self.registry.get_setting(key) + return setting.default_value if setting else None + + async def set_user_setting(self, user_id: int, key: str, value: Any) -> bool: + """Set user setting value""" + user_manager = get_user_manager() + user = await user_manager.get_user(user_id) + + if not user: + return False + + # Initialize settings if not exists + if not hasattr(user, "settings"): + # Add settings field to user model if not exists + user_dict = user.model_dump() + user_dict["settings"] = {} + user = User(**user_dict) + + # Create empty settings dict if None + if user.settings is None: + user.settings = {} + + # Set the value + user.settings[key] = value + + # Update user in database + await user_manager.update_user(user_id, "settings", user.settings) + return True + + +# Global settings manager instance +settings_manager = SettingsManager() + + +# --------------------------------------------- +# endregion Settings Storage +# --------------------------------------------- + + +# --------------------------------------------- +# region Keyboard Builders +# --------------------------------------------- + + +def build_main_menu_keyboard(is_admin: bool = False) -> InlineKeyboardMarkup: + """Build main menu keyboard for settings""" + categories = settings_registry.get_categories(is_admin=is_admin) + keyboard = [] + + # Add category buttons + for category in categories: + keyboard.append([ + InlineKeyboardButton( + text=f"📋 {category.name.capitalize()}", + callback_data=f"settings_category_{category.value}" + ) + ]) + + # Add back button + keyboard.append([ + InlineKeyboardButton(text="❌ Close", callback_data="settings_close") + ]) + + return InlineKeyboardMarkup(inline_keyboard=keyboard) + + +def build_category_keyboard(category: SettingCategory, is_admin: bool = False) -> InlineKeyboardMarkup: + """Build keyboard for settings in a category""" + if is_admin: + settings = settings_registry.get_admin_settings(category) + else: + settings = settings_registry.get_user_settings(category) + + keyboard = [] + + # Add setting buttons + for key, setting in settings.items(): + keyboard.append([ + InlineKeyboardButton( + text=f"{setting.name}", + callback_data=f"settings_select_{key}" + ) + ]) + + # Add back button + keyboard.append([ + InlineKeyboardButton(text="⬅️ Back", callback_data="settings_back_to_main") + ]) + + return InlineKeyboardMarkup(inline_keyboard=keyboard) + + +def build_boolean_setting_keyboard(key: str, current_value: bool) -> InlineKeyboardMarkup: + """Build keyboard for boolean setting""" + keyboard = [ + [ + InlineKeyboardButton( + text=f"✅ Enabled" if current_value else "Enabled", + callback_data=f"settings_bool_{key}_true" + ), + InlineKeyboardButton( + text=f"❌ Disabled" if not current_value else "Disabled", + callback_data=f"settings_bool_{key}_false" + ) + ], + [ + InlineKeyboardButton(text="⬅️ Back", callback_data="settings_back_to_category") + ] + ] + + return InlineKeyboardMarkup(inline_keyboard=keyboard) + + +def build_option_setting_keyboard(key: str, options: List[Any], current_value: Any) -> InlineKeyboardMarkup: + """Build keyboard for option setting""" + keyboard = [] + + # Add option buttons + for option in options: + option_text = str(option) + is_selected = option == current_value + keyboard.append([ + InlineKeyboardButton( + text=f"✅ {option_text}" if is_selected else option_text, + callback_data=f"settings_option_{key}_{option}" + ) + ]) + + # Add back button + keyboard.append([ + InlineKeyboardButton(text="⬅️ Back", callback_data="settings_back_to_category") + ]) + + return InlineKeyboardMarkup(inline_keyboard=keyboard) + + +# --------------------------------------------- +# endregion Keyboard Builders +# --------------------------------------------- + + +# --------------------------------------------- +# region Handlers +# --------------------------------------------- + + +async def settings_command(message: Message, state: FSMContext): + """Handler for /settings command""" + # Reset state + await state.clear() + + # Check if user is admin for admin settings + is_admin = await AdminFilter()(message) + + # Store admin flag in state data + await state.update_data(is_admin=is_admin) + + # Build and send main menu + keyboard = build_main_menu_keyboard(is_admin=is_admin) + + title = "🔧 Admin Settings" if is_admin else "⚙️ User Settings" + text = ( + f"{title}\n\n" + f"Select a category to configure:" + ) + + await message.answer(text, reply_markup=keyboard) + await state.set_state(SettingsStates.main_menu) + + +async def admin_settings_command(message: Message, state: FSMContext): + """Handler for /admin_settings command (admin only)""" + # Clear previous state + await state.clear() + + # Store admin flag in state data (always true for this command) + await state.update_data(is_admin=True) + + # Build and send admin main menu + keyboard = build_main_menu_keyboard(is_admin=True) + + text = ( + "🔧 Admin Settings\n\n" + "Select a category to configure:" + ) + + await message.answer(text, reply_markup=keyboard) + await state.set_state(SettingsStates.main_menu) + + +async def category_callback(query: CallbackQuery, state: FSMContext): + """Handle category selection""" + await query.answer() + + # Get category from callback data + callback_data = query.data + category_value = callback_data.replace("settings_category_", "") + category = SettingCategory(category_value) + + # Store selected category in state + state_data = await state.get_data() + is_admin = state_data.get("is_admin", False) + + await state.update_data(category=category_value) + + # Build and send category keyboard + keyboard = build_category_keyboard(category, is_admin=is_admin) + + text = ( + f"⚙️ {category.name.capitalize()} Settings\n\n" + f"Select a setting to configure:" + ) + + await query.message.edit_text(text, reply_markup=keyboard) + await state.set_state(SettingsStates.category_selection) + + +async def setting_select_callback(query: CallbackQuery, state: FSMContext): + """Handle setting selection""" + await query.answer() + + # Get setting key from callback data + callback_data = query.data + setting_key = callback_data.replace("settings_select_", "") + + # Get setting details + setting = settings_registry.get_setting(setting_key) + if not setting: + await query.message.edit_text("Setting not found.") + return + + # Store setting key in state + state_data = await state.get_data() + is_admin = state_data.get("is_admin", False) + + await state.update_data(setting_key=setting_key) + + # Get current value + if is_admin: + current_value = await settings_manager.get_admin_setting(setting_key) + else: + user_id = query.from_user.id + current_value = await settings_manager.get_user_setting(user_id, setting_key) + + if setting.type == SettingType.BOOLEAN: + # Build boolean keyboard + keyboard = build_boolean_setting_keyboard(setting_key, current_value) + + text = ( + f"⚙️ {setting.name}\n\n" + f"{setting.description}\n\n" + f"Current value: {'Enabled' if current_value else 'Disabled'}" + ) + + await query.message.edit_text(text, reply_markup=keyboard) + await state.set_state(SettingsStates.boolean_setting) + + elif setting.type == SettingType.OPTION: + # Build option keyboard + keyboard = build_option_setting_keyboard(setting_key, setting.options, current_value) + + text = ( + f"⚙️ {setting.name}\n\n" + f"{setting.description}\n\n" + f"Current value: {current_value}" + ) + + await query.message.edit_text(text, reply_markup=keyboard) + await state.set_state(SettingsStates.option_setting) + + elif setting.type == SettingType.TEXT: + text = ( + f"⚙️ {setting.name}\n\n" + f"{setting.description}\n\n" + f"Current value: {current_value}\n\n" + f"Please enter a new value or click Back to cancel:" + ) + + # Add back button + keyboard = InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="⬅️ Back", callback_data="settings_back_to_category")] + ]) + + await query.message.edit_text(text, reply_markup=keyboard) + await state.set_state(SettingsStates.text_input) + + elif setting.type == SettingType.NUMBER: + text = ( + f"⚙️ {setting.name}\n\n" + f"{setting.description}\n\n" + f"Current value: {current_value}\n\n" + f"Please enter a new value between {setting.min_value} and {setting.max_value}, " + f"or click Back to cancel:" + ) + + # Add back button + keyboard = InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="⬅️ Back", callback_data="settings_back_to_category")] + ]) + + await query.message.edit_text(text, reply_markup=keyboard) + await state.set_state(SettingsStates.number_input) + + elif setting.type == SettingType.MODEL: + # Special case for model selection + # Similar to option but with model-specific options + # This would typically use a dedicated handler or keyboard builder + pass + + +async def boolean_setting_callback(query: CallbackQuery, state: FSMContext): + """Handle boolean setting changes""" + await query.answer() + + # Parse callback data: format is "settings_bool_{key}_{value}" + parts = query.data.split("_") + setting_key = parts[2] + new_value = parts[3] == "true" + + # Get state data + state_data = await state.get_data() + is_admin = state_data.get("is_admin", False) + + # Update setting + if is_admin: + success = await settings_manager.set_admin_setting(setting_key, new_value) + else: + user_id = query.from_user.id + success = await settings_manager.set_user_setting(user_id, setting_key, new_value) + + if not success: + await query.message.edit_text("Failed to update setting.") + return + + # Get setting details + setting = settings_registry.get_setting(setting_key) + + # Update keyboard with new value + keyboard = build_boolean_setting_keyboard(setting_key, new_value) + + text = ( + f"⚙️ {setting.name}\n\n" + f"{setting.description}\n\n" + f"Current value: {'Enabled' if new_value else 'Disabled'}\n\n" + f"✅ Setting updated!" + ) + + await query.message.edit_text(text, reply_markup=keyboard) + + +async def option_setting_callback(query: CallbackQuery, state: FSMContext): + """Handle option setting changes""" + await query.answer() + + # Parse callback data: format is "settings_option_{key}_{value}" + parts = query.data.split("_") + setting_key = parts[2] + new_value = parts[3] # This might need conversion depending on option type + + # Get setting details + setting = settings_registry.get_setting(setting_key) + + # Convert value to correct type if needed + if setting and setting.options: + # Find matching option by string representation + for option in setting.options: + if str(option) == new_value: + new_value = option + break + + # Get state data + state_data = await state.get_data() + is_admin = state_data.get("is_admin", False) + + # Update setting + if is_admin: + success = await settings_manager.set_admin_setting(setting_key, new_value) + else: + user_id = query.from_user.id + success = await settings_manager.set_user_setting(user_id, setting_key, new_value) + + if not success: + await query.message.edit_text("Failed to update setting.") + return + + # Update keyboard with new value + keyboard = build_option_setting_keyboard(setting_key, setting.options, new_value) + + text = ( + f"⚙️ {setting.name}\n\n" + f"{setting.description}\n\n" + f"Current value: {new_value}\n\n" + f"✅ Setting updated!" + ) + + await query.message.edit_text(text, reply_markup=keyboard) + + +async def text_input_handler(message: Message, state: FSMContext): + """Handle text input for text settings""" + # Get state data + state_data = await state.get_data() + setting_key = state_data.get("setting_key") + is_admin = state_data.get("is_admin", False) + + # Get setting details + setting = settings_registry.get_setting(setting_key) + if not setting: + await message.answer("Setting not found.") + return + + # Get new value from message + new_value = message.text.strip() + + # Update setting + if is_admin: + success = await settings_manager.set_admin_setting(setting_key, new_value) + else: + user_id = message.from_user.id + success = await settings_manager.set_user_setting(user_id, setting_key, new_value) + + if not success: + await message.answer("Failed to update setting.") + return + + # Send confirmation + text = ( + f"⚙️ {setting.name}\n\n" + f"New value: {new_value}\n\n" + f"✅ Setting updated!" + ) + + # Add back buttons + keyboard = InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="⬅️ Back to Category", callback_data="settings_back_to_category")], + [InlineKeyboardButton(text="🔙 Back to Main Menu", callback_data="settings_back_to_main")] + ]) + + await message.answer(text, reply_markup=keyboard) + + +async def number_input_handler(message: Message, state: FSMContext): + """Handle numeric input for number settings""" + # Get state data + state_data = await state.get_data() + setting_key = state_data.get("setting_key") + is_admin = state_data.get("is_admin", False) + + # Get setting details + setting = settings_registry.get_setting(setting_key) + if not setting: + await message.answer("Setting not found.") + return + + # Try to parse number from input + try: + new_value = float(message.text.strip()) + + # Validate range if min/max are set + if setting.min_value is not None and new_value < setting.min_value: + await message.answer(f"Value must be at least {setting.min_value}. Please try again.") + return + + if setting.max_value is not None and new_value > setting.max_value: + await message.answer(f"Value must be at most {setting.max_value}. Please try again.") + return + except ValueError: + await message.answer("Please enter a valid number. Try again.") + return + + # Update setting + if is_admin: + success = await settings_manager.set_admin_setting(setting_key, new_value) + else: + user_id = message.from_user.id + success = await settings_manager.set_user_setting(user_id, setting_key, new_value) + + if not success: + await message.answer("Failed to update setting.") + return + + # Send confirmation + text = ( + f"⚙️ {setting.name}\n\n" + f"New value: {new_value}\n\n" + f"✅ Setting updated!" + ) + + # Add back buttons + keyboard = InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="⬅️ Back to Category", callback_data="settings_back_to_category")], + [InlineKeyboardButton(text="🔙 Back to Main Menu", callback_data="settings_back_to_main")] + ]) + + await message.answer(text, reply_markup=keyboard) + + +async def back_to_main_callback(query: CallbackQuery, state: FSMContext): + """Handle back to main menu button""" + await query.answer() + + # Get state data + state_data = await state.get_data() + is_admin = state_data.get("is_admin", False) + + # Build and send main menu + keyboard = build_main_menu_keyboard(is_admin=is_admin) + + title = "🔧 Admin Settings" if is_admin else "⚙️ User Settings" + text = ( + f"{title}\n\n" + f"Select a category to configure:" + ) + + await query.message.edit_text(text, reply_markup=keyboard) + await state.set_state(SettingsStates.main_menu) + + +async def back_to_category_callback(query: CallbackQuery, state: FSMContext): + """Handle back to category button""" + await query.answer() + + # Get state data + state_data = await state.get_data() + category_value = state_data.get("category") + is_admin = state_data.get("is_admin", False) + + if not category_value: + # Fallback to main menu if category not found + await back_to_main_callback(query, state) + return + + category = SettingCategory(category_value) + + # Build and send category keyboard + keyboard = build_category_keyboard(category, is_admin=is_admin) + + text = ( + f"⚙️ {category.name.capitalize()} Settings\n\n" + f"Select a setting to configure:" + ) + + await query.message.edit_text(text, reply_markup=keyboard) + await state.set_state(SettingsStates.category_selection) + + +async def close_settings_callback(query: CallbackQuery, state: FSMContext): + """Handle close settings button""" + await query.answer() + await query.message.delete() + await state.clear() + + +# --------------------------------------------- +# endregion Handlers +# --------------------------------------------- + + +# --------------------------------------------- +# region Dispatcher Setup +# --------------------------------------------- + + +def setup_dispatcher(dp: Dispatcher, settings: SettingsMenuSettings = None): + """Set up the dispatcher with the settings menu component""" + if settings is None: + settings = SettingsMenuSettings() + + if not settings.enabled: + logger.info("Settings Menu component is disabled") + return + + logger.info("Setting up Settings Menu component") + + # Create router + router = Router(name="settings_menu") + + # Register commands + from botspot import commands_menu + from botspot.commands_menu import Visibility + + # User settings command + @commands_menu.botspot_command(settings.command_name, "Configure your bot settings") + @router.message(Command(settings.command_name)) + async def settings_cmd(message: Message, state: FSMContext): + await settings_command(message, state) + + # Admin settings command (admin-only) + @commands_menu.botspot_command(settings.admin_command_name, + "Configure admin settings", + Visibility.ADMIN_ONLY) + @router.message(Command(settings.admin_command_name)) + @router.message(AdminFilter()) + async def admin_settings_cmd(message: Message, state: FSMContext): + await admin_settings_command(message, state) + + # Register callback handlers + router.callback_query.register(category_callback, + F.data.startswith("settings_category_")) + + router.callback_query.register(setting_select_callback, + F.data.startswith("settings_select_")) + + router.callback_query.register(boolean_setting_callback, + F.data.startswith("settings_bool_")) + + router.callback_query.register(option_setting_callback, + F.data.startswith("settings_option_")) + + router.callback_query.register(back_to_main_callback, + F.data == "settings_back_to_main") + + router.callback_query.register(back_to_category_callback, + F.data == "settings_back_to_category") + + router.callback_query.register(close_settings_callback, + F.data == "settings_close") + + # Register message handlers for text/number input + router.message.register(text_input_handler, SettingsStates.text_input) + router.message.register(number_input_handler, SettingsStates.number_input) + + # Include router in dispatcher + dp.include_router(router) + + return dp + + +def initialize(settings: SettingsMenuSettings): + """Initialize the settings menu component""" + if not settings.enabled: + logger.info("Settings Menu component is disabled") + return None + + logger.info("Initializing Settings Menu component") + return settings_registry + + +# --------------------------------------------- +# endregion Dispatcher Setup +# --------------------------------------------- + + +# --------------------------------------------- +# region Utils +# --------------------------------------------- + + +def register_component_setting( + component_name: str, + key: str, + name: str, + description: str, + setting_type: SettingType, + category: SettingCategory = SettingCategory.ADVANCED, + visibility: SettingVisibility = SettingVisibility.ADMIN, + default_value: Any = None, + options: Optional[List[Any]] = None, + min_value: Optional[float] = None, + max_value: Optional[float] = None, +) -> None: + """Register a setting for a component""" + setting = Setting( + key=key, + name=name, + description=description, + type=setting_type, + category=category, + visibility=visibility, + default_value=default_value, + options=options, + min_value=min_value, + max_value=max_value, + component_name=component_name + ) + + settings_registry.register_setting(setting) + + +def get_settings_registry(): + """Get the global settings registry""" + return settings_registry + + +def get_settings_manager(): + """Get the global settings manager""" + return settings_manager + + +# --------------------------------------------- +# endregion Utils +# --------------------------------------------- \ No newline at end of file diff --git a/botspot/core/bot_manager.py b/botspot/core/bot_manager.py index 885fe8f..df5e7cd 100644 --- a/botspot/core/bot_manager.py +++ b/botspot/core/bot_manager.py @@ -14,7 +14,7 @@ from botspot.components.main import event_scheduler, single_user_mode, telethon_manager, trial_mode from botspot.components.middlewares import error_handler from botspot.components.new import chat_binder, llm_provider -from botspot.components.qol import bot_commands_menu, bot_info, print_bot_url +from botspot.components.qol import bot_commands_menu, bot_info, print_bot_url, settings_menu from botspot.core.botspot_settings import BotspotSettings from botspot.core.dependency_manager import DependencyManager from botspot.utils.internal import Singleton @@ -60,6 +60,9 @@ def __init__( if self.settings.llm_provider.enabled: self.deps.llm_provider = llm_provider.initialize(self.settings.llm_provider) + + if self.settings.settings_menu.enabled: + self.deps.settings_registry = settings_menu.initialize(self.settings.settings_menu) def setup_dispatcher(self, dp: Dispatcher): """Setup dispatcher with components""" @@ -105,3 +108,6 @@ def setup_dispatcher(self, dp: Dispatcher): if self.settings.llm_provider.enabled: llm_provider.setup_dispatcher(dp) + + if self.settings.settings_menu.enabled: + settings_menu.setup_dispatcher(dp, self.settings.settings_menu) diff --git a/botspot/core/botspot_settings.py b/botspot/core/botspot_settings.py index 7c1d186..f2b56d9 100644 --- a/botspot/core/botspot_settings.py +++ b/botspot/core/botspot_settings.py @@ -17,6 +17,7 @@ from botspot.components.qol.bot_commands_menu import BotCommandsMenuSettings from botspot.components.qol.bot_info import BotInfoSettings from botspot.components.qol.print_bot_url import PrintBotUrlSettings +from botspot.components.qol.settings_menu import SettingsMenuSettings from botspot.utils.admin_filter import AdminFilterSettings from botspot.utils.send_safe import SendSafeSettings @@ -59,6 +60,7 @@ def friends(self) -> List[str]: admin_filter: AdminFilterSettings = AdminFilterSettings() chat_binder: ChatBinderSettings = ChatBinderSettings() llm_provider: LLMProviderSettings = LLMProviderSettings() + settings_menu: SettingsMenuSettings = SettingsMenuSettings() class Config: env_prefix = "BOTSPOT_" diff --git a/botspot/core/dependency_manager.py b/botspot/core/dependency_manager.py index 88bf491..bf23fe5 100644 --- a/botspot/core/dependency_manager.py +++ b/botspot/core/dependency_manager.py @@ -16,6 +16,7 @@ from botspot.components.data.user_data import UserManager from botspot.components.main.telethon_manager import TelethonManager from botspot.components.new.llm_provider import LLMProvider + from botspot.components.qol.settings_menu import SettingsRegistry class DependencyManager(metaclass=Singleton): @@ -38,6 +39,7 @@ def __init__( self._user_manager = None self._chat_binder = None self._llm_provider = None + self._settings_registry = None self.__dict__.update(kwargs) @property @@ -137,6 +139,16 @@ def llm_provider(self) -> "LLMProvider": @llm_provider.setter def llm_provider(self, value): self._llm_provider = value + + @property + def settings_registry(self) -> "SettingsRegistry": + if self._settings_registry is None: + raise RuntimeError("Settings Registry is not initialized") + return self._settings_registry + + @settings_registry.setter + def settings_registry(self, value): + self._settings_registry = value @classmethod def is_initialized(cls) -> bool: diff --git a/botspot/utils/deps_getters.py b/botspot/utils/deps_getters.py index b508dc4..48218f8 100644 --- a/botspot/utils/deps_getters.py +++ b/botspot/utils/deps_getters.py @@ -18,6 +18,8 @@ from aiogram.fsm.context import FSMContext from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorDatabase # noqa: F401 from telethon import TelegramClient + + from botspot.components.qol.settings_menu import SettingsRegistry # Core getters for bot and dispatcher @@ -60,6 +62,14 @@ async def get_telethon_client( return client +def get_settings_registry() -> "SettingsRegistry": + """Get the settings registry from dependency manager.""" + from botspot.core.dependency_manager import get_dependency_manager + + deps = get_dependency_manager() + return deps.settings_registry + + # Re-export all for convenience __all__ = [ "get_bot", @@ -70,4 +80,5 @@ async def get_telethon_client( "get_telethon_manager", "get_telethon_client", "get_mongo_client", + "get_settings_registry", ] diff --git a/examples/components_examples/settings_menu_demo/README.md b/examples/components_examples/settings_menu_demo/README.md new file mode 100644 index 0000000..a71cbd7 --- /dev/null +++ b/examples/components_examples/settings_menu_demo/README.md @@ -0,0 +1,36 @@ +# Settings Menu Demo + +This is a demonstration of the Botspot Settings Menu component, which provides a configurable UI for managing bot settings. + +## Features + +- Admin Settings: Configure botspot components (admin-only) +- User Settings: Customizable per-user preferences +- Inline Keyboard Interface: Easily navigate through setting categories and options + +## Usage + +1. Install dependencies with Poetry +2. Create a `.env` file using `sample.env` as a template +3. Run the bot: `python bot.py` + +## Commands + +- `/settings` - Open user settings menu +- `/admin_settings` - Open admin settings menu (admin-only) + +## Example Settings + +### Admin Settings +- Default LLM Model +- LLM Temperature +- Allow Everyone to Use LLM + +### User Settings +- Enable Notifications +- Preferred Language + +## Dependencies + +- User Data component (for storing user settings) +- Admin Filter (for admin-only settings) \ No newline at end of file diff --git a/examples/components_examples/settings_menu_demo/bot.py b/examples/components_examples/settings_menu_demo/bot.py new file mode 100644 index 0000000..852ff97 --- /dev/null +++ b/examples/components_examples/settings_menu_demo/bot.py @@ -0,0 +1,147 @@ +""" +Settings Menu Demo Bot + +This example shows how to use the Settings Menu component to provide a UI for managing +both admin settings (for components like LLM Provider) and user settings. +""" + +import asyncio +import logging +import os +from datetime import datetime + +from aiogram import Bot, Dispatcher +from aiogram.enums import ParseMode +from aiogram.filters import Command +from aiogram.types import Message +from dotenv import load_dotenv + +from botspot.components.data.user_data import User +from botspot.components.qol.settings_menu import SettingType, SettingCategory, SettingVisibility +from botspot.core.bot_manager import BotManager +from botspot.utils.deps_getters import get_settings_registry + + +# Configure logging +logging.basicConfig(level=logging.INFO) + + +# Extended User class with additional settings fields +class CustomUser(User): + """Custom user class with additional settings""" + + # Add settings dict to store user preferences + settings: dict = {} + + +async def register_custom_settings(): + """Register some custom settings for the demo""" + # Get settings registry + registry = get_settings_registry() + + # Add a custom user setting + registry.register_setting( + registry.Setting( + key="theme", + name="UI Theme", + description="Select your preferred UI theme", + type=SettingType.OPTION, + category=SettingCategory.APPEARANCE, + visibility=SettingVisibility.USER, + default_value="light", + options=["light", "dark", "system"] + ) + ) + + # Add a custom admin setting + registry.register_setting( + registry.Setting( + key="message_limit", + name="Daily Message Limit", + description="Maximum number of messages a user can send per day", + type=SettingType.NUMBER, + category=SettingCategory.ADVANCED, + visibility=SettingVisibility.ADMIN, + default_value=100, + min_value=10, + max_value=1000 + ) + ) + + +async def startup_actions(dispatcher: Dispatcher): + """Actions to perform at bot startup""" + await register_custom_settings() + logging.info("Settings menu component initialized") + + +async def check_setting_command(message: Message): + """Handler to check the value of a user setting""" + from botspot.components.qol.settings_menu import settings_manager + + # Get user ID + user_id = message.from_user.id + + # Get user settings + notifications = await settings_manager.get_user_setting(user_id, "notifications_enabled") + language = await settings_manager.get_user_setting(user_id, "user_preferred_language") + theme = await settings_manager.get_user_setting(user_id, "theme") + + # Format response + response = ( + "📊 Your current settings:\n\n" + f"🔔 Notifications: {'Enabled' if notifications else 'Disabled'}\n" + f"🌐 Language: {language}\n" + f"🎨 Theme: {theme}\n\n" + "Use /settings to change these values." + ) + + await message.answer(response) + + +async def main(): + """Main function to set up and run the bot""" + load_dotenv() # Load .env file + + # Configure the bot + token = os.getenv("BOT_TOKEN") + if token == "your_bot_token_here": + print("⚠️ Please set your bot token in the .env file!") + return + + username = os.getenv("BOTSPOT_ADMINS_STR") + if username == "your_username_here": + print("⚠️ Please set your Telegram username in BOTSPOT_ADMINS_STR to access admin settings") + + # Set user data to memory mode since we disabled MongoDB + os.environ["BOTSPOT_USER_DATA_STORAGE_TYPE"] = "memory" + + bot = Bot(token=token, parse_mode=ParseMode.HTML) + dp = Dispatcher() + + # Initialize BotManager with our custom User class + manager = BotManager(bot=bot, dispatcher=dp, user_class=CustomUser) + + # Register startup action + dp.startup.register(startup_actions) + + # Register check_setting command + @dp.message(Command("my_settings")) + async def my_settings_cmd(message: Message): + await check_setting_command(message) + + # Register all component handlers + manager.setup_dispatcher(dp) + + print(f"✅ Bot started! Search for @{bot.username} on Telegram") + print("Available commands:") + print(" /settings - Open user settings menu") + print(" /admin_settings - Open admin settings menu (admin only)") + print(" /my_settings - Check your current settings") + + # Start the bot + await dp.start_polling(bot) + + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/examples/components_examples/settings_menu_demo/sample.env b/examples/components_examples/settings_menu_demo/sample.env new file mode 100644 index 0000000..c697f09 --- /dev/null +++ b/examples/components_examples/settings_menu_demo/sample.env @@ -0,0 +1,24 @@ +# Bot token +BOT_TOKEN=your_bot_token_here + +# Admin settings +BOTSPOT_ADMINS_STR=your_username_here + +# Enable components +BOTSPOT_USER_DATA_ENABLED=true +BOTSPOT_SETTINGS_MENU_ENABLED=true +BOTSPOT_LLM_PROVIDER_ENABLED=true + +# Settings Menu config +BOTSPOT_SETTINGS_MENU_COMMAND_NAME=settings +BOTSPOT_SETTINGS_MENU_ADMIN_COMMAND_NAME=admin_settings + +# LLM Provider settings +BOTSPOT_LLM_PROVIDER_DEFAULT_MODEL=claude-3.7 +BOTSPOT_LLM_PROVIDER_DEFAULT_TEMPERATURE=0.7 +BOTSPOT_LLM_PROVIDER_ALLOW_EVERYONE=false + +# MongoDB settings (required for user settings) +BOTSPOT_MONGO_DATABASE_ENABLED=true +BOTSPOT_MONGO_DATABASE_URI=mongodb://localhost:27017 +BOTSPOT_MONGO_DATABASE_NAME=botspot_demo \ No newline at end of file