feat(plugin): support auto bot injection and file-based hot reload

This commit is contained in:
liuwei
2026-04-16 13:54:56 +08:00
parent 041a3f30d8
commit f0414e0dff
4 changed files with 330 additions and 18 deletions

View File

@@ -1,20 +1,115 @@
import asyncio
import threading
from datetime import datetime, timedelta
from typing import Callable, Awaitable, List, Dict
from typing import Callable, Awaitable, List, Dict, Optional, Any
class AsyncJob:
def __init__(self):
self.tasks: List[Callable[[], Awaitable]] = []
self._jobs: Dict[str, Dict[str, Any]] = {}
self._running_tasks: Dict[str, asyncio.Task] = {}
self._running = False
self._loop: Optional[asyncio.AbstractEventLoop] = None
self._stop_event: Optional[asyncio.Event] = None
self._lock = threading.RLock()
self._job_seq = 0
def _next_job_id(self) -> str:
with self._lock:
self._job_seq += 1
return f"job-{self._job_seq}"
@staticmethod
def _infer_owner(func: Callable) -> Optional[Any]:
owner = getattr(func, "__self__", None)
if owner is not None:
return owner
closure = getattr(func, "__closure__", None) or []
for cell in closure:
try:
value = cell.cell_contents
except ValueError:
continue
if all(hasattr(value, attr) for attr in ("initialize", "start", "stop")):
return value
return None
def _register(self, func: Callable, wrapper: Callable[[], Awaitable], trigger: str):
owner = self._infer_owner(func)
job_id = self._next_job_id()
with self._lock:
self._jobs[job_id] = {
"func": func,
"wrapper": wrapper,
"trigger": trigger,
"owner_id": id(owner) if owner is not None else None,
"owner_name": owner.__class__.__name__ if owner is not None else None,
}
if self._running and self._loop and self._loop.is_running():
self._loop.call_soon_threadsafe(self._start_job_in_loop, job_id)
def _start_job_in_loop(self, job_id: str):
job = self._jobs.get(job_id)
if not job or job_id in self._running_tasks:
return
async def runner():
try:
await job["wrapper"]()
except asyncio.CancelledError:
raise
except Exception as e:
print(f"[AsyncJob] 任务异常退出: {job_id}, trigger={job.get('trigger')}, error={e}")
task = asyncio.create_task(runner(), name=f"async_job:{job_id}")
self._running_tasks[job_id] = task
task.add_done_callback(lambda _task, _id=job_id: self._running_tasks.pop(_id, None))
def _cancel_job_in_loop(self, job_id: str):
task = self._running_tasks.pop(job_id, None)
if task:
task.cancel()
def remove_job(self, job_id: str) -> bool:
with self._lock:
existed = job_id in self._jobs or job_id in self._running_tasks
self._jobs.pop(job_id, None)
loop = self._loop
running = self._running
if running and loop and loop.is_running():
loop.call_soon_threadsafe(self._cancel_job_in_loop, job_id)
else:
task = self._running_tasks.pop(job_id, None)
if task:
task.cancel()
return existed
def remove_jobs_by_owner(self, owner: Any) -> int:
owner_id = id(owner)
with self._lock:
job_ids = [job_id for job_id, meta in self._jobs.items() if meta.get("owner_id") == owner_id]
removed = 0
for job_id in job_ids:
if self.remove_job(job_id):
removed += 1
return removed
def every_seconds(self, seconds: int):
def decorator(func: Callable):
async def wrapper():
while True:
await func()
try:
await func()
except asyncio.CancelledError:
raise
except Exception as e:
print(f"[AsyncJob] every_seconds 任务执行异常: {e}")
await asyncio.sleep(seconds)
self.tasks.append(wrapper)
self._register(func, wrapper, f"every_seconds({seconds})")
return func
return decorator
@@ -27,18 +122,29 @@ class AsyncJob:
def at_times(self, time_list: List[str]):
def decorator(func: Callable):
parsed_times = [datetime.strptime(t, "%H:%M").time() for t in time_list]
async def wrapper():
while True:
now = datetime.now()
for t in time_list:
target = datetime.strptime(t, "%H:%M").replace(year=now.year, month=now.month, day=now.day)
if target < now:
targets = []
for t in parsed_times:
target = datetime.combine(now.date(), t)
if target <= now:
target += timedelta(days=1)
wait_seconds = (target - now).total_seconds()
await asyncio.sleep(wait_seconds)
await func()
targets.append(target)
self.tasks.append(wrapper)
next_target = min(targets)
wait_seconds = (next_target - now).total_seconds()
await asyncio.sleep(max(wait_seconds, 0))
try:
await func()
except asyncio.CancelledError:
raise
except Exception as e:
print(f"[AsyncJob] at_times 任务执行异常: {e}")
self._register(func, wrapper, f"at_times({time_list})")
return func
return decorator
@@ -64,9 +170,14 @@ class AsyncJob:
sleep_secs = (target_dt - now).total_seconds()
await asyncio.sleep(sleep_secs)
await func()
try:
await func()
except asyncio.CancelledError:
raise
except Exception as e:
print(f"[AsyncJob] every_weekday_time 任务执行异常: {e}")
self.tasks.append(wrapper)
self._register(func, wrapper, f"every_weekday_time({weekday}, {time_str})")
return func
return decorator
@@ -91,9 +202,14 @@ class AsyncJob:
sleep_secs = (target_dt - now).total_seconds()
await asyncio.sleep(sleep_secs)
await func()
try:
await func()
except asyncio.CancelledError:
raise
except Exception as e:
print(f"[AsyncJob] every_week_time 任务执行异常: {e}")
self.tasks.append(wrapper)
self._register(func, wrapper, f"every_week_time({weekday}, {time_str})")
return func
return decorator
@@ -128,15 +244,43 @@ class AsyncJob:
sleep_secs = (target_dt - now).total_seconds()
await asyncio.sleep(sleep_secs)
await func()
try:
await func()
except asyncio.CancelledError:
raise
except Exception as e:
print(f"[AsyncJob] every_month_last_day_time 任务执行异常: {e}")
self.tasks.append(wrapper)
self._register(func, wrapper, f"every_month_last_day_time({time_str})")
return func
return decorator
async def run_all(self):
await asyncio.gather(*(task() for task in self.tasks))
with self._lock:
self._running = True
self._loop = asyncio.get_running_loop()
self._stop_event = asyncio.Event()
job_ids = list(self._jobs.keys())
for job_id in job_ids:
self._start_job_in_loop(job_id)
await self._stop_event.wait()
def stop_all(self):
with self._lock:
self._running = False
loop = self._loop
self._jobs.clear()
self._job_seq = 0
stop_event = self._stop_event
if loop and loop.is_running():
for job_id in list(self._running_tasks.keys()):
loop.call_soon_threadsafe(self._cancel_job_in_loop, job_id)
if stop_event:
loop.call_soon_threadsafe(stop_event.set)
# 全局唯一 job 管理器