feat(trigger): add session-bound local triggers

This commit is contained in:
chengyongru
2026-07-02 13:32:46 +08:00
committed by Xubin Ren
parent c78421cf16
commit 2a0cd19a74
33 changed files with 1566 additions and 67 deletions
+26 -1
View File
@@ -867,7 +867,7 @@ async def test_slash_new_is_blocked_for_disallowed_user() -> None:
assert handled == []
@pytest.mark.parametrize("slash_name", ["stop", "restart", "status", "history", "model"])
@pytest.mark.parametrize("slash_name", ["stop", "restart", "status", "history", "model", "trigger"])
@pytest.mark.asyncio
async def test_slash_commands_forward_via_handle_message(slash_name: str) -> None:
channel = DiscordChannel(DiscordConfig(enabled=True, allow_from=["*"]), MessageBus())
@@ -918,6 +918,31 @@ async def test_slash_model_forwards_optional_preset() -> None:
assert handled[0]["metadata"]["is_slash_command"] is True
@pytest.mark.asyncio
async def test_slash_trigger_forwards_optional_name() -> None:
channel = DiscordChannel(DiscordConfig(enabled=True, allow_from=["*"]), MessageBus())
handled: list[dict] = []
async def capture_handle(**kwargs) -> None:
handled.append(kwargs)
channel._handle_message = capture_handle # type: ignore[method-assign]
client = DiscordBotClient(channel, intents=discord.Intents.none())
interaction = _make_interaction()
interaction.command.qualified_name = "trigger"
trigger_cmd = client.tree.get_command("trigger")
assert trigger_cmd is not None
await trigger_cmd.callback(interaction, name="PR review")
assert interaction.response.messages == [
{"content": "Processing /trigger PR review...", "ephemeral": True}
]
assert len(handled) == 1
assert handled[0]["content"] == "/trigger PR review"
assert handled[0]["metadata"]["is_slash_command"] is True
@pytest.mark.asyncio
async def test_slash_help_returns_ephemeral_help_text() -> None:
channel = DiscordChannel(DiscordConfig(enabled=True, allow_from=["*"]), MessageBus())
+3
View File
@@ -1577,12 +1577,14 @@ def test_telegram_bus_slash_command_regex_matches_agent_loop_commands() -> None:
assert pat.fullmatch("/history")
assert pat.fullmatch("/history 5")
assert pat.fullmatch("/goal ship the feature")
assert pat.fullmatch("/trigger PR review")
assert pat.fullmatch("/pairing list")
assert pat.fullmatch("/model fast")
assert pat.fullmatch("/skill")
assert pat.fullmatch("/skill@nanobot_bot")
assert pat.fullmatch("/new@nanobot_bot")
assert pat.fullmatch("/goal@nanobot_bot refine objective")
assert pat.fullmatch("/trigger@nanobot_bot CI summary")
assert pat.fullmatch("/dream-log deadbeef") is None
assert pat.fullmatch("/dream-restore deadbeef") is None
@@ -1606,6 +1608,7 @@ async def test_on_help_includes_restart_command() -> None:
assert "/dream" in help_text
assert "/dream-log" in help_text
assert "/goal" in help_text
assert "/trigger" in help_text
assert "/pairing" in help_text
assert "/model" in help_text
assert "/dream-restore" in help_text
@@ -20,6 +20,7 @@ from nanobot.cron.service import CronService
from nanobot.cron.types import CronJob, CronPayload, CronSchedule
from nanobot.session.keys import UNIFIED_SESSION_KEY
from nanobot.session.manager import Session, SessionManager
from nanobot.triggers.store import ExternalTriggerStore
from nanobot.webui.gateway_services import GatewayServices, build_gateway_services
_PORT = 29900
@@ -46,6 +47,7 @@ def _make_handler(
workspace_path: Path | None = None,
runtime_model_name: Any | None = None,
cron_service: CronService | None = None,
external_trigger_store: ExternalTriggerStore | None = None,
cron_pending_job_ids: Any | None = None,
) -> GatewayServices:
config = WebSocketConfig.model_validate(cfg) if isinstance(cfg, dict) else cfg
@@ -61,6 +63,7 @@ def _make_handler(
runtime_surface="browser",
runtime_capabilities_overrides=None,
cron_service=cron_service,
external_trigger_store=external_trigger_store,
cron_pending_job_ids=cron_pending_job_ids,
)
@@ -74,6 +77,7 @@ def _ch(
port: int = _PORT,
runtime_model_name: Any | None = None,
cron_service: CronService | None = None,
external_trigger_store: ExternalTriggerStore | None = None,
cron_pending_job_ids: Any | None = None,
**extra: Any,
) -> WebSocketChannel:
@@ -93,6 +97,7 @@ def _ch(
workspace_path=workspace_path,
runtime_model_name=runtime_model_name,
cron_service=cron_service,
external_trigger_store=external_trigger_store,
cron_pending_job_ids=cron_pending_job_ids,
)
return WebSocketChannel(cfg, bus, gateway=gateway)
@@ -319,6 +324,50 @@ async def test_session_automations_route_ignores_unified_owner(
await server_task
@pytest.mark.asyncio
async def test_session_automations_route_lists_external_triggers(
bus: MagicMock, tmp_path: Path
) -> None:
port = _free_port()
base_url = f"http://127.0.0.1:{port}"
trigger_store = ExternalTriggerStore(tmp_path)
trigger = trigger_store.create(
name="PR review",
channel="websocket",
chat_id="abc",
session_key="websocket:abc",
)
channel = _ch(
bus,
session_manager=_seed_session(tmp_path, key="websocket:abc"),
external_trigger_store=trigger_store,
port=port,
)
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try:
boot = await _http_get(f"{base_url}/webui/bootstrap")
token = boot.json()["token"]
auth = {"Authorization": f"Bearer {token}"}
resp = await _http_get(
f"{base_url}/api/sessions/websocket%3Aabc/automations",
headers=auth,
)
assert resp.status_code == 200
body = resp.json()
assert [job["id"] for job in body["jobs"]] == [trigger.id]
job = body["jobs"][0]
assert job["kind"] == "external_trigger"
assert job["schedule"]["kind"] == "external"
assert job["payload"]["kind"] == "external_trigger"
assert job["payload"]["command"] == f'nanobot trigger {trigger.id} "message"'
finally:
await channel.stop()
await server_task
@pytest.mark.asyncio
async def test_webui_skills_route_requires_token_and_hides_paths(
bus: MagicMock, tmp_path: Path
@@ -1080,6 +1129,86 @@ async def test_webui_automations_route_lists_all_jobs_and_allows_user_actions(
await server_task
@pytest.mark.asyncio
async def test_webui_automations_route_manages_external_triggers(
bus: MagicMock, tmp_path: Path
) -> None:
port = _free_port()
base_url = f"http://127.0.0.1:{port}"
trigger_store = ExternalTriggerStore(tmp_path)
trigger = trigger_store.create(
name="PR review",
channel="websocket",
chat_id="abc",
session_key="websocket:abc",
)
channel = _ch(
bus,
session_manager=_seed_session(tmp_path, key="websocket:abc"),
external_trigger_store=trigger_store,
port=port,
)
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try:
boot = await _http_get(f"{base_url}/webui/bootstrap")
token = boot.json()["token"]
auth = {"Authorization": f"Bearer {token}"}
listed = await _http_get(f"{base_url}/api/webui/automations", headers=auth)
assert listed.status_code == 200
by_id = {job["id"]: job for job in listed.json()["jobs"]}
assert by_id[trigger.id]["kind"] == "external_trigger"
assert by_id[trigger.id]["trigger"]["command"] == f'nanobot trigger {trigger.id} "message"'
disabled = await _http_get(
f"{base_url}/api/webui/automations/disable?id={trigger.id}",
headers=auth,
)
assert disabled.status_code == 200
stored = trigger_store.get(trigger.id)
assert stored is not None
assert stored.enabled is False
run = await _http_get(
f"{base_url}/api/webui/automations/run?id={trigger.id}",
headers=auth,
)
assert run.status_code == 409
assert "CLI message" in run.text
renamed = await _http_get(
f"{base_url}/api/webui/automations/update?id={trigger.id}",
headers={
**auth,
"X-Nanobot-Automation-Values": json.dumps({"name": "Release review"}),
},
)
assert renamed.status_code == 200
stored = trigger_store.get(trigger.id)
assert stored is not None
assert stored.name == "Release review"
bad_update = await _http_get(
f"{base_url}/api/webui/automations/update?id={trigger.id}",
headers={
**auth,
"X-Nanobot-Automation-Values": json.dumps({"message": "coupled"}),
},
)
assert bad_update.status_code == 400
deleted = await _http_get(
f"{base_url}/api/webui/automations/delete?id={trigger.id}",
headers=auth,
)
assert deleted.status_code == 200
assert trigger_store.get(trigger.id) is None
finally:
await channel.stop()
await server_task
@pytest.mark.asyncio
async def test_session_delete_blocks_when_bound_automation_exists(
bus: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
@@ -1121,6 +1250,54 @@ async def test_session_delete_blocks_when_bound_automation_exists(
await server_task
@pytest.mark.asyncio
async def test_session_delete_blocks_and_cascades_external_triggers(
bus: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
port = _free_port()
base_url = f"http://127.0.0.1:{port}"
sm = _seed_session(tmp_path, key="websocket:doomed")
trigger_store = ExternalTriggerStore(tmp_path)
trigger = trigger_store.create(
name="PR review",
channel="websocket",
chat_id="doomed",
session_key="websocket:doomed",
)
channel = _ch(
bus,
session_manager=sm,
external_trigger_store=trigger_store,
port=port,
)
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try:
boot = await _http_get(f"{base_url}/webui/bootstrap")
token = boot.json()["token"]
auth = {"Authorization": f"Bearer {token}"}
blocked = await _http_get(
f"{base_url}/api/sessions/websocket:doomed/delete",
headers=auth,
)
assert blocked.status_code == 200
assert blocked.json()["blocked_by_automations"] is True
assert trigger_store.get(trigger.id) is not None
deleted = await _http_get(
f"{base_url}/api/sessions/websocket:doomed/delete?delete_automations=true",
headers=auth,
)
assert deleted.status_code == 200
assert deleted.json()["deleted"] is True
assert trigger_store.get(trigger.id) is None
finally:
await channel.stop()
await server_task
@pytest.mark.asyncio
async def test_session_delete_can_cascade_bound_automations(
bus: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch