fix(trigger): require names for trigger creation

This commit is contained in:
chengyongru
2026-07-02 13:32:46 +08:00
committed by Xubin Ren
parent 7178ea3f13
commit b690a48336
10 changed files with 60 additions and 19 deletions
+4 -3
View File
@@ -16,7 +16,7 @@ These commands work inside chat channels and interactive agent sessions:
| `/dream-restore` | List recent Dream memory versions |
| `/dream-restore <sha>` | 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 <name>` | Create a named local external trigger for the current chat/session |
| `/pairing` | List pending pairing requests |
| `/pairing approve <code>` | 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 <name>` 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:
+2 -2
View File
@@ -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 <id> "message"` | Created first with `/trigger` in the target chat/session |
| Deliver a local external trigger | `nanobot trigger <id> "message"` | Created first with `/trigger <name>` 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 <channel>` | 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 <name>`.
```bash
nanobot trigger trg_8K4P2Q9X "Review PR #4502"
+1 -1
View File
@@ -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 <name>`, then call
`nanobot trigger <id> "<message>"` 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.
+1 -1
View File
@@ -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 <name>`, 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
+4 -4
View File
@@ -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)
+1 -1
View File
@@ -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"),
+15 -4
View File
@@ -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]",
"<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 <name>\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,
+2 -2
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", "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] = []
+1
View File
@@ -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")
+29 -1
View File
@@ -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 <name>" 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 <name>" in build_help_text()