lockbot 2.4.0__tar.gz → 2.5.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {lockbot-2.4.0/python/lockbot.egg-info → lockbot-2.5.1}/PKG-INFO +2 -2
- {lockbot-2.4.0 → lockbot-2.5.1}/pyproject.toml +2 -2
- lockbot-2.5.1/python/lockbot/backend/app/bots/manager.py +154 -0
- {lockbot-2.4.0 → lockbot-2.5.1}/python/lockbot/backend/app/bots/router.py +4 -1
- {lockbot-2.4.0 → lockbot-2.5.1}/python/lockbot/backend/app/bots/schemas.py +42 -1
- {lockbot-2.4.0 → lockbot-2.5.1}/python/lockbot/backend/app/config.py +1 -1
- {lockbot-2.4.0 → lockbot-2.5.1}/python/lockbot/backend/app/main.py +4 -3
- {lockbot-2.4.0 → lockbot-2.5.1}/python/lockbot/core/base_bot.py +22 -31
- lockbot-2.5.1/python/lockbot/core/bot_instance.py +79 -0
- {lockbot-2.4.0 → lockbot-2.5.1}/python/lockbot/core/device_bot.py +36 -13
- {lockbot-2.4.0 → lockbot-2.5.1}/python/lockbot/core/i18n/en.py +2 -0
- {lockbot-2.4.0 → lockbot-2.5.1}/python/lockbot/core/i18n/zh.py +1 -0
- {lockbot-2.4.0 → lockbot-2.5.1}/python/lockbot/core/node_bot.py +34 -10
- {lockbot-2.4.0 → lockbot-2.5.1}/python/lockbot/core/queue_bot.py +44 -9
- lockbot-2.5.1/python/lockbot/core/scheduler.py +202 -0
- {lockbot-2.4.0 → lockbot-2.5.1/python/lockbot.egg-info}/PKG-INFO +2 -2
- {lockbot-2.4.0 → lockbot-2.5.1}/python/lockbot.egg-info/SOURCES.txt +3 -1
- {lockbot-2.4.0 → lockbot-2.5.1}/python/lockbot.egg-info/requires.txt +1 -1
- {lockbot-2.4.0 → lockbot-2.5.1}/tools/create_super_admin.py +9 -9
- {lockbot-2.4.0 → lockbot-2.5.1}/tools/gen_keys.py +3 -1
- lockbot-2.5.1/tools/reset_super_admin_password.py +112 -0
- lockbot-2.4.0/python/lockbot/backend/app/bots/manager.py +0 -106
- lockbot-2.4.0/python/lockbot/core/bot_instance.py +0 -49
- {lockbot-2.4.0 → lockbot-2.5.1}/LICENSE +0 -0
- {lockbot-2.4.0 → lockbot-2.5.1}/MANIFEST.in +0 -0
- {lockbot-2.4.0 → lockbot-2.5.1}/README.md +0 -0
- {lockbot-2.4.0 → lockbot-2.5.1}/python/lockbot/__init__.py +0 -0
- {lockbot-2.4.0 → lockbot-2.5.1}/python/lockbot/backend/__init__.py +0 -0
- {lockbot-2.4.0 → lockbot-2.5.1}/python/lockbot/backend/app/__init__.py +0 -0
- {lockbot-2.4.0 → lockbot-2.5.1}/python/lockbot/backend/app/admin/__init__.py +0 -0
- {lockbot-2.4.0 → lockbot-2.5.1}/python/lockbot/backend/app/admin/router.py +0 -0
- {lockbot-2.4.0 → lockbot-2.5.1}/python/lockbot/backend/app/audit/__init__.py +0 -0
- {lockbot-2.4.0 → lockbot-2.5.1}/python/lockbot/backend/app/audit/models.py +0 -0
- {lockbot-2.4.0 → lockbot-2.5.1}/python/lockbot/backend/app/audit/router.py +0 -0
- {lockbot-2.4.0 → lockbot-2.5.1}/python/lockbot/backend/app/audit/service.py +0 -0
- {lockbot-2.4.0 → lockbot-2.5.1}/python/lockbot/backend/app/auth/__init__.py +0 -0
- {lockbot-2.4.0 → lockbot-2.5.1}/python/lockbot/backend/app/auth/dependencies.py +0 -0
- {lockbot-2.4.0 → lockbot-2.5.1}/python/lockbot/backend/app/auth/models.py +0 -0
- {lockbot-2.4.0 → lockbot-2.5.1}/python/lockbot/backend/app/auth/router.py +0 -0
- {lockbot-2.4.0 → lockbot-2.5.1}/python/lockbot/backend/app/auth/schemas.py +0 -0
- {lockbot-2.4.0 → lockbot-2.5.1}/python/lockbot/backend/app/bots/__init__.py +0 -0
- {lockbot-2.4.0 → lockbot-2.5.1}/python/lockbot/backend/app/bots/encryption.py +0 -0
- {lockbot-2.4.0 → lockbot-2.5.1}/python/lockbot/backend/app/bots/models.py +0 -0
- {lockbot-2.4.0 → lockbot-2.5.1}/python/lockbot/backend/app/bots/webhook_handler.py +0 -0
- {lockbot-2.4.0 → lockbot-2.5.1}/python/lockbot/backend/app/database.py +0 -0
- {lockbot-2.4.0 → lockbot-2.5.1}/python/lockbot/backend/app/logs/__init__.py +0 -0
- {lockbot-2.4.0 → lockbot-2.5.1}/python/lockbot/backend/app/rate_limit.py +0 -0
- {lockbot-2.4.0 → lockbot-2.5.1}/python/lockbot/backend/app/settings/__init__.py +0 -0
- {lockbot-2.4.0 → lockbot-2.5.1}/python/lockbot/backend/app/settings/models.py +0 -0
- {lockbot-2.4.0 → lockbot-2.5.1}/python/lockbot/backend/app/settings/router.py +0 -0
- {lockbot-2.4.0 → lockbot-2.5.1}/python/lockbot/core/__init__.py +0 -0
- {lockbot-2.4.0 → lockbot-2.5.1}/python/lockbot/core/config.py +0 -0
- {lockbot-2.4.0 → lockbot-2.5.1}/python/lockbot/core/device_usage_alert.py +0 -0
- {lockbot-2.4.0 → lockbot-2.5.1}/python/lockbot/core/device_usage_utils.py +0 -0
- {lockbot-2.4.0 → lockbot-2.5.1}/python/lockbot/core/entry.py +0 -0
- {lockbot-2.4.0 → lockbot-2.5.1}/python/lockbot/core/env.py +0 -0
- {lockbot-2.4.0 → lockbot-2.5.1}/python/lockbot/core/handler.py +0 -0
- {lockbot-2.4.0 → lockbot-2.5.1}/python/lockbot/core/i18n/__init__.py +0 -0
- {lockbot-2.4.0 → lockbot-2.5.1}/python/lockbot/core/io.py +0 -0
- {lockbot-2.4.0 → lockbot-2.5.1}/python/lockbot/core/message_adapter.py +0 -0
- {lockbot-2.4.0 → lockbot-2.5.1}/python/lockbot/core/msg_utils.py +0 -0
- {lockbot-2.4.0 → lockbot-2.5.1}/python/lockbot/core/platforms/__init__.py +0 -0
- {lockbot-2.4.0 → lockbot-2.5.1}/python/lockbot/core/platforms/infoflow.py +0 -0
- {lockbot-2.4.0 → lockbot-2.5.1}/python/lockbot/core/request.py +0 -0
- {lockbot-2.4.0 → lockbot-2.5.1}/python/lockbot/core/utils.py +0 -0
- {lockbot-2.4.0 → lockbot-2.5.1}/python/lockbot.egg-info/dependency_links.txt +0 -0
- {lockbot-2.4.0 → lockbot-2.5.1}/python/lockbot.egg-info/top_level.txt +0 -0
- {lockbot-2.4.0 → lockbot-2.5.1}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: lockbot
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.5.1
|
|
4
4
|
Summary: Cluster resource management bot for IM platforms
|
|
5
5
|
Author-email: Jianbang Yang <yangjianbang112@gmail.com>
|
|
6
6
|
License: MIT
|
|
@@ -36,7 +36,7 @@ Requires-Dist: httpx
|
|
|
36
36
|
Requires-Dist: slowapi
|
|
37
37
|
Provides-Extra: dev
|
|
38
38
|
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
39
|
-
Requires-Dist: ruff
|
|
39
|
+
Requires-Dist: ruff==0.15.10; extra == "dev"
|
|
40
40
|
Dynamic: license-file
|
|
41
41
|
|
|
42
42
|
# lockbot
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "lockbot"
|
|
7
|
-
version = "2.
|
|
7
|
+
version = "2.5.1"
|
|
8
8
|
description = "Cluster resource management bot for IM platforms"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = {text = "MIT"}
|
|
@@ -47,7 +47,7 @@ dependencies = [
|
|
|
47
47
|
[project.optional-dependencies]
|
|
48
48
|
dev = [
|
|
49
49
|
"pytest>=7.0",
|
|
50
|
-
"ruff
|
|
50
|
+
"ruff==0.15.10",
|
|
51
51
|
]
|
|
52
52
|
|
|
53
53
|
[project.urls]
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Bot instance manager (in-process, shared port).
|
|
3
|
+
|
|
4
|
+
All bots run inside the FastAPI process as BotInstance objects.
|
|
5
|
+
Webhook callbacks arrive at /api/bots/webhook/{bot_id} and are
|
|
6
|
+
dispatched to the corresponding BotInstance.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
import threading
|
|
12
|
+
|
|
13
|
+
from lockbot.core.bot_instance import BotInstance
|
|
14
|
+
from lockbot.core.scheduler import BotScheduler
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class BotManager:
|
|
20
|
+
"""
|
|
21
|
+
Manages bot instances running in the FastAPI process.
|
|
22
|
+
|
|
23
|
+
Usage:
|
|
24
|
+
manager = BotManager()
|
|
25
|
+
manager.start_scheduler()
|
|
26
|
+
manager.start_bot(bot_id=1, config_dict={...})
|
|
27
|
+
instance = manager.get_instance(bot_id=1)
|
|
28
|
+
manager.stop_bot(bot_id=1)
|
|
29
|
+
manager.shutdown_all()
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self):
|
|
33
|
+
self._bots: dict[int, BotInstance] = {}
|
|
34
|
+
self._lock = threading.Lock()
|
|
35
|
+
self._scheduler = BotScheduler()
|
|
36
|
+
|
|
37
|
+
def start_scheduler(self) -> None:
|
|
38
|
+
"""Start the shared scheduler background thread. Call once at app startup."""
|
|
39
|
+
self._scheduler.start()
|
|
40
|
+
|
|
41
|
+
def is_running(self, bot_id: int) -> bool:
|
|
42
|
+
return bot_id in self._bots
|
|
43
|
+
|
|
44
|
+
def get_pid(self, bot_id: int) -> int | None:
|
|
45
|
+
if bot_id in self._bots:
|
|
46
|
+
return os.getpid()
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
def get_instance(self, bot_id: int) -> BotInstance | None:
|
|
50
|
+
"""Get the BotInstance for webhook dispatching."""
|
|
51
|
+
return self._bots.get(bot_id)
|
|
52
|
+
|
|
53
|
+
def start_bot(self, bot_id: int, config_dict: dict) -> int:
|
|
54
|
+
"""
|
|
55
|
+
Start a bot instance in-process.
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
PID of the current process.
|
|
59
|
+
|
|
60
|
+
Raises:
|
|
61
|
+
RuntimeError: If the bot is already running.
|
|
62
|
+
"""
|
|
63
|
+
with self._lock:
|
|
64
|
+
if bot_id in self._bots:
|
|
65
|
+
raise RuntimeError(f"Bot {bot_id} is already running")
|
|
66
|
+
|
|
67
|
+
instance = BotInstance(
|
|
68
|
+
config_dict.get("BOT_TYPE", "NODE"),
|
|
69
|
+
config_dict,
|
|
70
|
+
scheduler=self._scheduler, # managed mode: shared scheduler
|
|
71
|
+
)
|
|
72
|
+
self._bots[bot_id] = instance
|
|
73
|
+
|
|
74
|
+
# Schedule first check immediately (outside _lock to avoid lock ordering issues)
|
|
75
|
+
self._scheduler.add(
|
|
76
|
+
bot_id,
|
|
77
|
+
instance,
|
|
78
|
+
delay=0.0,
|
|
79
|
+
on_fatal_error=self._make_fatal_error_handler(bot_id),
|
|
80
|
+
)
|
|
81
|
+
# Wire up state-change callback so new locks wake the scheduler immediately
|
|
82
|
+
instance.bot._on_state_changed = lambda: self._scheduler.reschedule_soon(bot_id)
|
|
83
|
+
logger.info("Started bot %d in-process (type=%s)", bot_id, instance.bot_type)
|
|
84
|
+
return os.getpid()
|
|
85
|
+
|
|
86
|
+
def _make_fatal_error_handler(self, bot_id: int):
|
|
87
|
+
"""Return a closure that marks the bot as 'error' in DB after too many failures."""
|
|
88
|
+
|
|
89
|
+
def _on_fatal_error(failed_bot_id: int) -> None:
|
|
90
|
+
# Remove from active bots (scheduler already removed itself)
|
|
91
|
+
with self._lock:
|
|
92
|
+
self._bots.pop(failed_bot_id, None)
|
|
93
|
+
|
|
94
|
+
# Update DB status — runs in scheduler thread, create own session
|
|
95
|
+
try:
|
|
96
|
+
from lockbot.backend.app.bots.models import Bot as BotModel
|
|
97
|
+
from lockbot.backend.app.database import SessionLocal
|
|
98
|
+
|
|
99
|
+
db = SessionLocal()
|
|
100
|
+
try:
|
|
101
|
+
bot = db.query(BotModel).filter(BotModel.id == failed_bot_id).first()
|
|
102
|
+
if bot:
|
|
103
|
+
bot.status = "error"
|
|
104
|
+
bot.pid = None
|
|
105
|
+
db.commit()
|
|
106
|
+
logger.critical(
|
|
107
|
+
"Bot %d (%s): marked as error in DB after repeated failures",
|
|
108
|
+
failed_bot_id,
|
|
109
|
+
bot.name,
|
|
110
|
+
)
|
|
111
|
+
finally:
|
|
112
|
+
db.close()
|
|
113
|
+
except Exception:
|
|
114
|
+
logger.exception("Bot %d: failed to update DB status to error", failed_bot_id)
|
|
115
|
+
|
|
116
|
+
return _on_fatal_error
|
|
117
|
+
|
|
118
|
+
def stop_bot(self, bot_id: int) -> None:
|
|
119
|
+
"""
|
|
120
|
+
Stop a bot instance.
|
|
121
|
+
|
|
122
|
+
Raises:
|
|
123
|
+
RuntimeError: If the bot is not running.
|
|
124
|
+
"""
|
|
125
|
+
# Cancel scheduling before removing from _bots
|
|
126
|
+
self._scheduler.remove(bot_id)
|
|
127
|
+
with self._lock:
|
|
128
|
+
if bot_id not in self._bots:
|
|
129
|
+
raise RuntimeError(f"Bot {bot_id} is not running")
|
|
130
|
+
self._bots.pop(bot_id)
|
|
131
|
+
logger.info("Stopped bot %d", bot_id)
|
|
132
|
+
|
|
133
|
+
def restart_bot(self, bot_id: int, config_dict: dict) -> int:
|
|
134
|
+
"""Stop then start a bot. Returns PID."""
|
|
135
|
+
if self.is_running(bot_id):
|
|
136
|
+
self.stop_bot(bot_id)
|
|
137
|
+
return self.start_bot(bot_id, config_dict)
|
|
138
|
+
|
|
139
|
+
def shutdown_all(self) -> None:
|
|
140
|
+
"""Stop the scheduler and clear all running bots. Called on platform shutdown."""
|
|
141
|
+
self._scheduler.stop()
|
|
142
|
+
with self._lock:
|
|
143
|
+
self._bots.clear()
|
|
144
|
+
# Clear scheduler state so it can be safely restarted (e.g. in tests)
|
|
145
|
+
with self._scheduler._lock:
|
|
146
|
+
self._scheduler._instances.clear()
|
|
147
|
+
self._scheduler._heap.clear()
|
|
148
|
+
self._scheduler._gens.clear()
|
|
149
|
+
self._scheduler._failure_counts.clear()
|
|
150
|
+
self._scheduler._callbacks.clear()
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# Module-level singleton
|
|
154
|
+
bot_manager = BotManager()
|
|
@@ -1123,7 +1123,10 @@ async def _reply_bot_not_running(
|
|
|
1123
1123
|
owner_username = owner.username if owner else ""
|
|
1124
1124
|
|
|
1125
1125
|
# Build and send "bot not running" reply
|
|
1126
|
-
|
|
1126
|
+
if bot.status == "error":
|
|
1127
|
+
content = t("webhook.bot_error", lang=lang, bot_name=bot.name, owner_username=owner_username)
|
|
1128
|
+
else:
|
|
1129
|
+
content = t("webhook.bot_not_running", lang=lang, bot_name=bot.name, owner_username=owner_username)
|
|
1127
1130
|
reply = adapter.build_reply(content, [user_id], group_id=group_id)
|
|
1128
1131
|
toid = msg_data["message"]["header"].get("toid")
|
|
1129
1132
|
if toid:
|
|
@@ -4,7 +4,38 @@ Bot Pydantic schemas
|
|
|
4
4
|
|
|
5
5
|
from datetime import datetime
|
|
6
6
|
|
|
7
|
-
from pydantic import BaseModel
|
|
7
|
+
from pydantic import BaseModel, field_validator
|
|
8
|
+
|
|
9
|
+
# ── config_overrides value bounds ─────────────────────────────────────────────
|
|
10
|
+
_CFG_RULES: dict[str, tuple[int, int] | None] = {
|
|
11
|
+
# (min, max); None means special-cased below
|
|
12
|
+
"DEFAULT_DURATION": (60, 604800), # 1 min – 7 days
|
|
13
|
+
"TIME_ALERT": (30, 3600), # 30 s – 1 h
|
|
14
|
+
"MAX_LOCK_DURATION": None, # -1 (unlimited) or 300–604800
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _validate_config_overrides(v: dict | None) -> dict | None:
|
|
19
|
+
if not v:
|
|
20
|
+
return v
|
|
21
|
+
errors: list[str] = []
|
|
22
|
+
for key, bounds in _CFG_RULES.items():
|
|
23
|
+
if key not in v:
|
|
24
|
+
continue
|
|
25
|
+
val = v[key]
|
|
26
|
+
if not isinstance(val, int):
|
|
27
|
+
errors.append(f"{key} must be an integer")
|
|
28
|
+
continue
|
|
29
|
+
if key == "MAX_LOCK_DURATION":
|
|
30
|
+
if val != -1 and not (300 <= val <= 604800):
|
|
31
|
+
errors.append("MAX_LOCK_DURATION must be -1 (unlimited) or between 300 and 604800")
|
|
32
|
+
else:
|
|
33
|
+
lo, hi = bounds # type: ignore[misc]
|
|
34
|
+
if not (lo <= val <= hi):
|
|
35
|
+
errors.append(f"{key} must be between {lo} and {hi}")
|
|
36
|
+
if errors:
|
|
37
|
+
raise ValueError("; ".join(errors))
|
|
38
|
+
return v
|
|
8
39
|
|
|
9
40
|
|
|
10
41
|
class BotCreate(BaseModel):
|
|
@@ -18,6 +49,11 @@ class BotCreate(BaseModel):
|
|
|
18
49
|
cluster_configs: dict | list
|
|
19
50
|
config_overrides: dict | None = None
|
|
20
51
|
|
|
52
|
+
@field_validator("config_overrides")
|
|
53
|
+
@classmethod
|
|
54
|
+
def validate_config_overrides(cls, v: dict | None) -> dict | None:
|
|
55
|
+
return _validate_config_overrides(v)
|
|
56
|
+
|
|
21
57
|
|
|
22
58
|
class BotUpdate(BaseModel):
|
|
23
59
|
name: str | None = None
|
|
@@ -28,6 +64,11 @@ class BotUpdate(BaseModel):
|
|
|
28
64
|
cluster_configs: dict | list | None = None
|
|
29
65
|
config_overrides: dict | None = None
|
|
30
66
|
|
|
67
|
+
@field_validator("config_overrides")
|
|
68
|
+
@classmethod
|
|
69
|
+
def validate_config_overrides(cls, v: dict | None) -> dict | None:
|
|
70
|
+
return _validate_config_overrides(v)
|
|
71
|
+
|
|
31
72
|
|
|
32
73
|
class BotOut(BaseModel):
|
|
33
74
|
id: int
|
|
@@ -19,7 +19,7 @@ DATABASE_URL = os.environ.get(
|
|
|
19
19
|
# JWT
|
|
20
20
|
JWT_SECRET = os.environ.get("JWT_SECRET", "lockbot-dev-secret-change-me")
|
|
21
21
|
JWT_ALGORITHM = "HS256"
|
|
22
|
-
JWT_EXPIRE_MINUTES = 60 * 24 *
|
|
22
|
+
JWT_EXPIRE_MINUTES = 60 * 24 * 90 # 90 days (~3 months)
|
|
23
23
|
|
|
24
24
|
# Fernet encryption key (for sensitive fields)
|
|
25
25
|
ENCRYPTION_KEY = os.environ.get("ENCRYPTION_KEY", "")
|
|
@@ -204,11 +204,12 @@ async def lifespan(app: FastAPI):
|
|
|
204
204
|
_migrate_audit_logs()
|
|
205
205
|
_seed_dev_admin()
|
|
206
206
|
_seed_dev_users()
|
|
207
|
-
_reset_running_bots()
|
|
208
|
-
yield
|
|
209
|
-
# Shutdown: cancel all bot timers and clean up
|
|
210
207
|
from lockbot.backend.app.bots.manager import bot_manager
|
|
211
208
|
|
|
209
|
+
bot_manager.start_scheduler()
|
|
210
|
+
_reset_running_bots()
|
|
211
|
+
yield
|
|
212
|
+
# Shutdown: stop scheduler and clean up all bots
|
|
212
213
|
logger.info("Shutting down all bots…")
|
|
213
214
|
bot_manager.shutdown_all()
|
|
214
215
|
|
|
@@ -4,6 +4,7 @@ lockbot - BaseLockBot
|
|
|
4
4
|
|
|
5
5
|
import logging
|
|
6
6
|
import threading
|
|
7
|
+
from collections.abc import Callable
|
|
7
8
|
from importlib.metadata import version as _pkg_version
|
|
8
9
|
|
|
9
10
|
from lockbot.core.config import Config
|
|
@@ -42,9 +43,6 @@ class BaseLockBot:
|
|
|
42
43
|
# Subclasses MUST define an inner _state_class(BotState) with a _loader.
|
|
43
44
|
_state_class = None
|
|
44
45
|
|
|
45
|
-
_timer_failure_count = 0
|
|
46
|
-
_MAX_TIMER_FAILURES = 5
|
|
47
|
-
|
|
48
46
|
logger = logging.getLogger("lockbot.timer")
|
|
49
47
|
|
|
50
48
|
# ------------------------------------------------------------------ init
|
|
@@ -68,6 +66,27 @@ class BaseLockBot:
|
|
|
68
66
|
|
|
69
67
|
self._lock = lock or threading.Lock()
|
|
70
68
|
self.adapter = adapter or InfoflowAdapter(config=self.config)
|
|
69
|
+
# Optional callback: invoked after a successful lock/slock so the
|
|
70
|
+
# scheduler can recalculate its next wakeup without waiting for idle.
|
|
71
|
+
self._on_state_changed: Callable[[], None] | None = None
|
|
72
|
+
|
|
73
|
+
def _notify_state_changed(self) -> None:
|
|
74
|
+
"""Call _on_state_changed if wired up (no-op otherwise)."""
|
|
75
|
+
if self._on_state_changed is not None:
|
|
76
|
+
self._on_state_changed()
|
|
77
|
+
|
|
78
|
+
def _save_and_notify(self) -> None:
|
|
79
|
+
"""Persist bot state to disk and wake the scheduler (if wired).
|
|
80
|
+
|
|
81
|
+
Use this in every command handler that mutates state so it's
|
|
82
|
+
impossible to forget either step. The scheduler's
|
|
83
|
+
``_check_and_notify`` loop should still call ``save_bot_state_to_file``
|
|
84
|
+
directly to avoid an unwanted reschedule from the timer thread.
|
|
85
|
+
"""
|
|
86
|
+
from lockbot.core.io import save_bot_state_to_file
|
|
87
|
+
|
|
88
|
+
save_bot_state_to_file(self.state.bot_state, config=self.config)
|
|
89
|
+
self._notify_state_changed()
|
|
71
90
|
|
|
72
91
|
# ---------------------------------------------------------- show_error
|
|
73
92
|
def show_error(self, user_id, error_msg):
|
|
@@ -76,34 +95,6 @@ class BaseLockBot:
|
|
|
76
95
|
"""
|
|
77
96
|
return self.adapter.build_reply("\u274c" + error_msg, [user_id])
|
|
78
97
|
|
|
79
|
-
# -------------------------------------------------------- timer_routine
|
|
80
|
-
def timer_routine(self):
|
|
81
|
-
"""
|
|
82
|
-
Timer routine entrypoint: run _check_and_notify every 5 seconds.
|
|
83
|
-
|
|
84
|
-
Self-healing: if _check_and_notify raises, log the error and
|
|
85
|
-
continue scheduling the next tick. After _MAX_TIMER_FAILURES
|
|
86
|
-
consecutive failures, back off to 30-second intervals.
|
|
87
|
-
"""
|
|
88
|
-
try:
|
|
89
|
-
self._check_and_notify()
|
|
90
|
-
self._timer_failure_count = 0
|
|
91
|
-
except Exception:
|
|
92
|
-
self._timer_failure_count += 1
|
|
93
|
-
bot_name = self.config.get_val("BOT_NAME") if self.config else "?"
|
|
94
|
-
self.logger.exception(
|
|
95
|
-
"timer_routine crashed for bot %s (failure %d/%d)",
|
|
96
|
-
bot_name,
|
|
97
|
-
self._timer_failure_count,
|
|
98
|
-
self._MAX_TIMER_FAILURES,
|
|
99
|
-
)
|
|
100
|
-
|
|
101
|
-
# Back off when too many consecutive failures
|
|
102
|
-
interval = 30.0 if self._timer_failure_count >= self._MAX_TIMER_FAILURES else 5.0
|
|
103
|
-
self._timer = threading.Timer(interval, self.timer_routine)
|
|
104
|
-
self._timer.daemon = True
|
|
105
|
-
self._timer.start()
|
|
106
|
-
|
|
107
98
|
# ------------------------------------------------------ _msg_with_usage
|
|
108
99
|
def _msg_with_usage(self, msg_key, *, node_key=None, sep="", **kwargs):
|
|
109
100
|
"""Return ``t(msg_key, ...) + sep + self._current_usage(node_key)``."""
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""
|
|
2
|
+
bot_instance.py - Bot instance factory.
|
|
3
|
+
|
|
4
|
+
Creates the appropriate Bot instance based on bot_type.
|
|
5
|
+
Each instance holds independent Config, State and Lock,
|
|
6
|
+
supporting multiple concurrent bots in a single process.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from lockbot.core.device_bot import DeviceBot
|
|
10
|
+
from lockbot.core.node_bot import NodeBot
|
|
11
|
+
from lockbot.core.queue_bot import QueueBot
|
|
12
|
+
|
|
13
|
+
_BOT_CLASS_MAP = {
|
|
14
|
+
"NODE": NodeBot,
|
|
15
|
+
"QUEUE": QueueBot,
|
|
16
|
+
"DEVICE": DeviceBot,
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
_STANDALONE_KEY = 0 # internal scheduler key used in standalone mode
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class BotInstance:
|
|
23
|
+
"""
|
|
24
|
+
Wraps an independent Bot instance.
|
|
25
|
+
|
|
26
|
+
Usage:
|
|
27
|
+
instance = BotInstance("NODE", {"BOT_NAME": "my_bot", ...})
|
|
28
|
+
instance.bot # NodeBot / QueueBot / DeviceBot instance
|
|
29
|
+
instance.bot.config # Config instance
|
|
30
|
+
instance.bot.state # State instance
|
|
31
|
+
|
|
32
|
+
Scheduling:
|
|
33
|
+
Standalone mode (scheduler=None, default):
|
|
34
|
+
A private BotScheduler is created and started automatically.
|
|
35
|
+
Call instance.shutdown() to stop it when done.
|
|
36
|
+
|
|
37
|
+
Managed mode (scheduler provided by BotManager):
|
|
38
|
+
No scheduler is created here. BotManager calls
|
|
39
|
+
scheduler.add(bot_id, instance) after construction and
|
|
40
|
+
scheduler.remove(bot_id) on teardown.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(self, bot_type, config_dict=None, scheduler=None, auto_start=True):
|
|
44
|
+
if bot_type not in _BOT_CLASS_MAP:
|
|
45
|
+
raise ValueError(f"Invalid bot_type '{bot_type}', must be one of {list(_BOT_CLASS_MAP.keys())}")
|
|
46
|
+
|
|
47
|
+
full_config = dict(config_dict or {})
|
|
48
|
+
full_config["BOT_TYPE"] = bot_type
|
|
49
|
+
|
|
50
|
+
self.bot_type = bot_type
|
|
51
|
+
bot_cls = _BOT_CLASS_MAP[bot_type]
|
|
52
|
+
self.bot = bot_cls(config_dict=full_config)
|
|
53
|
+
|
|
54
|
+
self.config = self.bot.config
|
|
55
|
+
self.state = self.bot.state
|
|
56
|
+
|
|
57
|
+
if not auto_start:
|
|
58
|
+
# Testing/manual mode: no scheduler
|
|
59
|
+
self._scheduler = None
|
|
60
|
+
self._owns_scheduler = False
|
|
61
|
+
elif scheduler is None:
|
|
62
|
+
# Standalone mode: own scheduler, start immediately
|
|
63
|
+
from lockbot.core.scheduler import BotScheduler
|
|
64
|
+
|
|
65
|
+
self._scheduler = BotScheduler()
|
|
66
|
+
self._scheduler.start()
|
|
67
|
+
self._owns_scheduler = True
|
|
68
|
+
self._scheduler.add(_STANDALONE_KEY, self, delay=0.0)
|
|
69
|
+
self.bot._on_state_changed = lambda: self._scheduler.reschedule_soon(_STANDALONE_KEY)
|
|
70
|
+
else:
|
|
71
|
+
# Managed mode: shared scheduler injected by BotManager
|
|
72
|
+
self._scheduler = scheduler
|
|
73
|
+
self._owns_scheduler = False
|
|
74
|
+
|
|
75
|
+
def shutdown(self) -> None:
|
|
76
|
+
"""Stop scheduling (standalone mode only). No-op in managed mode."""
|
|
77
|
+
if self._owns_scheduler:
|
|
78
|
+
self._scheduler.remove(_STANDALONE_KEY)
|
|
79
|
+
self._scheduler.stop()
|
|
@@ -225,9 +225,8 @@ class DeviceBot(BaseLockBot):
|
|
|
225
225
|
self._msg_with_usage("success.resource_locked", node_key=node_key_list), [user_id]
|
|
226
226
|
)
|
|
227
227
|
|
|
228
|
-
save_bot_state_to_file(self.state.bot_state, config=self.config)
|
|
229
228
|
log_to_file(user_id, "lock", node_key, dev_ids, duration, config=self.config)
|
|
230
|
-
|
|
229
|
+
self._save_and_notify()
|
|
231
230
|
return reply
|
|
232
231
|
|
|
233
232
|
def slock(self, user_id, command):
|
|
@@ -291,8 +290,8 @@ class DeviceBot(BaseLockBot):
|
|
|
291
290
|
self._msg_with_usage("success.resource_locked", node_key=node_key_list), [user_id]
|
|
292
291
|
)
|
|
293
292
|
|
|
294
|
-
save_bot_state_to_file(self.state.bot_state, config=self.config)
|
|
295
293
|
log_to_file(user_id, "slock", node_key_list, dev_ids_list, duration, config=self.config)
|
|
294
|
+
self._save_and_notify()
|
|
296
295
|
return reply
|
|
297
296
|
|
|
298
297
|
def unlock(self, user_id, command):
|
|
@@ -311,8 +310,8 @@ class DeviceBot(BaseLockBot):
|
|
|
311
310
|
if len(device["current_users"]) == 0:
|
|
312
311
|
device["status"] = "idle"
|
|
313
312
|
reply = self.adapter.build_reply(self._msg_with_usage("success.resource_released"), [user_id])
|
|
314
|
-
save_bot_state_to_file(self.state.bot_state, config=self.config)
|
|
315
313
|
log_to_file(user_id, "unlock", "all", "all", config=self.config)
|
|
314
|
+
self._save_and_notify()
|
|
316
315
|
return reply
|
|
317
316
|
|
|
318
317
|
m = re.match(r"^\s*(unlock|free)\s+([\w\d]+)(\s*[,,、]\s*([\w\d])+)*\s*$", command)
|
|
@@ -343,8 +342,8 @@ class DeviceBot(BaseLockBot):
|
|
|
343
342
|
if len(device["current_users"]) == 0:
|
|
344
343
|
device["status"] = "idle"
|
|
345
344
|
reply = self.adapter.build_reply(self._msg_with_usage("success.resource_released"), [user_id])
|
|
346
|
-
save_bot_state_to_file(self.state.bot_state, config=self.config)
|
|
347
345
|
log_to_file(user_id, "unlock", node_key, "all", config=self.config)
|
|
346
|
+
self._save_and_notify()
|
|
348
347
|
return reply
|
|
349
348
|
|
|
350
349
|
# Case 3: Release specific devices requested by the user
|
|
@@ -372,8 +371,8 @@ class DeviceBot(BaseLockBot):
|
|
|
372
371
|
reply = self.adapter.build_reply(
|
|
373
372
|
self._msg_with_usage("success.resource_released", node_key=node_key_list), [user_id]
|
|
374
373
|
)
|
|
375
|
-
save_bot_state_to_file(self.state.bot_state, config=self.config)
|
|
376
374
|
log_to_file(user_id, "unlock", node_key_list, dev_ids_list, config=self.config)
|
|
375
|
+
self._save_and_notify()
|
|
377
376
|
return reply
|
|
378
377
|
|
|
379
378
|
def kickout(self, user_id, command):
|
|
@@ -402,9 +401,8 @@ class DeviceBot(BaseLockBot):
|
|
|
402
401
|
content += self._msg_with_usage("label.after_release", node_key=node_key_list)
|
|
403
402
|
reply = self.adapter.build_reply(content, list(users))
|
|
404
403
|
|
|
405
|
-
save_bot_state_to_file(self.state.bot_state, config=self.config)
|
|
406
404
|
log_to_file(user_id, "kickout", node_key_list, dev_ids_list, config=self.config)
|
|
407
|
-
|
|
405
|
+
self._save_and_notify()
|
|
408
406
|
return reply
|
|
409
407
|
|
|
410
408
|
def _help_commands(self):
|
|
@@ -438,13 +436,13 @@ class DeviceBot(BaseLockBot):
|
|
|
438
436
|
|
|
439
437
|
return text
|
|
440
438
|
|
|
441
|
-
def _check_and_notify(self):
|
|
439
|
+
def _check_and_notify(self) -> float | None:
|
|
442
440
|
"""
|
|
443
441
|
Check and notify when resource availability time is running low or resources have been released.
|
|
444
442
|
If EARLY_NOTIFY is set, sends an early notification when remaining time is less than TIME_ALERT.
|
|
445
443
|
Otherwise, notifies after resources are automatically released.
|
|
446
444
|
|
|
447
|
-
Returns:
|
|
445
|
+
Returns: seconds until next interesting event, or None if no active locks.
|
|
448
446
|
"""
|
|
449
447
|
EARLY_NOTIFY = self.config.get_val("EARLY_NOTIFY")
|
|
450
448
|
TIME_ALERT = self.config.get_val("TIME_ALERT")
|
|
@@ -466,7 +464,12 @@ class DeviceBot(BaseLockBot):
|
|
|
466
464
|
if remaining_time <= 0:
|
|
467
465
|
removed_users_id.append(user_info["user_id"])
|
|
468
466
|
state_changed = True
|
|
469
|
-
|
|
467
|
+
|
|
468
|
+
# Send expiry notification only if early warning was never sent.
|
|
469
|
+
# When EARLY_NOTIFY=True and warning fired on time, is_notified=True → silent release.
|
|
470
|
+
# When EARLY_NOTIFY=False, is_notified is always False → always notify here.
|
|
471
|
+
# Fallback: EARLY_NOTIFY=True but scheduler delayed past expiry → notify here instead.
|
|
472
|
+
if not user_info.get("is_notified"):
|
|
470
473
|
trigger_time_alert = True
|
|
471
474
|
user_ids.add(user_info["user_id"])
|
|
472
475
|
alert_tuples.append(
|
|
@@ -478,7 +481,7 @@ class DeviceBot(BaseLockBot):
|
|
|
478
481
|
remaining_time,
|
|
479
482
|
)
|
|
480
483
|
)
|
|
481
|
-
if EARLY_NOTIFY and not user_info.get("is_notified") and remaining_time <= TIME_ALERT:
|
|
484
|
+
if EARLY_NOTIFY and not user_info.get("is_notified") and 0 < remaining_time <= TIME_ALERT:
|
|
482
485
|
trigger_time_alert = True
|
|
483
486
|
user_ids.add(user_info["user_id"])
|
|
484
487
|
user_info["is_notified"] = True
|
|
@@ -503,13 +506,33 @@ class DeviceBot(BaseLockBot):
|
|
|
503
506
|
if state_changed:
|
|
504
507
|
save_bot_state_to_file(self.state.bot_state, config=self.config)
|
|
505
508
|
|
|
509
|
+
# Compute next wakeup: scan remaining active users after mutations
|
|
510
|
+
min_next = float("inf")
|
|
511
|
+
for devices in self.state.bot_state.values():
|
|
512
|
+
for device in devices:
|
|
513
|
+
if device["status"] != "idle":
|
|
514
|
+
for user_info in device["current_users"]:
|
|
515
|
+
remaining = remaining_duration(user_info["start_time"], user_info["duration"])
|
|
516
|
+
if remaining <= 0:
|
|
517
|
+
continue
|
|
518
|
+
if EARLY_NOTIFY and not user_info.get("is_notified"):
|
|
519
|
+
next_event = remaining - TIME_ALERT
|
|
520
|
+
else:
|
|
521
|
+
next_event = remaining
|
|
522
|
+
min_next = min(min_next, next_event)
|
|
523
|
+
|
|
506
524
|
grouped_devices = group_devices_by_node_user_and_mode(alert_tuples)
|
|
507
525
|
for node_key, user_id, (dev_start, dev_end), status, remaining_time in grouped_devices:
|
|
508
526
|
alert_info += format_alert_info(node_key, user_id, dev_start, dev_end, status, remaining_time)
|
|
509
527
|
|
|
510
528
|
if trigger_time_alert:
|
|
511
529
|
msg = self.adapter.build_reply(alert_info + "\n", list(user_ids))
|
|
512
|
-
|
|
530
|
+
try:
|
|
531
|
+
self.adapter.send(msg)
|
|
532
|
+
except Exception:
|
|
533
|
+
self.logger.exception("Failed to send alert for bot %s", self.config.get_val("BOT_NAME"))
|
|
534
|
+
|
|
535
|
+
return max(1.0, min_next) if min_next != float("inf") else None
|
|
513
536
|
|
|
514
537
|
def _current_usage(self, node_filter=None):
|
|
515
538
|
"""
|
|
@@ -161,4 +161,6 @@ MESSAGES = {
|
|
|
161
161
|
# ── Webhook: bot not running ──
|
|
162
162
|
"webhook.bot_not_running": "⚠️ Bot {bot_name} is not running. " # noqa: E501
|
|
163
163
|
"Please contact the owner @{owner_username} to start it.",
|
|
164
|
+
"webhook.bot_error": "❌ Bot {bot_name} encountered an error. " # noqa: E501
|
|
165
|
+
"Please contact the owner @{owner_username} for assistance.",
|
|
164
166
|
}
|
|
@@ -154,4 +154,5 @@ MESSAGES = {
|
|
|
154
154
|
"state.dev_id_corrected": "节点 '{name}', 设备 {index}: dev_id 从 {old} 修正为 {new}",
|
|
155
155
|
# ── Webhook: bot not running ──
|
|
156
156
|
"webhook.bot_not_running": "⚠️ 机器人 {bot_name} 尚未启动,请联系管理人 @{owner_username} 启动后再使用。",
|
|
157
|
+
"webhook.bot_error": "❌ 机器人 {bot_name} 运行异常,请联系管理人 @{owner_username} 处理。",
|
|
157
158
|
}
|