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.
Files changed (68) hide show
  1. {lockbot-2.4.0/python/lockbot.egg-info → lockbot-2.5.1}/PKG-INFO +2 -2
  2. {lockbot-2.4.0 → lockbot-2.5.1}/pyproject.toml +2 -2
  3. lockbot-2.5.1/python/lockbot/backend/app/bots/manager.py +154 -0
  4. {lockbot-2.4.0 → lockbot-2.5.1}/python/lockbot/backend/app/bots/router.py +4 -1
  5. {lockbot-2.4.0 → lockbot-2.5.1}/python/lockbot/backend/app/bots/schemas.py +42 -1
  6. {lockbot-2.4.0 → lockbot-2.5.1}/python/lockbot/backend/app/config.py +1 -1
  7. {lockbot-2.4.0 → lockbot-2.5.1}/python/lockbot/backend/app/main.py +4 -3
  8. {lockbot-2.4.0 → lockbot-2.5.1}/python/lockbot/core/base_bot.py +22 -31
  9. lockbot-2.5.1/python/lockbot/core/bot_instance.py +79 -0
  10. {lockbot-2.4.0 → lockbot-2.5.1}/python/lockbot/core/device_bot.py +36 -13
  11. {lockbot-2.4.0 → lockbot-2.5.1}/python/lockbot/core/i18n/en.py +2 -0
  12. {lockbot-2.4.0 → lockbot-2.5.1}/python/lockbot/core/i18n/zh.py +1 -0
  13. {lockbot-2.4.0 → lockbot-2.5.1}/python/lockbot/core/node_bot.py +34 -10
  14. {lockbot-2.4.0 → lockbot-2.5.1}/python/lockbot/core/queue_bot.py +44 -9
  15. lockbot-2.5.1/python/lockbot/core/scheduler.py +202 -0
  16. {lockbot-2.4.0 → lockbot-2.5.1/python/lockbot.egg-info}/PKG-INFO +2 -2
  17. {lockbot-2.4.0 → lockbot-2.5.1}/python/lockbot.egg-info/SOURCES.txt +3 -1
  18. {lockbot-2.4.0 → lockbot-2.5.1}/python/lockbot.egg-info/requires.txt +1 -1
  19. {lockbot-2.4.0 → lockbot-2.5.1}/tools/create_super_admin.py +9 -9
  20. {lockbot-2.4.0 → lockbot-2.5.1}/tools/gen_keys.py +3 -1
  21. lockbot-2.5.1/tools/reset_super_admin_password.py +112 -0
  22. lockbot-2.4.0/python/lockbot/backend/app/bots/manager.py +0 -106
  23. lockbot-2.4.0/python/lockbot/core/bot_instance.py +0 -49
  24. {lockbot-2.4.0 → lockbot-2.5.1}/LICENSE +0 -0
  25. {lockbot-2.4.0 → lockbot-2.5.1}/MANIFEST.in +0 -0
  26. {lockbot-2.4.0 → lockbot-2.5.1}/README.md +0 -0
  27. {lockbot-2.4.0 → lockbot-2.5.1}/python/lockbot/__init__.py +0 -0
  28. {lockbot-2.4.0 → lockbot-2.5.1}/python/lockbot/backend/__init__.py +0 -0
  29. {lockbot-2.4.0 → lockbot-2.5.1}/python/lockbot/backend/app/__init__.py +0 -0
  30. {lockbot-2.4.0 → lockbot-2.5.1}/python/lockbot/backend/app/admin/__init__.py +0 -0
  31. {lockbot-2.4.0 → lockbot-2.5.1}/python/lockbot/backend/app/admin/router.py +0 -0
  32. {lockbot-2.4.0 → lockbot-2.5.1}/python/lockbot/backend/app/audit/__init__.py +0 -0
  33. {lockbot-2.4.0 → lockbot-2.5.1}/python/lockbot/backend/app/audit/models.py +0 -0
  34. {lockbot-2.4.0 → lockbot-2.5.1}/python/lockbot/backend/app/audit/router.py +0 -0
  35. {lockbot-2.4.0 → lockbot-2.5.1}/python/lockbot/backend/app/audit/service.py +0 -0
  36. {lockbot-2.4.0 → lockbot-2.5.1}/python/lockbot/backend/app/auth/__init__.py +0 -0
  37. {lockbot-2.4.0 → lockbot-2.5.1}/python/lockbot/backend/app/auth/dependencies.py +0 -0
  38. {lockbot-2.4.0 → lockbot-2.5.1}/python/lockbot/backend/app/auth/models.py +0 -0
  39. {lockbot-2.4.0 → lockbot-2.5.1}/python/lockbot/backend/app/auth/router.py +0 -0
  40. {lockbot-2.4.0 → lockbot-2.5.1}/python/lockbot/backend/app/auth/schemas.py +0 -0
  41. {lockbot-2.4.0 → lockbot-2.5.1}/python/lockbot/backend/app/bots/__init__.py +0 -0
  42. {lockbot-2.4.0 → lockbot-2.5.1}/python/lockbot/backend/app/bots/encryption.py +0 -0
  43. {lockbot-2.4.0 → lockbot-2.5.1}/python/lockbot/backend/app/bots/models.py +0 -0
  44. {lockbot-2.4.0 → lockbot-2.5.1}/python/lockbot/backend/app/bots/webhook_handler.py +0 -0
  45. {lockbot-2.4.0 → lockbot-2.5.1}/python/lockbot/backend/app/database.py +0 -0
  46. {lockbot-2.4.0 → lockbot-2.5.1}/python/lockbot/backend/app/logs/__init__.py +0 -0
  47. {lockbot-2.4.0 → lockbot-2.5.1}/python/lockbot/backend/app/rate_limit.py +0 -0
  48. {lockbot-2.4.0 → lockbot-2.5.1}/python/lockbot/backend/app/settings/__init__.py +0 -0
  49. {lockbot-2.4.0 → lockbot-2.5.1}/python/lockbot/backend/app/settings/models.py +0 -0
  50. {lockbot-2.4.0 → lockbot-2.5.1}/python/lockbot/backend/app/settings/router.py +0 -0
  51. {lockbot-2.4.0 → lockbot-2.5.1}/python/lockbot/core/__init__.py +0 -0
  52. {lockbot-2.4.0 → lockbot-2.5.1}/python/lockbot/core/config.py +0 -0
  53. {lockbot-2.4.0 → lockbot-2.5.1}/python/lockbot/core/device_usage_alert.py +0 -0
  54. {lockbot-2.4.0 → lockbot-2.5.1}/python/lockbot/core/device_usage_utils.py +0 -0
  55. {lockbot-2.4.0 → lockbot-2.5.1}/python/lockbot/core/entry.py +0 -0
  56. {lockbot-2.4.0 → lockbot-2.5.1}/python/lockbot/core/env.py +0 -0
  57. {lockbot-2.4.0 → lockbot-2.5.1}/python/lockbot/core/handler.py +0 -0
  58. {lockbot-2.4.0 → lockbot-2.5.1}/python/lockbot/core/i18n/__init__.py +0 -0
  59. {lockbot-2.4.0 → lockbot-2.5.1}/python/lockbot/core/io.py +0 -0
  60. {lockbot-2.4.0 → lockbot-2.5.1}/python/lockbot/core/message_adapter.py +0 -0
  61. {lockbot-2.4.0 → lockbot-2.5.1}/python/lockbot/core/msg_utils.py +0 -0
  62. {lockbot-2.4.0 → lockbot-2.5.1}/python/lockbot/core/platforms/__init__.py +0 -0
  63. {lockbot-2.4.0 → lockbot-2.5.1}/python/lockbot/core/platforms/infoflow.py +0 -0
  64. {lockbot-2.4.0 → lockbot-2.5.1}/python/lockbot/core/request.py +0 -0
  65. {lockbot-2.4.0 → lockbot-2.5.1}/python/lockbot/core/utils.py +0 -0
  66. {lockbot-2.4.0 → lockbot-2.5.1}/python/lockbot.egg-info/dependency_links.txt +0 -0
  67. {lockbot-2.4.0 → lockbot-2.5.1}/python/lockbot.egg-info/top_level.txt +0 -0
  68. {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.4.0
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>=0.1.0; extra == "dev"
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.4.0"
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>=0.1.0",
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
- content = t("webhook.bot_not_running", lang=lang, bot_name=bot.name, owner_username=owner_username)
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 * 7 # 7 days
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: None. Modifies bot_state and user_ids as side effects.
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
- if not EARLY_NOTIFY:
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
- self.adapter.send(msg)
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
  }