diff --git a/docs/chat-commands.md b/docs/chat-commands.md index 89c864bb..31377340 100644 --- a/docs/chat-commands.md +++ b/docs/chat-commands.md @@ -16,7 +16,7 @@ These commands work inside chat channels and interactive agent sessions: | `/dream-restore` | List recent Dream memory versions | | `/dream-restore ` | Restore memory to the state before a specific change | | `/skill` | List enabled skills and their descriptions | -| `/trigger` | Create a local external trigger for the current chat/session | +| `/trigger` | Show external trigger usage | | `/trigger ` | Create a named local external trigger for the current chat/session | | `/pairing` | List pending pairing requests | | `/pairing approve ` | Approve a pairing code | @@ -59,8 +59,9 @@ Preset names come from the top-level `modelPresets` config. Switching is runtime ## External Triggers -Use `/trigger` when a local script or another service should be able to send a -message into the current chat/session later. +Use `/trigger ` when a local script or another service should be able to +send a message into the current chat/session later. A name is required; plain +`/trigger` only shows the usage hint. Create the trigger from the chat where future messages should arrive: diff --git a/docs/cli-reference.md b/docs/cli-reference.md index bc93cff8..d8e27e28 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -13,7 +13,7 @@ Use this page when you know what you want to run and need the command shape. For | Send one test message | `nanobot agent -m "Hello!"` | First proof that install, config, provider, model, and workspace all work | | Chat in the terminal | `nanobot agent` | Interactive local chat; exit with `exit`, `/exit`, `:q`, or `Ctrl+D` | | Use WebUI or chat apps | `nanobot gateway` | Keep this terminal running, or use `nanobot gateway --background` | -| Deliver a local external trigger | `nanobot trigger "message"` | Created first with `/trigger` in the target chat/session | +| Deliver a local external trigger | `nanobot trigger "message"` | Created first with `/trigger ` in the target chat/session | | Serve an OpenAI-compatible API | `nanobot serve` | Starts `/v1/chat/completions`, `/v1/models`, and `/health` | | Check chat channel setup | `nanobot channels status` | Useful before starting `nanobot gateway` | | Log in to QR/OAuth-style channels | `nanobot channels login ` | Used by channels such as WhatsApp and WeChat | @@ -126,7 +126,7 @@ The bundled WebUI is served by the WebSocket channel, usually on port `8765`, no ## Local Triggers `nanobot trigger` delivers one local message to a trigger that was created from -a chat/session with `/trigger [name]`. +a chat/session with `/trigger `. ```bash nanobot trigger trg_8K4P2Q9X "Review PR #4502" diff --git a/docs/concepts.md b/docs/concepts.md index eb7559be..da39c80a 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -144,7 +144,7 @@ protected heartbeat system job. They run as scheduled turns in their origin chat/session and normally deliver the result back to that channel. External triggers are also session-bound, but they do not have their own -schedule. Create one from the target chat with `/trigger [name]`, then call +schedule. Create one from the target chat with `/trigger `, then call `nanobot trigger ""` when a local script or external service wants nanobot to respond in that session. Webhook servers, third-party auth, and event-to-message formatting stay outside nanobot. diff --git a/docs/webui.md b/docs/webui.md index 1bef8e7b..f1172b27 100644 --- a/docs/webui.md +++ b/docs/webui.md @@ -125,7 +125,7 @@ There are two user-facing automation types: - Scheduled automations, created by the agent's cron tool, run at a time, interval, or cron expression. -- External triggers, created with `/trigger [name]`, run when you call a local +- External triggers, created with `/trigger `, run when you call a local command such as `nanobot trigger trg_8K4P2Q9X "Review PR #4502"`. If a GitHub webhook, CI system, or another service should wake nanobot up, keep diff --git a/nanobot/channels/discord.py b/nanobot/channels/discord.py index b2259045..69427287 100644 --- a/nanobot/channels/discord.py +++ b/nanobot/channels/discord.py @@ -218,13 +218,13 @@ if DISCORD_AVAILABLE: command_text = f"/model {preset}" if preset else "/model" await self._forward_slash_command(interaction, command_text) - @self.tree.command(name="trigger", description="Create a local trigger for this chat") - @app_commands.describe(name="Optional trigger name") + @self.tree.command(name="trigger", description="Create a named local trigger for this chat") + @app_commands.describe(name="Trigger name") async def trigger_command( interaction: discord.Interaction, - name: str | None = None, + name: str, ) -> None: - name = (name or "").strip() + name = name.strip() command_text = f"/trigger {name}" if name else "/trigger" await self._forward_slash_command(interaction, command_text) diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py index 40eeef47..a2a4022e 100644 --- a/nanobot/channels/telegram.py +++ b/nanobot/channels/telegram.py @@ -411,7 +411,7 @@ class TelegramChannel(BaseChannel): BotCommand("status", "Show bot status"), BotCommand("history", "Show recent conversation messages"), BotCommand("goal", "Start a sustained objective (long-running task)"), - BotCommand("trigger", "Create a local trigger for this chat"), + BotCommand("trigger", "Create a named local trigger"), BotCommand("pairing", "Manage DM pairing (approve/deny/list)"), BotCommand("model", "Switch runtime model preset"), BotCommand("skill", "List enabled skills"), diff --git a/nanobot/command/builtin.py b/nanobot/command/builtin.py index 6e95d443..c2dd0db1 100644 --- a/nanobot/command/builtin.py +++ b/nanobot/command/builtin.py @@ -83,10 +83,10 @@ BUILTIN_COMMAND_SPECS: tuple[BuiltinCommandSpec, ...] = ( ), BuiltinCommandSpec( "/trigger", - "Create local trigger", - "Create a CLI trigger bound to this chat session.", + "Create named local trigger", + "Create a named CLI trigger bound to this chat session.", "zap", - "[name]", + "", ), BuiltinCommandSpec( "/dream", @@ -728,6 +728,18 @@ async def cmd_skill(ctx: CommandContext) -> OutboundMessage: async def cmd_trigger(ctx: CommandContext) -> OutboundMessage: """Create a local trigger bound to the current session.""" + name = ctx.args.strip() + if not name: + return OutboundMessage( + channel=ctx.msg.channel, + chat_id=ctx.msg.chat_id, + content=( + "Usage: /trigger \n\n" + "Create a named local trigger bound to this chat session." + ), + metadata={**dict(ctx.msg.metadata or {}), "render_as": "text"}, + ) + from nanobot.triggers.store import ExternalTriggerStore loop = ctx.loop @@ -741,7 +753,6 @@ async def cmd_trigger(ctx: CommandContext) -> OutboundMessage: if store is None: store = ExternalTriggerStore(workspace) - name = ctx.args.strip() or "External trigger" trigger = store.create( name=name, channel=ctx.msg.channel, diff --git a/tests/channels/test_discord_channel.py b/tests/channels/test_discord_channel.py index 1e3142f7..9ce91a9f 100644 --- a/tests/channels/test_discord_channel.py +++ b/tests/channels/test_discord_channel.py @@ -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", "trigger"]) +@pytest.mark.parametrize("slash_name", ["stop", "restart", "status", "history", "model"]) @pytest.mark.asyncio async def test_slash_commands_forward_via_handle_message(slash_name: str) -> None: channel = DiscordChannel(DiscordConfig(enabled=True, allow_from=["*"]), MessageBus()) @@ -919,7 +919,7 @@ async def test_slash_model_forwards_optional_preset() -> None: @pytest.mark.asyncio -async def test_slash_trigger_forwards_optional_name() -> None: +async def test_slash_trigger_forwards_required_name() -> None: channel = DiscordChannel(DiscordConfig(enabled=True, allow_from=["*"]), MessageBus()) handled: list[dict] = [] diff --git a/tests/channels/test_telegram_channel.py b/tests/channels/test_telegram_channel.py index fccbf9ed..bbf9e2eb 100644 --- a/tests/channels/test_telegram_channel.py +++ b/tests/channels/test_telegram_channel.py @@ -1577,6 +1577,7 @@ 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") assert pat.fullmatch("/trigger PR review") assert pat.fullmatch("/pairing list") assert pat.fullmatch("/model fast") diff --git a/tests/command/test_trigger_command.py b/tests/command/test_trigger_command.py index 9c0a9213..f9c6d039 100644 --- a/tests/command/test_trigger_command.py +++ b/tests/command/test_trigger_command.py @@ -45,5 +45,33 @@ async def test_trigger_command_creates_session_bound_local_trigger(tmp_path: Pat assert f"nanobot trigger {trigger.id} \"message\"" in response.content +@pytest.mark.asyncio +async def test_trigger_command_without_name_returns_usage_only(tmp_path: Path) -> None: + router = CommandRouter() + register_builtin_commands(router) + store = ExternalTriggerStore(tmp_path) + loop = SimpleNamespace(workspace=tmp_path, external_trigger_store=store) + msg = InboundMessage( + channel="websocket", + sender_id="user", + chat_id="chat-1", + content="/trigger@nanobot_bot", + metadata={"webui": True}, + ) + ctx = CommandContext( + msg=msg, + session=None, + key="websocket:chat-1", + raw="/trigger@nanobot_bot", + loop=loop, + ) + + response = await router.dispatch(ctx) + + assert response is not None + assert "Usage: /trigger " in response.content + assert store.list_for_session("websocket:chat-1") == [] + + def test_trigger_command_is_in_help_text() -> None: - assert "/trigger [name]" in build_help_text() + assert "/trigger " in build_help_text()