diff --git a/astrbot/core/cron/manager.py b/astrbot/core/cron/manager.py index c42b7197ba..b5a0e7c3e4 100644 --- a/astrbot/core/cron/manager.py +++ b/astrbot/core/cron/manager.py @@ -286,6 +286,24 @@ def _get_next_run_time(self, job_id: str): return None return aps_job.next_run_time.astimezone(timezone.utc) + def get_next_run_time(self, job_id: str) -> datetime | None: + """Read the live next-run time straight from the scheduler. + + The DB copy of ``next_run_time`` is written via a fire-and-forget + task in ``_schedule_job``, so it can still be stale/None right after + ``add_active_job``/``update_job`` return. The scheduler itself is + updated synchronously, so callers that need an immediate answer + should use this instead of the job row's ``next_run_time`` field. + + Args: + job_id: The scheduled job's ID. + + Returns: + The job's next scheduled run time in UTC, or None if the job + is not currently scheduled. + """ + return self._get_next_run_time(job_id) + async def run_job_now(self, job_id: str) -> None: await self._run_job(job_id, ignore_enabled=True, delete_run_once=False) diff --git a/astrbot/core/tools/cron_tools.py b/astrbot/core/tools/cron_tools.py index ee6ff27e2d..b0ca035c9b 100644 --- a/astrbot/core/tools/cron_tools.py +++ b/astrbot/core/tools/cron_tools.py @@ -1,9 +1,12 @@ from datetime import datetime +from datetime import timezone as dt_timezone from typing import Any +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from pydantic import Field from pydantic.dataclasses import dataclass +from astrbot import logger from astrbot.core.agent.run_context import ContextWrapper from astrbot.core.agent.tool import FunctionTool, ToolExecResult from astrbot.core.astr_agent_context import AstrAgentContext @@ -130,18 +133,56 @@ async def call( "origin": "tool", } + tz_name = str( + context.context.context.get_config( + umo=context.context.event.unified_msg_origin + ).get("timezone") + or "" + ).strip() + tzinfo = None + if tz_name: + try: + tzinfo = ZoneInfo(tz_name) + except ZoneInfoNotFoundError: + logger.warning( + "Invalid timezone %r in config, falling back to system timezone.", + tz_name, + ) + try: job = await cron_mgr.add_active_job( name=name, cron_expression=str(cron_expression) if cron_expression else None, payload=payload, description=note, + timezone=tz_name or None, run_once=run_once, run_at=run_at_dt, ) except CronJobSchedulingError: return "error: failed to schedule task due to invalid configuration." - next_run = job.next_run_time or run_at_dt + # add_active_job writes next_run_time to the DB via a fire-and-forget + # task, so job.next_run_time can still be None here; read the live + # value straight from the scheduler instead. + next_run = cron_mgr.get_next_run_time(job.job_id) or job.next_run_time + if next_run is not None: + # Scheduler/DB values are always UTC, even when naive (SQLite + # has no tz-aware column type), so restore the UTC label + # before converting to the display timezone. + if next_run.tzinfo is None: + next_run = next_run.replace(tzinfo=dt_timezone.utc) + next_run = ( + next_run.astimezone(tzinfo) if tzinfo else next_run.astimezone() + ) + elif run_at_dt is not None: + # No scheduler/DB value yet; fall back to the user-supplied + # run_at, treating a naive value as already being in the + # display timezone. + next_run = ( + run_at_dt.astimezone(tzinfo) + if run_at_dt.tzinfo + else (run_at_dt.replace(tzinfo=tzinfo) if tzinfo else run_at_dt) + ) suffix = ( f"one-time at {next_run}" if run_once @@ -245,10 +286,35 @@ async def call( ] if not jobs: return "No cron jobs found." + tz_name = str( + context.context.context.get_config( + umo=context.context.event.unified_msg_origin + ).get("timezone") + or "" + ).strip() + tzinfo = None + if tz_name: + try: + tzinfo = ZoneInfo(tz_name) + except ZoneInfoNotFoundError: + logger.warning( + "Invalid timezone %r in config, falling back to system timezone.", + tz_name, + ) lines = [] for j in jobs: + next_run = j.next_run_time + if next_run is not None: + # DB values are always UTC, even when naive (SQLite has + # no tz-aware column type), so restore the UTC label + # before converting to the display timezone. + if next_run.tzinfo is None: + next_run = next_run.replace(tzinfo=dt_timezone.utc) + next_run = ( + next_run.astimezone(tzinfo) if tzinfo else next_run.astimezone() + ) lines.append( - f"{j.job_id} | {j.name} | {j.job_type} | run_once={getattr(j, 'run_once', False)} | enabled={j.enabled} | next={j.next_run_time}" + f"{j.job_id} | {j.name} | {j.job_type} | run_once={getattr(j, 'run_once', False)} | enabled={j.enabled} | next={next_run}" ) return "\n".join(lines) diff --git a/tests/unit/test_cron_tools.py b/tests/unit/test_cron_tools.py index 2cc254fbee..b81e1f2197 100644 --- a/tests/unit/test_cron_tools.py +++ b/tests/unit/test_cron_tools.py @@ -1,17 +1,27 @@ """Tests for cron tool metadata.""" +from datetime import datetime from types import SimpleNamespace -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, MagicMock import pytest from astrbot.core.tools.cron_tools import FutureTaskTool -def _context(cron_mgr, *, umo: str = "test:group:shared", sender_id: str = "user-1"): +def _context( + cron_mgr, + *, + umo: str = "test:group:shared", + sender_id: str = "user-1", + tz_name: str | None = "Asia/Shanghai", +): return SimpleNamespace( context=SimpleNamespace( - context=SimpleNamespace(cron_manager=cron_mgr), + context=SimpleNamespace( + cron_manager=cron_mgr, + get_config=lambda umo=None: {"timezone": tz_name}, + ), event=SimpleNamespace( unified_msg_origin=umo, get_sender_id=lambda: sender_id, @@ -222,3 +232,138 @@ async def test_future_task_list_filters_by_umo_and_sender(): assert "own-job" in result assert "other-sender-job" not in result assert "other-umo-job" not in result + + +@pytest.mark.asyncio +async def test_future_task_list_localizes_naive_utc_next_run_time_to_shanghai(): + """List mode should treat a naive DB next_run_time as UTC and convert it. + + SQLite has no tz-aware datetime column, so the real DB layer always + returns next_run_time without tzinfo even though the stored instant is + UTC. This must not be misread as "already in the display timezone". + """ + tool = FutureTaskTool() + job = _job("job-1") + job.next_run_time = datetime(2026, 1, 1, 0, 0) + cron_mgr = SimpleNamespace(list_jobs=AsyncMock(return_value=[job])) + + result = await tool.call( + _context(cron_mgr, tz_name="Asia/Shanghai"), + action="list", + ) + + assert "2026-01-01 08:00:00+08:00" in result + + +@pytest.mark.asyncio +async def test_future_task_list_localizes_naive_utc_next_run_time_to_new_york(): + """List mode should honor a differently configured IANA timezone.""" + tool = FutureTaskTool() + job = _job("job-1") + job.next_run_time = datetime(2026, 1, 1, 0, 0) + cron_mgr = SimpleNamespace(list_jobs=AsyncMock(return_value=[job])) + + result = await tool.call( + _context(cron_mgr, tz_name="America/New_York"), + action="list", + ) + + assert "2025-12-31 19:00:00-05:00" in result + + +@pytest.mark.asyncio +async def test_future_task_list_falls_back_when_timezone_invalid(): + """An invalid configured timezone should not crash the tool.""" + tool = FutureTaskTool() + job = _job("job-1") + job.next_run_time = datetime(2026, 1, 1, 0, 0) + cron_mgr = SimpleNamespace(list_jobs=AsyncMock(return_value=[job])) + + result = await tool.call( + _context(cron_mgr, tz_name="Not/AZone"), + action="list", + ) + + assert "job-1" in result + + +@pytest.mark.asyncio +async def test_future_task_create_passes_config_timezone_to_scheduler(): + """Create mode should forward the configured timezone so recurring jobs don't + silently use the server's local timezone.""" + tool = FutureTaskTool() + created_job = SimpleNamespace( + job_id="job-1", + name="active_agent_task", + next_run_time=None, + ) + cron_mgr = SimpleNamespace( + add_active_job=AsyncMock(return_value=created_job), + get_next_run_time=MagicMock(return_value=datetime(2026, 1, 1, 0, 0)), + ) + + result = await tool.call( + _context(cron_mgr, tz_name="Asia/Shanghai"), + action="create", + cron_expression="0 8 * * *", + note="daily reminder", + ) + + cron_mgr.add_active_job.assert_awaited_once() + _, call_kwargs = cron_mgr.add_active_job.call_args + assert call_kwargs["timezone"] == "Asia/Shanghai" + cron_mgr.get_next_run_time.assert_called_once_with("job-1") + assert "2026-01-01 08:00:00+08:00" in result + + +@pytest.mark.asyncio +async def test_future_task_create_localizes_next_run_for_new_york(): + """Create mode's reported next-run time should reflect a non-Shanghai tz too.""" + tool = FutureTaskTool() + created_job = SimpleNamespace( + job_id="job-1", + name="active_agent_task", + next_run_time=None, + ) + cron_mgr = SimpleNamespace( + add_active_job=AsyncMock(return_value=created_job), + get_next_run_time=MagicMock(return_value=datetime(2026, 1, 1, 0, 0)), + ) + + result = await tool.call( + _context(cron_mgr, tz_name="America/New_York"), + action="create", + cron_expression="0 8 * * *", + note="daily reminder", + ) + + _, call_kwargs = cron_mgr.add_active_job.call_args + assert call_kwargs["timezone"] == "America/New_York" + assert "2025-12-31 19:00:00-05:00" in result + + +@pytest.mark.asyncio +async def test_future_task_create_falls_back_to_run_at_when_scheduler_has_no_time(): + """If the scheduler has not registered a next-run time yet (e.g. run_once + scheduled far in the future), create mode should fall back to displaying + the user-supplied run_at instead of the literal string 'None'.""" + tool = FutureTaskTool() + created_job = SimpleNamespace( + job_id="job-1", + name="active_agent_task", + next_run_time=None, + ) + cron_mgr = SimpleNamespace( + add_active_job=AsyncMock(return_value=created_job), + get_next_run_time=MagicMock(return_value=None), + ) + + result = await tool.call( + _context(cron_mgr, tz_name="Asia/Shanghai"), + action="create", + run_once=True, + run_at="2026-02-02T08:00:00+08:00", + note="one-time reminder", + ) + + assert "2026-02-02 08:00:00+08:00" in result