Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
57 changes: 57 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,28 @@ docker compose logs -f rule-bot
| `ALLOWED_GROUP_IDS` | 群组模式允许的群组 ID,逗号分隔 | 空 |
| `ADMIN_USER_IDS` | 管理员 Telegram 用户 ID,逗号分隔 | 空 |
| `TZ` | 时区 | `Asia/Shanghai` |
| `DNS_CACHE_TTL` | DNS A 记录缓存秒数 | `60` |
| `DNS_CACHE_SIZE` | DNS A 记录缓存上限 | `1024` |
| `NS_CACHE_TTL` | DNS NS 记录缓存秒数 | `300` |
| `NS_CACHE_SIZE` | DNS NS 记录缓存上限 | `512` |
| `DNS_MAX_CONCURRENCY` | DNS 并发限制 | `20` |
| `DNS_CONN_LIMIT` | DNS 全局连接池上限 | `30` |
| `DNS_CONN_LIMIT_PER_HOST` | DNS 单主机连接上限 | `10` |
| `DNS_TIMEOUT_TOTAL` | DNS 请求总超时 | `10` |
| `DNS_TIMEOUT_CONNECT` | DNS 连接超时 | `3` |
| `GEOSITE_CACHE_TTL` | GeoSite 查询缓存秒数 | `3600` |
| `GEOSITE_CACHE_SIZE` | GeoSite 查询缓存上限 | `2048` |
| `GEOIP_CACHE_TTL` | GeoIP 缓存秒数 | `21600` |
| `GEOIP_CACHE_SIZE` | GeoIP 缓存上限 | `4096` |
| `GITHUB_FILE_CACHE_TTL` | 规则文件缓存秒数 | `60` |
| `GITHUB_FILE_CACHE_SIZE` | 规则文件缓存上限 | `4` |
| `METRICS_ENABLED` | 开启 metrics 导出 | `false` |
| `METRICS_EXPORT_PATH` | metrics 输出路径 | `/tmp/rule-bot-metrics.json` |
| `METRICS_EXPORT_INTERVAL` | metrics 导出间隔秒数 | `30` |
| `METRICS_RESET_ON_EXPORT` | 导出后清零 | `false` |
| `MEMORY_SOFT_LIMIT_MB` | 进程软限制 MB | `256` |
| `MEMORY_HARD_LIMIT_MB` | 进程硬限制 MB | `512` |
| `MEMORY_TRIM_ENABLED` | 启用内存修剪 | `true` |

</details>

Expand Down Expand Up @@ -188,6 +210,41 @@ docker compose logs -f rule-bot
- Python 3.12+
- `pip install -r requirements.txt`

## 🧪 1C1G 运行建议

建议对容器设置内存上限与 CPU 配额,避免全机抖动:

```yaml
services:
rule-bot:
mem_limit: 256m
mem_reservation: 128m
cpus: "0.5"
```

如果你使用的是 Swarm 模式,可改用 `deploy.resources` 语法。

**Swap/RSS 观测**

```bash
pid=$(pgrep -f "python -m src.main" | head -n 1)
grep -E "VmRSS|VmSize|VmSwap" /proc/$pid/status
```

**性能采样(需开启 metrics 导出)**

```bash
export METRICS_ENABLED=1
export METRICS_EXPORT_INTERVAL=30
python tools/profile_runtime.py --process-name "python -m src.main" --duration 300 --interval 5
```

**10 分钟压力模拟**

```bash
python tools/stress_sim.py --duration 600 --concurrency 4 --pause 0.5
```

## 📄 许可证

GPLv3
58 changes: 35 additions & 23 deletions src/bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from .config import Config
from .data_manager import DataManager
from .handlers import HandlerManager, GroupHandler
from .utils.metrics import EXPORTER


class RuleBot:
Expand All @@ -26,18 +27,36 @@ def __init__(self, config: Config, data_manager: DataManager):
self.app: Optional[Application] = None
self.handler_manager = None # 延迟初始化
self.group_handler = None # 群组处理器
self._metrics_task = None

async def stop(self):
"""停止机器人"""
logger.info("正在停止机器人...")
if self.handler_manager:
await self.handler_manager.stop()
if self._metrics_task:
await EXPORTER.stop()
if self.app:
await self.app.stop()
await self.app.shutdown()
try:
if self.app.updater and self.app.updater.running:
await self.app.updater.stop()
except Exception as e:
logger.debug(f"停止 updater 失败: {e}")
try:
if self.app.running:
await self.app.stop()
except Exception as e:
logger.debug(f"停止 app 失败: {e}")
try:
if self.app.initialized:
await self.app.shutdown()
except Exception as e:
logger.debug(f"关闭 app 失败: {e}")
if self.data_manager:
await self.data_manager.close()
logger.info("机器人已停止")

def start(self):
async def start(self):
"""启动机器人"""
try:
# 创建应用
Expand All @@ -54,30 +73,23 @@ def start(self):

# 启动轮询
logger.info("机器人启动成功,开始轮询...")

# 在新的事件循环中运行机器人
import asyncio

async def run_bot():
try:
async with self.app:
await self.handler_manager.start() # 显式启动服务(如 DNS Session)
await self.app.start()
await self.app.updater.start_polling(
allowed_updates=Update.ALL_TYPES,
drop_pending_updates=True # 丢弃待处理的更新,避免发送旧消息
)
# 保持运行
await asyncio.Event().wait()
finally:
await self.stop()

# 使用新的事件循环运行
asyncio.run(run_bot())

async with self.app:
await self.handler_manager.start() # 显式启动服务(如 DNS Session)
await self.app.start()
self._metrics_task = EXPORTER.start()
await self.app.updater.start_polling(
allowed_updates=Update.ALL_TYPES,
drop_pending_updates=True # 丢弃待处理的更新,避免发送旧消息
)
# 保持运行
await asyncio.Event().wait()

except Exception as e:
logger.error(f"机器人启动失败: {e}")
raise
finally:
await self.stop()

def _register_handlers(self):
"""注册所有处理器"""
Expand Down
57 changes: 57 additions & 0 deletions src/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,34 @@ def __init__(self):

# 数据目录(可选)
self.DATA_DIR = os.getenv("DATA_DIR", "").strip()

# 性能与缓存配置
self.DNS_CACHE_TTL = self._parse_int_env("DNS_CACHE_TTL", 60, min_value=0)
self.DNS_CACHE_SIZE = self._parse_int_env("DNS_CACHE_SIZE", 1024, min_value=0)
self.NS_CACHE_TTL = self._parse_int_env("NS_CACHE_TTL", 300, min_value=0)
self.NS_CACHE_SIZE = self._parse_int_env("NS_CACHE_SIZE", 512, min_value=0)
self.DNS_MAX_CONCURRENCY = self._parse_int_env("DNS_MAX_CONCURRENCY", 20, min_value=1)
self.DNS_CONN_LIMIT = self._parse_int_env("DNS_CONN_LIMIT", 30, min_value=1)
self.DNS_CONN_LIMIT_PER_HOST = self._parse_int_env("DNS_CONN_LIMIT_PER_HOST", 10, min_value=1)
self.DNS_TIMEOUT_TOTAL = self._parse_int_env("DNS_TIMEOUT_TOTAL", 10, min_value=1)
self.DNS_TIMEOUT_CONNECT = self._parse_int_env("DNS_TIMEOUT_CONNECT", 3, min_value=1)

self.GEOSITE_CACHE_TTL = self._parse_int_env("GEOSITE_CACHE_TTL", 3600, min_value=0)
self.GEOSITE_CACHE_SIZE = self._parse_int_env("GEOSITE_CACHE_SIZE", 2048, min_value=0)
self.GEOIP_CACHE_TTL = self._parse_int_env("GEOIP_CACHE_TTL", 21600, min_value=0)
self.GEOIP_CACHE_SIZE = self._parse_int_env("GEOIP_CACHE_SIZE", 4096, min_value=0)

self.GITHUB_FILE_CACHE_TTL = self._parse_int_env("GITHUB_FILE_CACHE_TTL", 60, min_value=0)
self.GITHUB_FILE_CACHE_SIZE = self._parse_int_env("GITHUB_FILE_CACHE_SIZE", 4, min_value=0)

# Metrics 配置
self.METRICS_ENABLED = self._parse_bool_env("METRICS_ENABLED", False)
self.METRICS_EXPORT_PATH = os.getenv("METRICS_EXPORT_PATH", "/tmp/rule-bot-metrics.json")
self.METRICS_EXPORT_INTERVAL = self._parse_int_env("METRICS_EXPORT_INTERVAL", 30, min_value=1)
self.METRICS_RESET_ON_EXPORT = self._parse_bool_env("METRICS_RESET_ON_EXPORT", False)

# 内存修剪(glibc malloc_trim)
self.MEMORY_TRIM_ENABLED = self._parse_bool_env("MEMORY_TRIM_ENABLED", True)

# 群组验证配置(用于私聊模式下验证用户是否在群组中)
required_group_id_raw = os.getenv("REQUIRED_GROUP_ID", "").strip()
Expand Down Expand Up @@ -201,3 +229,32 @@ def _parse_doh_servers(self, value: str, defaults: Dict[str, str]) -> Dict[str,
return defaults

return servers

def _parse_int_env(
self,
key: str,
default: int,
min_value: Optional[int] = None,
max_value: Optional[int] = None
) -> int:
raw = os.getenv(key, "").strip()
if not raw:
return default
try:
value = int(raw)
except ValueError:
logger.warning(f"无效的 {key}: {raw},使用默认值 {default}")
return default
if min_value is not None and value < min_value:
logger.warning(f"{key} 小于最小值 {min_value},使用默认值 {default}")
return default
if max_value is not None and value > max_value:
logger.warning(f"{key} 大于最大值 {max_value},使用默认值 {default}")
return default
return value

def _parse_bool_env(self, key: str, default: bool = False) -> bool:
raw = os.getenv(key, "").strip().lower()
if not raw:
return default
return raw in ("1", "true", "yes", "on")
Loading