Module Development Best Practices
This document provides best practices for developing modules in ErisPulse.
Module Design
1. Single Responsibility Principle
Each module should only be responsible for one core function:
# Good design: each module handles only one function
class WeatherModule(BaseModule):
"""Weather query module"""
pass
class NewsModule(BaseModule):
"""News query module"""
pass
# Bad design: one module handles multiple unrelated functions
class UtilityModule(BaseModule):
"""Contains weather, news, jokes, and other functions"""
pass
2. Module Naming Convention
[project]
name = "ErisPulse-ModuleName" # Use ErisPulse- prefix
3. Clear Configuration Management
It is recommended to use declarative configuration (ConfigClass + BaseConfig) to gain type safety, automatic template generation, and WebUI form support:
from dataclasses import dataclass, field
from ErisPulse.Core.Bases import BaseConfig
@dataclass
class MyModuleConfig(BaseConfig):
api_url: str = field(default="https://api.example.com", metadata={
"description": {"i18n": "my_module.api_url", "default": "API URL"},
})
timeout: int = field(default=30, metadata={
"description": {"i18n": "my_module.timeout", "default": "Timeout (seconds)"},
})
cache_ttl: int = field(default=3600, metadata={
"description": {"i18n": "my_module.cache_ttl", "default": "Cache TTL (seconds)"},
})
class MyModule(BaseModule):
ConfigClass = MyModuleConfig
async def do_something(self):
cfg = self.cfg # Type-safe, real-time reading
await self._fetch(cfg.api_url, timeout=cfg.timeout)
You can also continue using manual configuration storage reading and writing (see Module Core Concepts).
Declarative Translation Keys (v2.7.0+)
Modules can centrally declare translation keys via I18nClass, and the framework automatically registers them to the i18n system, eliminating the need to manually call i18n.register().
from ErisPulse.Core.Bases import BaseI18n, I18nKey
class MyModule(BaseModule):
class I18nClass(BaseI18n):
# Business translation keys with placeholders
welcome: I18nKey = I18nKey(
default="Welcome, {name}!",
zh_CN="欢迎你,{name}!",
zh_TW="歡迎你,{name}!",
en="Welcome, {name}!",
ja="ようこそ、{name}!",
ru="Добро пожаловать, {name}!",
)
# Configuration field description translations
api_url: I18nKey = I18nKey(
default="API URL",
zh_CN="API 地址",
zh_TW="API 位址",
en="API URL",
ja="API URL",
ru="API URL",
)
See i18n documentation for detailed usage.
Asynchronous Programming
1. Use Asynchronous Libraries
# Recommended to use SDK built-in HTTP client (asynchronous, automatic logging and statistics)
from ErisPulse.Core import client
class MyModule(BaseModule):
async def fetch_data(self, url):
resp = await client.get(url)
return await resp.json()
# Alternatively, use sdk.client (same effect)
from ErisPulse import sdk
class MyModule(BaseModule):
async def fetch_data(self, url):
resp = await sdk.client.get(url)
return await resp.json()
# Do not use aiohttp directly (not easy for framework to manage uniformly)
import aiohttp
class MyModule(BaseModule):
async def fetch_data(self, url):
async with aiohttp.ClientSession() as session:
async with session.get(url) as response:
return await response.json()
# Do not use requests (synchronous, blocks the event loop)
import requests
class MyModule(BaseModule):
def fetch_data(self, url):
return requests.get(url).json() # Blocks the event loop
2. Correct Asynchronous Operations
from ErisPulse.Core.Event import Event # event: Event annotation provides IDE completion
async def handle_command(self, event: Event):
# Time-consuming operations that need to wait for results: directly await (clear lifecycle)
result = await self._long_operation()
async def on_load(self, event: dict):
# Background tasks (polling/timing/fire-and-forget): use self.spawn(),
# when the module unloads, the framework cancels in on_unload, avoiding holding self causing leaks
self.spawn(self._poll())
Note
Background tasks are recommended to use self.spawn() (ErisPulse 2.8.0+). From 2.8.3 onwards, raw asyncio.create_task
will also be automatically registered to the module (Task Factory automatically registers, cancels in on_unload, no longer leaks self reference);
self.spawn() remains the recommended approach—supports non-main thread scheduling back to main loop, explicit owner= specification.
Before 2.8.3, raw tasks are not registered, holding self reference causing module instance unable to be recycled
(hot reload leak), must use self.spawn(). See Lifecycle Management.
3. Resource Management
async def on_load(self, event):
# SDK client automatically manages connection pool, no need to manually create session
pass
async def on_unload(self, event):
# If you need a custom client, remember to clean up resources
pass
Event Handling
1. Use Event Wrapper Class
# Use convenient methods of Event wrapper class
@command("info")
async def info_command(event: Event):
user_id = event.get_user_id()
nickname = event.get_user_nickname()
await event.reply(f"Hello, {nickname}!")
# Rather than directly accessing dictionary
@command("info")
async def info_command(event: Event):
user_id = event["user_id"] # Less clear, prone to errors
2. Reasonable Use of Lazy Loading
# Low-frequency command module: declare activate_on trigger, automatically activates on first matching command arrival (maintain lazy loading)
class CommandModule(BaseModule):
@staticmethod
def get_load_strategy():
return ModuleLoadStrategy(lazy_load=True, activate_on=[
{"command": {"name": "dice", "help": "Roll a dice", "aliases": ["d"]}},
])
# Low-frequency listener module: declare event trigger, automatically activates on event arrival
class ListenerModule(BaseModule):
@staticmethod
def get_load_strategy():
return ModuleLoadStrategy(lazy_load=True, activate_on=[
{"notice": "group_member_increase"},
])
# High-frequency triggers (every message needs processing) or modules that must be ready at startup: load immediately
class HotListenerModule(BaseModule):
@staticmethod
def get_load_strategy():
return ModuleLoadStrategy(lazy_load=False)
# Utility modules are suitable for lazy loading
class UtilityModule(BaseModule):
@staticmethod
def get_load_strategy():
return ModuleLoadStrategy(lazy_load=True)
The complete syntax of
activate_on(event three forms / command shorthand and dict declaration / help fallback chain) is detailed in Lazy Loading Module System.
3. Event Handler Registration
async def on_load(self, event):
# Register event handlers in on_load
@command("hello")
async def hello_handler(event: Event):
await event.reply("Hello!")
@message.on_group_message()
async def group_handler(event: Event):
self.logger.info("Received group message")
# No need to manually unregister, framework handles automatically
Utility Module: When Hosting Other Things, You Must Catch "Unload Notification"
When is it needed: Your module holds things for other modules (timers, subscribers, connections, cache entries...). If these references are not discarded after the other module unloads, the other instance can never be recycled—this is the most common source of memory leaks in utility modules.
from ErisPulse.Core.Bases import BaseModule
from ErisPulse.runtime import off_cleanup, on_cleanup
class MyToolModule(BaseModule):
def __init__(self):
self._entries = {} # {module name: hosted things}
def register(self, entry):
owner = on_cleanup(self._drop) # ① Register cleanup chain on registration, automatically recognize caller
self._entries.setdefault(owner, []).append(entry)
def _drop(self, owner: str):
self._entries.pop(owner, None) # ② When the other module unloads, framework automatically calls: discard its things
async def on_unload(self, event):
off_cleanup(self._drop) # ③ Unregister hook before unloading self
That's it. The framework ensures:
- When the other module is unloaded / disabled (or adapter closed),
_drop("other module name")will definitely be called - Caller identification is automatic: The caller directly calls
sdk.MyToolModule.register(...)inon_load, or calls viasdk.module.call("MyToolModule", "register", ...); both can correctly identify who it is - No need to worry about timing—the hook triggers in the framework cleanup chain, earlier than leak detection, no false positives
Consequences of not hooking in: The instance cannot be recycled when the other purge unloads completely (leak diagnosis reports "un回收able"); if the other module does not unregister you in on_unload, the leak is permanent.
Ordinary modules (not hosting other things) do not need to care about this—framework resources (commands / handlers / routing / background tasks...) are automatically cleaned up on unload.
Trigger timing, caller identification rules, timeout and fault tolerance details are detailed in Ownership System · Utility Module Guide.
Error Handling
1. Categorized Exception Handling
from ErisPulse.Core.Bases.errors import ClientError
async def handle_event(self, event: Event):
try:
result = await self._process(event)
except ValueError as e:
# Expected business error
self.logger.warning(f"Business warning: {e}")
await event.reply(f"Parameter error: {e}")
except ClientError as e:
# Network error (sdk.client's underlying aiohttp exception is automatically converted)
self.logger.error(f"Network error {e.method} {e.url}: {e}")
await event.reply("Network request failed, please try again later")
except Exception as e:
# Unexpected error
self.logger.error(f"Unknown error: {e}", exc_info=True)
await event.reply("Processing failed, please contact the administrator")
raise
2. Timeout Handling
# Recommended to use SDK built-in client (with timeout and retry)
from ErisPulse.Core import client
from ErisPulse.Core.Bases.errors import ClientTimeoutError
async def fetch_with_timeout(self, url, timeout=30):
try:
resp = await client.get(url, timeout=timeout)
return await resp.json()
except ClientTimeoutError:
self.logger.warning(f"Request timeout: {url}")
raise
Storage System
1. Use Transactions
# Use transaction to ensure data consistency
async def update_user(self, user_id, data):
with self.sdk.storage.transaction():
self.sdk.storage.set(f"user:{user_id}:profile", data["profile"])
self.sdk.storage.set(f"user:{user_id}:settings", data["settings"])
# ❌ Not using transaction may cause data inconsistency
async def update_user(self, user_id, data):
self.sdk.storage.set(f"user:{user_id}:profile", data["profile"])
# If this fails, the above setting cannot be rolled back
self.sdk.storage.set(f"user:{user_id}:settings", data["settings"])
2. Batch Operations
# Use batch operations to improve performance
def cache_multiple_items(self, items):
self.sdk.storage.set_multi({
f"item:{k}": v for k, v in items.items()
})
# ❌ Multiple calls are inefficient
def cache_multiple_items(self, items):
for k, v in items.items():
self.sdk.storage.set(f"item:{k}", v)
Logging
1. Reasonable Use of Log Levels
# DEBUG: Detailed debug information (only during development)
self.logger.debug(f"Input parameters: {params}")
# INFO: Normal operation information
self.logger.info("Module loaded")
self.logger.info(f"Processing request: {request_id}")
# WARNING: Warning information, does not affect main functionality
self.logger.warning(f"Configuration item {key} not set, using default value")
self.logger.warning("API response slow, may need optimization")
# ERROR: Error information
self.logger.error(f"API request failed: {e}")
self.logger.error(f"Event processing failed: {e}", exc_info=True)
# CRITICAL: Fatal error, needs immediate handling
self.logger.critical("Database connection failed, bot cannot run normally")
2. Structured Logging
# Use structured logging for easier parsing
self.logger.info(f"Processing request: request_id={request_id}, user_id={user_id}, duration={duration}ms")
# ❌ Use unstructured logging
self.logger.info(f"Processing request, from user {user_id}, took {duration} milliseconds")
Performance Optimization
1. Use Caching
class MyModule(BaseModule):
def __init__(self):
self._cache = {}
self._cache_lock = asyncio.Lock()
async def get_data(self, key):
async with self._cache_lock:
if key in self._cache:
return self._cache[key]
# Get from database
data = await self._fetch_from_db(key)
# Cache data
self._cache[key] = data
return data
2. Avoid Blocking Operations
# Use asynchronous operations
async def process_message(self, event: Event):
# Asynchronous processing
await self._async_process(event)
# ❌ Blocking operation
async def process_message(self, event: Event):
# Synchronous operation, blocks the event loop
result = self._sync_process(event)
Security
1. Sensitive Data Protection
# Sensitive data stored in configuration (declarative ConfigClass, secret fields not in logs/export)
from dataclasses import dataclass, field
from ErisPulse.Core.Bases import BaseModule, BaseConfig
@dataclass
class MyModuleConfig(BaseConfig):
api_key: str = field(
default="",
metadata={"description": "API key", "secret": True},
)
class MyModule(BaseModule):
ConfigClass = MyModuleConfig
def check_api_key(self):
if not self.cfg.api_key or self.cfg.api_key == "YOUR_API_KEY_HERE":
raise ValueError("Please configure a valid API key in config.toml")
# ❌ Hardcoded sensitive data
class MyModule(BaseModule):
API_KEY = "sk-1234567890" # Don't do this!
2. Input Validation
# Validate user input
async def process_command(self, event: Event):
user_input = event.get_text()
# Validate input length
if len(user_input) > 1000:
await event.reply("Input too long, please re-enter")
return
# Validate input format
if not re.match(r'^[a-zA-Z0-9]+$', user_input):
await event.reply("Invalid input format")
return
Testing
1. Unit Tests
import pytest
from ErisPulse.Core.Bases import BaseModule
class TestMyModule:
def test_config_defaults(self):
"""Test configuration default values"""
config = MyModule.ConfigClass()
assert config.timeout == 30
2. Integration Tests
@pytest.mark.asyncio
async def test_command_handling():
"""Test command handling"""
module = MyModule()
await module.on_load({})
# Simulate command event
event = create_test_command_event("hello")
await module.handle_command(event)
Deployment
1. Version Management
[project]
name = "ErisPulse-MyModule"
version = "1.0.0"
Follow semantic versioning:
- MAJOR.MINOR.PATCH
- Major version: incompatible API changes
- Minor version: backward-compatible feature additions
- Patch version: backward-compatible bug fixes
2. README Header
The README generated by epsdk create already includes the ErisPulse header (Logo + badge row). Two recommended modes:
Mode A — Only ErisPulse Logo (Default):
<div align="center">
<img src="https://raw.githubusercontent.com/ErisPulse/ErisPulse/main/.github/assets/ErisPulseLogo.png" width="180" alt="MyModule" />
# MyModule
**One-sentence description**
<p>
<a href="https://pypi.org/project/ErisPulse-MyModule/"><img src="https://img.shields.io/pypi/v/ErisPulse-MyModule?style=for-the-badge&logo=pypi&logoColor=white" alt="PyPI"></a>
<a href="https://pypi.org/project/ErisPulse-MyModule/"><img src="https://img.shields.io/badge/Python-3.10+-FFD43B?style=for-the-badge&logo=python&logoColor=blue" alt="Python"></a>
<a href="./LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue?style=for-the-badge" alt="License"></a>
<a href="https://github.com/ErisPulse/ErisPulse"><img src="https://img.shields.io/badge/Powered_by-ErisPulse-FF6B9D?style=for-the-badge&logo=bookstack&logoColor=white" alt="ErisPulse"></a>
</p>
</div>
Mode B — Module Icon × ErisPulse Logo (with custom icon):
<div align="center">
<img src=".github/assets/MyModuleIcon.svg" width="120" alt="MyModule" />
<span style="font-size:44px;color:#c8c8c8;margin:0 18px;vertical-align:middle;">×</span>
<img src="https://raw.githubusercontent.com/ErisPulse/ErisPulse/main/.github/assets/ErisPulseLogo.png" height="120" alt="ErisPulse" />
# MyModule
(Badge row same as above)
</div>
You can add GitHub Stars, Downloads, and other badges as needed. The logo can also be downloaded to the project locally (.github/assets/ErisPulseLogo.png) and referenced with a relative path.
Related Documentation
- Module Development Getting Started - Create your first module
- Module Core Concepts - Understand module architecture
- Event Wrapper Class - Detailed event handling