From 64e8f56a7b4bec1851b7f7d9ad672e8e04362133 Mon Sep 17 00:00:00 2001 From: AaronZ345 <34849476+AaronZ345@users.noreply.github.com> Date: Fri, 17 Jul 2026 17:06:08 +0800 Subject: [PATCH] feat: add cross-channel tasks command --- channel/telegram/telegram_channel.py | 1 + plugins/cow_cli/cow_cli.py | 83 ++++++++++++++++++- tests/test_cow_cli_tasks.py | 114 +++++++++++++++++++++++++++ 3 files changed, 197 insertions(+), 1 deletion(-) create mode 100644 tests/test_cow_cli_tasks.py diff --git a/channel/telegram/telegram_channel.py b/channel/telegram/telegram_channel.py index 9e40c59f..6b82b308 100644 --- a/channel/telegram/telegram_channel.py +++ b/channel/telegram/telegram_channel.py @@ -36,6 +36,7 @@ TELEGRAM_BOT_COMMANDS = [ ("help", "Show command help"), ("status", "Show running status"), ("context", "View/clear conversation context (sub: clear)"), + ("tasks", "List scheduled tasks for this chat"), ("skill", "Manage skills (list/search/install/...)"), ("memory", "Manage memory (sub: dream)"), ("knowledge", "Manage knowledge base (list/on/off)"), diff --git a/plugins/cow_cli/cow_cli.py b/plugins/cow_cli/cow_cli.py index ac26e831..3881db49 100644 --- a/plugins/cow_cli/cow_cli.py +++ b/plugins/cow_cli/cow_cli.py @@ -35,7 +35,7 @@ KNOWN_COMMANDS = { "help", "version", "status", "logs", "start", "stop", "restart", "cancel", - "skill", "context", "config", + "skill", "context", "config", "tasks", "knowledge", "memory", "install-browser", } @@ -357,6 +357,7 @@ class CowCliPlugin(Plugin): "/logs [N]: Show the last N log lines (default 20)", "/context: Show current conversation context", "/context clear: Clear current conversation context", + "/tasks: List scheduled tasks for this chat", "/skill list: List installed skills", "/skill list --remote: Browse Skill Hub", "/skill search : Search skills", @@ -385,6 +386,7 @@ class CowCliPlugin(Plugin): "/logs [N]: 查看最近N条日志 (默认20)", "/context: 查看当前对话上下文信息", "/context clear: 清除当前对话上下文", + "/tasks: 查看当前会话的定时任务", "/skill list: 查看已安装的技能", "/skill list --remote: 浏览技能广场", "/skill search <关键词>: 搜索技能", @@ -407,6 +409,85 @@ class CowCliPlugin(Plugin): def _cmd_version(self, args: str, e_context, **_) -> str: return f"CowAgent v{__version__}" + # ------------------------------------------------------------------ + # tasks — read-only scheduler list scoped to the current chat. + # Feishu intercepts /tasks before this plugin to keep its interactive card; + # every other channel receives this plain-text view. + # ------------------------------------------------------------------ + + def _cmd_tasks(self, args: str, e_context, session_id: str = "", **_) -> str: + from agent.tools.scheduler.integration import get_task_store + + task_store = get_task_store() + if task_store is None: + from agent.tools.scheduler.task_store import TaskStore + from common.utils import expand_path + + workspace = expand_path(conf().get("agent_workspace", "~/cow")) + task_store = TaskStore(os.path.join(workspace, "scheduler", "tasks.json")) + + channel_type = "" + receiver = "" + if e_context is not None: + context = e_context["context"] + channel_type = context.get("channel_type", "") or "" + receiver = context.get("receiver", "") or "" + session_id = context.get("session_id", "") or session_id + + visible = [] + for task in task_store.list_tasks(): + action = task.get("action") or {} + if receiver: + if action.get("receiver") != receiver: + continue + if channel_type and action.get("channel_type") != channel_type: + continue + elif session_id not in { + action.get("receiver"), + action.get("notify_session_id"), + }: + continue + visible.append(task) + + return self._format_tasks(visible) + + @staticmethod + def _format_tasks(tasks) -> str: + if not tasks: + return _t( + "📅 当前会话暂无定时任务。", + "📅 No scheduled tasks in this chat.", + ) + + lines = [_t("📅 当前会话的定时任务", "📅 Scheduled tasks in this chat"), ""] + for index, task in enumerate(tasks[:20], 1): + enabled = task.get("enabled", True) + status = "✅" if enabled else "⏸️" + schedule = task.get("schedule") or {} + schedule_type = schedule.get("type") + if schedule_type == "cron": + schedule_text = "cron {}".format(schedule.get("expression") or "?") + elif schedule_type == "interval": + schedule_text = "every {}s".format(schedule.get("seconds") or "?") + elif schedule_type == "once": + schedule_text = "once at {}".format(schedule.get("run_at") or "?") + else: + schedule_text = str(schedule_type or "unknown") + + next_run = str(task.get("next_run_at") or "-").replace("T", " ") + lines.extend( + [ + "{}. {} {}".format(index, status, task.get("name") or "Unnamed task"), + " ID: {}".format(task.get("id") or "-"), + " {} · Next: {}".format(schedule_text, next_run), + ] + ) + + hidden = len(tasks) - 20 + if hidden > 0: + lines.append(_t("\n另有 {} 个任务未显示。", "\n{} more tasks are hidden.").format(hidden)) + return "\n".join(lines) + # ------------------------------------------------------------------ # cancel — abort the in-flight agent run for the current session. # Fallback handler; in practice chat_channel/web_channel intercept diff --git a/tests/test_cow_cli_tasks.py b/tests/test_cow_cli_tasks.py new file mode 100644 index 00000000..57c292aa --- /dev/null +++ b/tests/test_cow_cli_tasks.py @@ -0,0 +1,114 @@ +import os +from types import SimpleNamespace + +import plugins + + +_old_plugin_path = plugins.instance.current_plugin_path +plugins.instance.current_plugin_path = os.path.join(os.getcwd(), "plugins", "cow_cli") +try: + from plugins.cow_cli.cow_cli import KNOWN_COMMANDS +finally: + plugins.instance.current_plugin_path = _old_plugin_path + +CowCliPlugin = plugins.instance.plugins["COW_CLI"] + + +class FakeTaskStore: + def __init__(self, tasks): + self.tasks = tasks + + def list_tasks(self): + return list(self.tasks) + + +def _task(task_id, receiver, channel_type, enabled=True, notify_session_id=None): + action = { + "receiver": receiver, + "channel_type": channel_type, + } + if notify_session_id: + action["notify_session_id"] = notify_session_id + return { + "id": task_id, + "name": f"Task {task_id}", + "enabled": enabled, + "schedule": {"type": "cron", "expression": "0 9 * * *"}, + "next_run_at": "2026-07-18T09:00:00", + "action": action, + } + + +def _event_context(channel_type, receiver, session_id): + context = SimpleNamespace( + kwargs={ + "channel_type": channel_type, + "receiver": receiver, + "session_id": session_id, + } + ) + context.get = context.kwargs.get + return {"context": context} + + +def test_tasks_is_a_known_command_and_is_listed_in_help(): + plugin = CowCliPlugin() + + assert "tasks" in KNOWN_COMMANDS + assert "/tasks" in plugin._cmd_help("", None) + + +def test_tasks_command_only_lists_tasks_owned_by_current_channel_and_receiver(monkeypatch): + store = FakeTaskStore( + [ + _task("mine", "user-1", "telegram"), + _task("other-channel", "user-1", "feishu"), + _task("other-user", "user-2", "telegram"), + ] + ) + monkeypatch.setattr( + "agent.tools.scheduler.integration.get_task_store", lambda: store + ) + plugin = CowCliPlugin() + + result = plugin._cmd_tasks( + "", _event_context("telegram", "user-1", "session-1") + ) + + assert "mine" in result + assert "other-channel" not in result + assert "other-user" not in result + + +def test_tasks_command_uses_session_identity_without_channel_context(monkeypatch): + store = FakeTaskStore( + [ + _task("direct", "session-1", "web"), + _task("notified", "chat-1", "feishu", notify_session_id="session-1"), + _task("hidden", "session-2", "web"), + ] + ) + monkeypatch.setattr( + "agent.tools.scheduler.integration.get_task_store", lambda: store + ) + plugin = CowCliPlugin() + + result = plugin._cmd_tasks("", None, session_id="session-1") + + assert "direct" in result + assert "notified" in result + assert "hidden" not in result + + +def test_tasks_command_formats_empty_state(monkeypatch): + monkeypatch.setattr( + "agent.tools.scheduler.integration.get_task_store", + lambda: FakeTaskStore([]), + ) + plugin = CowCliPlugin() + + result = plugin._cmd_tasks( + "", _event_context("slack", "channel-1", "session-1") + ) + + assert "task" in result.lower() or "任务" in result