feat(trigger): add session-bound local triggers

This commit is contained in:
chengyongru 2026-06-30 00:36:29 +08:00 committed by Xubin Ren
parent c78421cf16
commit 2a0cd19a74
33 changed files with 1566 additions and 67 deletions

View File

@ -218,6 +218,16 @@ if DISCORD_AVAILABLE:
command_text = f"/model {preset}" if preset else "/model" command_text = f"/model {preset}" if preset else "/model"
await self._forward_slash_command(interaction, command_text) 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")
async def trigger_command(
interaction: discord.Interaction,
name: str | None = None,
) -> None:
name = (name or "").strip()
command_text = f"/trigger {name}" if name else "/trigger"
await self._forward_slash_command(interaction, command_text)
@self.tree.command(name="help", description="Show available commands") @self.tree.command(name="help", description="Show available commands")
async def help_command(interaction: discord.Interaction) -> None: async def help_command(interaction: discord.Interaction) -> None:
sender_id = str(interaction.user.id) sender_id = str(interaction.user.id)

View File

@ -68,6 +68,7 @@ class ChannelManager:
*, *,
session_manager: "SessionManager | None" = None, session_manager: "SessionManager | None" = None,
cron_service: Any | None = None, cron_service: Any | None = None,
external_trigger_store: Any | None = None,
webui_runtime_model_name: Callable[[], str | None] | None = None, webui_runtime_model_name: Callable[[], str | None] | None = None,
webui_cron_pending_job_ids: Callable[[str], set[str]] | None = None, webui_cron_pending_job_ids: Callable[[str], set[str]] | None = None,
webui_static_dist: bool = True, webui_static_dist: bool = True,
@ -78,6 +79,7 @@ class ChannelManager:
self.bus = bus self.bus = bus
self._session_manager = session_manager self._session_manager = session_manager
self._cron_service = cron_service self._cron_service = cron_service
self._external_trigger_store = external_trigger_store
self._webui_runtime_model_name = webui_runtime_model_name self._webui_runtime_model_name = webui_runtime_model_name
self._webui_cron_pending_job_ids = webui_cron_pending_job_ids self._webui_cron_pending_job_ids = webui_cron_pending_job_ids
self._webui_static_dist = webui_static_dist self._webui_static_dist = webui_static_dist
@ -139,6 +141,7 @@ class ChannelManager:
runtime_surface=self._webui_runtime_surface, runtime_surface=self._webui_runtime_surface,
runtime_capabilities_overrides=self._webui_runtime_capabilities, runtime_capabilities_overrides=self._webui_runtime_capabilities,
cron_service=self._cron_service, cron_service=self._cron_service,
external_trigger_store=self._external_trigger_store,
cron_pending_job_ids=self._webui_cron_pending_job_ids, cron_pending_job_ids=self._webui_cron_pending_job_ids,
logger=logger, logger=logger,
) )

View File

@ -411,6 +411,7 @@ class TelegramChannel(BaseChannel):
BotCommand("status", "Show bot status"), BotCommand("status", "Show bot status"),
BotCommand("history", "Show recent conversation messages"), BotCommand("history", "Show recent conversation messages"),
BotCommand("goal", "Start a sustained objective (long-running task)"), BotCommand("goal", "Start a sustained objective (long-running task)"),
BotCommand("trigger", "Create a local trigger for this chat"),
BotCommand("pairing", "Manage DM pairing (approve/deny/list)"), BotCommand("pairing", "Manage DM pairing (approve/deny/list)"),
BotCommand("model", "Switch runtime model preset"), BotCommand("model", "Switch runtime model preset"),
BotCommand("skill", "List enabled skills"), BotCommand("skill", "List enabled skills"),
@ -423,7 +424,7 @@ class TelegramChannel(BaseChannel):
# Regex for slash commands routed to AgentLoop via ``_forward_command``. # Regex for slash commands routed to AgentLoop via ``_forward_command``.
# Hyphenated ``dream-*`` commands stay on a separate handler (below). # Hyphenated ``dream-*`` commands stay on a separate handler (below).
TELEGRAM_BUS_SLASH_COMMAND_RE = re.compile( TELEGRAM_BUS_SLASH_COMMAND_RE = re.compile(
r"^/(?:new|stop|restart|status|dream|history|goal|pairing|model|skill)(?:@\w+)?(?:\s+.*)?$" r"^/(?:new|stop|restart|status|dream|history|goal|trigger|pairing|model|skill)(?:@\w+)?(?:\s+.*)?$"
) )
@classmethod @classmethod

View File

@ -718,6 +718,21 @@ def _load_runtime_config(config: str | None = None, workspace: str | None = None
return loaded return loaded
def _read_trigger_cli_message(message: str | None) -> str:
"""Read a trigger message from an argument or stdin."""
if message and message.strip():
return message
try:
if not sys.stdin.isatty():
content = sys.stdin.read()
if content.strip():
return content
except Exception:
pass
console.print("[red]Error: trigger message is required[/red]")
raise typer.Exit(1)
def _warn_deprecated_config_keys(config_path: Path | None) -> None: def _warn_deprecated_config_keys(config_path: Path | None) -> None:
"""Hint users to remove obsolete keys from their config file.""" """Hint users to remove obsolete keys from their config file."""
import json import json
@ -749,6 +764,35 @@ def _migrate_cron_store(config: "Config") -> None:
shutil.move(str(legacy_path), str(new_path)) shutil.move(str(legacy_path), str(new_path))
@app.command()
def trigger(
trigger_id: str = typer.Argument(..., help="Trigger ID returned by /trigger"),
message: str | None = typer.Argument(None, help="Message to deliver; stdin is used when omitted"),
workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"),
config: str | None = typer.Option(None, "--config", "-c", help="Config file path"),
):
"""Deliver a local trigger message to its bound chat session."""
from nanobot.triggers.store import (
ExternalTriggerStore,
TriggerDisabledError,
TriggerNotFoundError,
TriggerStoreError,
)
runtime_config = _load_runtime_config(config, workspace)
content = _read_trigger_cli_message(message)
store = ExternalTriggerStore(runtime_config.workspace_path)
try:
delivery = store.enqueue(trigger_id, content)
except (TriggerNotFoundError, TriggerDisabledError) as exc:
console.print(f"[red]Error: {exc}[/red]")
raise typer.Exit(1) from exc
except (TriggerStoreError, ValueError) as exc:
console.print(f"[red]Error: {exc}[/red]")
raise typer.Exit(1) from exc
console.print(f"[green]Queued[/green] {delivery.trigger_id} ({delivery.id})")
# ============================================================================ # ============================================================================
# OpenAI-Compatible API Server # OpenAI-Compatible API Server
# ============================================================================ # ============================================================================
@ -865,6 +909,8 @@ def _run_gateway(
from nanobot.providers.image_generation import image_gen_provider_configs from nanobot.providers.image_generation import image_gen_provider_configs
from nanobot.session.manager import SessionManager from nanobot.session.manager import SessionManager
from nanobot.session.webui_turns import WebuiTurnCoordinator from nanobot.session.webui_turns import WebuiTurnCoordinator
from nanobot.triggers.runner import run_external_trigger_queue
from nanobot.triggers.store import ExternalTriggerStore
from nanobot.webui.token_usage import TokenUsageHook from nanobot.webui.token_usage import TokenUsageHook
port = port if port is not None else config.gateway.port port = port if port is not None else config.gateway.port
@ -887,6 +933,7 @@ def _run_gateway(
# Create cron service with workspace-scoped store # Create cron service with workspace-scoped store
cron_store_path = config.workspace_path / "cron" / "jobs.json" cron_store_path = config.workspace_path / "cron" / "jobs.json"
cron = CronService(cron_store_path) cron = CronService(cron_store_path)
trigger_store = ExternalTriggerStore(config.workspace_path)
# Create agent with cron service # Create agent with cron service
agent = AgentLoop.from_config( agent = AgentLoop.from_config(
@ -907,6 +954,7 @@ def _run_gateway(
sessions=session_manager, sessions=session_manager,
schedule_background=lambda coro: agent._schedule_background(coro), schedule_background=lambda coro: agent._schedule_background(coro),
).subscribe(runtime_events) ).subscribe(runtime_events)
agent.external_trigger_store = trigger_store
from nanobot.bus.events import OutboundMessage from nanobot.bus.events import OutboundMessage
from nanobot.session.keys import session_key_for_channel from nanobot.session.keys import session_key_for_channel
@ -1103,6 +1151,7 @@ def _run_gateway(
bus, bus,
session_manager=session_manager, session_manager=session_manager,
cron_service=cron, cron_service=cron,
external_trigger_store=trigger_store,
webui_runtime_model_name=_webui_runtime_model_name, webui_runtime_model_name=_webui_runtime_model_name,
webui_cron_pending_job_ids=getattr(agent, "pending_cron_job_ids_for_session", None), webui_cron_pending_job_ids=getattr(agent, "pending_cron_job_ids_for_session", None),
webui_static_dist=webui_static_dist, webui_static_dist=webui_static_dist,
@ -1245,6 +1294,10 @@ def _run_gateway(
tasks = [ tasks = [
asyncio.create_task(agent.run(), name="nanobot-agent-loop"), asyncio.create_task(agent.run(), name="nanobot-agent-loop"),
asyncio.create_task(channels.start_all(), name="nanobot-channels"), asyncio.create_task(channels.start_all(), name="nanobot-channels"),
asyncio.create_task(
run_external_trigger_queue(store=trigger_store, bus=bus),
name="nanobot-external-triggers",
),
] ]
if health_server_enabled: if health_server_enabled:
tasks.append(asyncio.create_task( tasks.append(asyncio.create_task(

View File

@ -81,6 +81,13 @@ BUILTIN_COMMAND_SPECS: tuple[BuiltinCommandSpec, ...] = (
"activity", "activity",
"<goal>", "<goal>",
), ),
BuiltinCommandSpec(
"/trigger",
"Create local trigger",
"Create a CLI trigger bound to this chat session.",
"zap",
"[name]",
),
BuiltinCommandSpec( BuiltinCommandSpec(
"/dream", "/dream",
"Run Dream", "Run Dream",
@ -718,6 +725,43 @@ async def cmd_skill(ctx: CommandContext) -> OutboundMessage:
metadata=dict(ctx.msg.metadata or {}), metadata=dict(ctx.msg.metadata or {}),
) )
async def cmd_trigger(ctx: CommandContext) -> OutboundMessage:
"""Create a local trigger bound to the current session."""
from nanobot.triggers.store import ExternalTriggerStore
loop = ctx.loop
workspace = getattr(loop, "workspace", None)
if workspace is None:
workspace = getattr(getattr(loop, "context", None), "workspace", None)
if workspace is None:
raise RuntimeError("workspace unavailable for trigger creation")
store = getattr(loop, "external_trigger_store", None)
if store is None:
store = ExternalTriggerStore(workspace)
name = ctx.args.strip() or "External trigger"
trigger = store.create(
name=name,
channel=ctx.msg.channel,
chat_id=ctx.msg.chat_id,
session_key=ctx.key,
sender_id="trigger",
origin_metadata=dict(ctx.msg.metadata or {}),
)
command = f'nanobot trigger {trigger.id} "message"'
return OutboundMessage(
channel=ctx.msg.channel,
chat_id=ctx.msg.chat_id,
content=(
f"Trigger created: {trigger.name}\n"
f"ID: {trigger.id}\n\n"
f"Command:\n{command}"
),
metadata={**dict(ctx.msg.metadata or {}), "render_as": "text"},
)
async def cmd_help(ctx: CommandContext) -> OutboundMessage: async def cmd_help(ctx: CommandContext) -> OutboundMessage:
"""Return available slash commands.""" """Return available slash commands."""
return OutboundMessage( return OutboundMessage(
@ -752,6 +796,8 @@ def register_builtin_commands(router: CommandRouter) -> None:
router.prefix("/history ", cmd_history) router.prefix("/history ", cmd_history)
router.exact("/goal", cmd_goal) router.exact("/goal", cmd_goal)
router.prefix("/goal ", cmd_goal) router.prefix("/goal ", cmd_goal)
router.exact("/trigger", cmd_trigger)
router.prefix("/trigger ", cmd_trigger)
router.exact("/dream", cmd_dream) router.exact("/dream", cmd_dream)
router.exact("/dream-log", cmd_dream_log) router.exact("/dream-log", cmd_dream_log)
router.prefix("/dream-log ", cmd_dream_log) router.prefix("/dream-log ", cmd_dream_log)

View File

@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import re
from dataclasses import dataclass from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Awaitable, Callable from typing import TYPE_CHECKING, Any, Awaitable, Callable
@ -10,6 +11,26 @@ if TYPE_CHECKING:
from nanobot.session.manager import Session from nanobot.session.manager import Session
Handler = Callable[["CommandContext"], Awaitable["OutboundMessage | None"]] Handler = Callable[["CommandContext"], Awaitable["OutboundMessage | None"]]
_BOT_SUFFIX_RE = re.compile(r"^[A-Za-z0-9_]+$")
def normalize_command_text(text: str) -> str:
"""Normalize slash-command transport variants before routing.
Telegram and Discord-style command dispatch can produce ``/cmd@bot args``.
The bot suffix belongs to the transport, not the command name, so strip it
once at the router boundary while preserving user arguments verbatim.
"""
stripped = text.strip()
if not stripped.startswith("/"):
return stripped
first, sep, rest = stripped.partition(" ")
if "@" not in first:
return stripped
command, suffix = first.rsplit("@", 1)
if command and suffix and _BOT_SUFFIX_RE.fullmatch(suffix):
return f"{command}{sep}{rest}" if sep else command
return stripped
@dataclass @dataclass
@ -50,7 +71,7 @@ class CommandRouter:
self._prefix.sort(key=lambda p: len(p[0]), reverse=True) self._prefix.sort(key=lambda p: len(p[0]), reverse=True)
def is_priority(self, text: str) -> bool: def is_priority(self, text: str) -> bool:
return text.strip().lower() in self._priority return normalize_command_text(text).lower() in self._priority
def is_dispatchable_command(self, text: str) -> bool: def is_dispatchable_command(self, text: str) -> bool:
"""Check whether *text* matches any non-priority command tier (exact or prefix). """Check whether *text* matches any non-priority command tier (exact or prefix).
@ -58,7 +79,7 @@ class CommandRouter:
Does NOT check priority tier. Does NOT check priority tier.
If this returns True, ``dispatch()`` is guaranteed to match a handler. If this returns True, ``dispatch()`` is guaranteed to match a handler.
""" """
cmd = text.strip().lower() cmd = normalize_command_text(text).lower()
if cmd in self._exact: if cmd in self._exact:
return True return True
for pfx, _ in self._prefix: for pfx, _ in self._prefix:
@ -68,6 +89,7 @@ class CommandRouter:
async def dispatch_priority(self, ctx: CommandContext) -> OutboundMessage | None: async def dispatch_priority(self, ctx: CommandContext) -> OutboundMessage | None:
"""Dispatch a priority command. Called from run() without the lock.""" """Dispatch a priority command. Called from run() without the lock."""
ctx.raw = normalize_command_text(ctx.raw)
handler = self._priority.get(ctx.raw.lower()) handler = self._priority.get(ctx.raw.lower())
if handler: if handler:
return await handler(ctx) return await handler(ctx)
@ -75,6 +97,7 @@ class CommandRouter:
async def dispatch(self, ctx: CommandContext) -> OutboundMessage | None: async def dispatch(self, ctx: CommandContext) -> OutboundMessage | None:
"""Try exact, then prefix handlers. Returns None if unhandled.""" """Try exact, then prefix handlers. Returns None if unhandled."""
ctx.raw = normalize_command_text(ctx.raw)
cmd = ctx.raw.lower() cmd = ctx.raw.lower()
if handler := self._exact.get(cmd): if handler := self._exact.get(cmd):

View File

@ -0,0 +1,19 @@
"""Local external trigger support."""
from nanobot.triggers.store import (
ExternalTriggerStore,
TriggerDisabledError,
TriggerNotFoundError,
TriggerStoreError,
)
from nanobot.triggers.types import ExternalTrigger, TriggerDelivery, TriggerRunRecord
__all__ = [
"ExternalTrigger",
"ExternalTriggerStore",
"TriggerDelivery",
"TriggerDisabledError",
"TriggerNotFoundError",
"TriggerRunRecord",
"TriggerStoreError",
]

120
nanobot/triggers/runner.py Normal file
View File

@ -0,0 +1,120 @@
"""Gateway delivery loop for local external triggers."""
from __future__ import annotations
import asyncio
import uuid
from typing import Any
from loguru import logger
from nanobot.bus.events import InboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.triggers.store import ExternalTriggerStore
from nanobot.triggers.types import ExternalTrigger, TriggerDelivery
from nanobot.webui.metadata import WEBUI_MESSAGE_SOURCE_METADATA_KEY, WEBUI_TURN_METADATA_KEY
EXTERNAL_TRIGGER_META = "_external_trigger"
async def run_external_trigger_queue(
*,
store: ExternalTriggerStore,
bus: MessageBus,
poll_interval_s: float = 0.5,
batch_size: int = 20,
) -> None:
"""Poll local trigger deliveries and publish them as normal inbound messages."""
logger.info("External trigger queue started")
while True:
deliveries = store.claim_deliveries(limit=batch_size)
if not deliveries:
await asyncio.sleep(poll_interval_s)
continue
for delivery in deliveries:
try:
await _publish_delivery(store, bus, delivery)
store.complete_delivery(delivery)
except asyncio.CancelledError as exc:
store.retry_delivery(delivery, str(exc) or exc.__class__.__name__)
raise
except _TerminalDeliveryError as exc:
store.record_delivery(
delivery.trigger_id,
status="error",
error=str(exc),
run_at_ms=delivery.created_at_ms,
)
store.complete_delivery(delivery)
logger.warning(
"Trigger: dropped delivery {} for {}: {}",
delivery.id,
delivery.trigger_id,
exc,
)
except Exception as exc:
error = str(exc) or exc.__class__.__name__
retried = store.retry_delivery(delivery, error)
store.record_delivery(
delivery.trigger_id,
status="error",
error=error,
run_at_ms=delivery.created_at_ms,
)
logger.exception(
"Trigger: failed delivery {} for {}{}",
delivery.id,
delivery.trigger_id,
"; queued retry" if retried else "; moved to failed queue",
)
class _TerminalDeliveryError(RuntimeError):
pass
async def _publish_delivery(
store: ExternalTriggerStore,
bus: MessageBus,
delivery: TriggerDelivery,
) -> None:
trigger = store.get(delivery.trigger_id)
if trigger is None:
raise _TerminalDeliveryError("trigger not found")
if not trigger.enabled:
raise _TerminalDeliveryError("trigger is disabled")
await bus.publish_inbound(
InboundMessage(
channel=trigger.channel,
sender_id=trigger.sender_id,
chat_id=trigger.chat_id,
content=delivery.content,
metadata=_delivery_metadata(trigger, delivery),
session_key_override=trigger.session_key,
)
)
store.record_delivery(
trigger.id,
status="ok",
run_at_ms=delivery.created_at_ms,
)
def _delivery_metadata(trigger: ExternalTrigger, delivery: TriggerDelivery) -> dict[str, Any]:
metadata = dict(trigger.origin_metadata or {})
metadata[EXTERNAL_TRIGGER_META] = {
"trigger_id": trigger.id,
"trigger_name": trigger.name,
"delivery_id": delivery.id,
"created_at_ms": delivery.created_at_ms,
}
if trigger.channel == "websocket":
metadata.pop(WEBUI_TURN_METADATA_KEY, None)
metadata[WEBUI_TURN_METADATA_KEY] = f"trigger:{trigger.id}:{uuid.uuid4().hex}"
source: dict[str, str] = {"kind": "trigger"}
if trigger.name:
source["label"] = trigger.name
metadata[WEBUI_MESSAGE_SOURCE_METADATA_KEY] = source
return metadata

344
nanobot/triggers/store.py Normal file
View File

@ -0,0 +1,344 @@
"""Workspace-scoped local trigger store and delivery queue."""
from __future__ import annotations
import json
import os
import secrets
import time
import uuid
from contextlib import suppress
from pathlib import Path
from typing import Any
from filelock import FileLock
from loguru import logger
from nanobot.triggers.types import ExternalTrigger, TriggerDelivery, TriggerRunRecord
_TRIGGER_ID_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
_MAX_RUN_HISTORY = 20
_MAX_DELIVERY_ATTEMPTS = 10
class TriggerStoreError(RuntimeError):
"""Base class for trigger store errors."""
class TriggerNotFoundError(TriggerStoreError):
"""Raised when a trigger ID does not exist."""
class TriggerDisabledError(TriggerStoreError):
"""Raised when a trigger is disabled."""
class ExternalTriggerStore:
"""Persistent local triggers for one workspace."""
def __init__(self, workspace_path: Path):
self.workspace_path = Path(workspace_path)
self.root = self.workspace_path / "triggers"
self.store_path = self.root / "triggers.json"
self.inbox_dir = self.root / "inbox"
self.processing_dir = self.root / "processing"
self.failed_dir = self.root / "failed"
self._lock = FileLock(str(self.root / ".lock"))
def create(
self,
*,
name: str,
channel: str,
chat_id: str,
session_key: str,
sender_id: str = "trigger",
origin_metadata: dict[str, Any] | None = None,
) -> ExternalTrigger:
"""Create a new session-bound external trigger."""
clean_name = _clean_name(name)
channel = channel.strip()
chat_id = chat_id.strip()
session_key = session_key.strip()
if not channel or not chat_id or not session_key:
raise ValueError("channel, chat_id, and session_key are required")
now = _now_ms()
self._ensure_dirs()
with self._lock:
triggers = self._load_triggers_unlocked()
existing_ids = {trigger.id for trigger in triggers}
trigger_id = _new_trigger_id(existing_ids)
trigger = ExternalTrigger(
id=trigger_id,
name=clean_name,
enabled=True,
channel=channel,
chat_id=chat_id,
session_key=session_key,
sender_id=sender_id.strip() or "trigger",
origin_metadata=dict(origin_metadata or {}),
created_at_ms=now,
updated_at_ms=now,
)
triggers.append(trigger)
self._save_triggers_unlocked(triggers)
return trigger
def list_triggers(self, *, include_disabled: bool = False) -> list[ExternalTrigger]:
"""List triggers in this workspace."""
self._ensure_dirs()
with self._lock:
triggers = self._load_triggers_unlocked()
if not include_disabled:
triggers = [trigger for trigger in triggers if trigger.enabled]
return sorted(triggers, key=lambda trigger: (trigger.updated_at_ms, trigger.id), reverse=True)
def list_for_session(
self,
session_key: str,
*,
include_disabled: bool = True,
) -> list[ExternalTrigger]:
"""List triggers bound to one session key."""
return [
trigger
for trigger in self.list_triggers(include_disabled=include_disabled)
if trigger.session_key == session_key
]
def get(self, trigger_id: str) -> ExternalTrigger | None:
"""Return one trigger by ID."""
self._ensure_dirs()
with self._lock:
return self._find_unlocked(self._load_triggers_unlocked(), trigger_id)
def enable(self, trigger_id: str, *, enabled: bool) -> ExternalTrigger | None:
"""Enable or disable a trigger."""
self._ensure_dirs()
with self._lock:
triggers = self._load_triggers_unlocked()
trigger = self._find_unlocked(triggers, trigger_id)
if trigger is None:
return None
trigger.enabled = enabled
trigger.updated_at_ms = _now_ms()
self._save_triggers_unlocked(triggers)
return trigger
def update(self, trigger_id: str, *, name: str | None = None) -> ExternalTrigger | None:
"""Update mutable trigger fields."""
self._ensure_dirs()
with self._lock:
triggers = self._load_triggers_unlocked()
trigger = self._find_unlocked(triggers, trigger_id)
if trigger is None:
return None
if name is not None:
trigger.name = _clean_name(name)
trigger.updated_at_ms = _now_ms()
self._save_triggers_unlocked(triggers)
return trigger
def delete(self, trigger_id: str) -> bool:
"""Delete a trigger by ID."""
self._ensure_dirs()
with self._lock:
triggers = self._load_triggers_unlocked()
remaining = [trigger for trigger in triggers if trigger.id != trigger_id]
if len(remaining) == len(triggers):
return False
self._save_triggers_unlocked(remaining)
return True
def enqueue(self, trigger_id: str, content: str) -> TriggerDelivery:
"""Queue a delivery for the gateway process to consume."""
trigger_id = trigger_id.strip()
if not content.strip():
raise ValueError("trigger message is required")
self._ensure_dirs()
with self._lock:
trigger = self._find_unlocked(self._load_triggers_unlocked(), trigger_id)
if trigger is None:
raise TriggerNotFoundError(f"trigger not found: {trigger_id}")
if not trigger.enabled:
raise TriggerDisabledError(f"trigger is disabled: {trigger_id}")
delivery = TriggerDelivery(
id=f"tdl_{uuid.uuid4().hex[:12]}",
trigger_id=trigger_id,
content=content,
created_at_ms=_now_ms(),
)
path = self.inbox_dir / f"{delivery.created_at_ms}-{delivery.id}.json"
self._atomic_write(path, json.dumps(_delivery_payload(delivery), ensure_ascii=False))
delivery.path = path
return delivery
def claim_deliveries(self, *, limit: int = 20) -> list[TriggerDelivery]:
"""Move pending deliveries into processing and return them."""
self._ensure_dirs()
claimed: list[TriggerDelivery] = []
with self._lock:
for path in sorted(self.inbox_dir.glob("*.json"))[: max(0, limit)]:
try:
data = json.loads(path.read_text(encoding="utf-8"))
delivery = TriggerDelivery.from_dict(
data.get("delivery", data),
path=self.processing_dir / path.name,
)
except Exception:
logger.exception("Trigger: failed to parse delivery {}", path)
self._move_bad_delivery_unlocked(path)
continue
os.replace(path, delivery.path)
claimed.append(delivery)
return claimed
def complete_delivery(self, delivery: TriggerDelivery) -> None:
"""Delete a claimed delivery after it is handled."""
if delivery.path is None:
return
self._ensure_dirs()
with self._lock:
delivery.path.unlink(missing_ok=True)
def retry_delivery(self, delivery: TriggerDelivery, error: str) -> bool:
"""Retry a claimed delivery unless it exceeded the attempt limit."""
if delivery.path is None:
return False
self._ensure_dirs()
with self._lock:
if delivery.attempts + 1 >= _MAX_DELIVERY_ATTEMPTS:
delivery.attempts += 1
delivery.last_error = error
failed = self.failed_dir / delivery.path.name
self._atomic_write(failed, json.dumps(_delivery_payload(delivery), ensure_ascii=False))
delivery.path.unlink(missing_ok=True)
return False
delivery.attempts += 1
delivery.last_error = error
target = self.inbox_dir / delivery.path.name
self._atomic_write(target, json.dumps(_delivery_payload(delivery), ensure_ascii=False))
delivery.path.unlink(missing_ok=True)
return True
def record_delivery(
self,
trigger_id: str,
*,
status: str,
error: str | None = None,
run_at_ms: int | None = None,
) -> None:
"""Record the latest delivery status on a trigger."""
self._ensure_dirs()
run_at_ms = run_at_ms or _now_ms()
with self._lock:
triggers = self._load_triggers_unlocked()
trigger = self._find_unlocked(triggers, trigger_id)
if trigger is None:
return
trigger.last_run_at_ms = run_at_ms
trigger.last_status = "ok" if status == "ok" else "error"
trigger.last_error = None if status == "ok" else (error or "delivery failed")
trigger.updated_at_ms = _now_ms()
trigger.run_history.append(
TriggerRunRecord(
run_at_ms=run_at_ms,
status=trigger.last_status,
error=trigger.last_error,
)
)
trigger.run_history = trigger.run_history[-_MAX_RUN_HISTORY:]
self._save_triggers_unlocked(triggers)
def _ensure_dirs(self) -> None:
self.root.mkdir(parents=True, exist_ok=True)
self.inbox_dir.mkdir(parents=True, exist_ok=True)
self.processing_dir.mkdir(parents=True, exist_ok=True)
self.failed_dir.mkdir(parents=True, exist_ok=True)
def _load_triggers_unlocked(self) -> list[ExternalTrigger]:
if not self.store_path.exists():
return []
try:
data = json.loads(self.store_path.read_text(encoding="utf-8"))
return [
ExternalTrigger.from_dict(raw)
for raw in data.get("triggers", [])
if isinstance(raw, dict)
]
except Exception as exc:
backup = self.store_path.with_suffix(
self.store_path.suffix + f".corrupt-{int(time.time())}"
)
with suppress(OSError):
os.replace(self.store_path, backup)
raise TriggerStoreError(
f"trigger store at {self.store_path} could not be loaded and was preserved "
"as a .corrupt-<ts> backup"
) from exc
def _save_triggers_unlocked(self, triggers: list[ExternalTrigger]) -> None:
payload = {
"version": 1,
"triggers": [trigger.to_dict() for trigger in triggers],
}
self._atomic_write(self.store_path, json.dumps(payload, indent=2, ensure_ascii=False))
@staticmethod
def _find_unlocked(
triggers: list[ExternalTrigger],
trigger_id: str,
) -> ExternalTrigger | None:
return next((trigger for trigger in triggers if trigger.id == trigger_id), None)
def _move_bad_delivery_unlocked(self, path: Path) -> None:
target = self.failed_dir / f"{path.name}.bad"
with suppress(OSError):
os.replace(path, target)
@staticmethod
def _atomic_write(path: Path, content: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
tmp_path = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp")
try:
with open(tmp_path, "w", encoding="utf-8") as f:
f.write(content)
f.flush()
os.fsync(f.fileno())
os.replace(tmp_path, path)
with suppress(PermissionError):
fd = os.open(str(path.parent), os.O_RDONLY)
try:
os.fsync(fd)
finally:
os.close(fd)
except BaseException:
tmp_path.unlink(missing_ok=True)
raise
def _new_trigger_id(existing_ids: set[str]) -> str:
for _ in range(100):
suffix = "".join(secrets.choice(_TRIGGER_ID_ALPHABET) for _ in range(8))
candidate = f"trg_{suffix}"
if candidate not in existing_ids:
return candidate
raise TriggerStoreError("could not allocate a unique trigger id")
def _clean_name(name: str) -> str:
stripped = " ".join(name.strip().split())
return (stripped or "External trigger")[:120]
def _now_ms() -> int:
return int(time.time() * 1000)
def _delivery_payload(delivery: TriggerDelivery) -> dict[str, Any]:
return {
"version": 1,
"delivery": delivery.to_dict(),
}

141
nanobot/triggers/types.py Normal file
View File

@ -0,0 +1,141 @@
"""Persistent types for local external triggers."""
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Literal
TriggerStatus = Literal["ok", "error"]
def _get(data: dict[str, Any], camel: str, snake: str, default: Any = None) -> Any:
if camel in data:
return data[camel]
return data.get(snake, default)
@dataclass
class TriggerRunRecord:
"""A single local trigger delivery record."""
run_at_ms: int
status: TriggerStatus
error: str | None = None
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "TriggerRunRecord":
return cls(
run_at_ms=int(_get(data, "runAtMs", "run_at_ms", 0)),
status=str(data.get("status") or "error"), # type: ignore[arg-type]
error=data.get("error"),
)
def to_dict(self) -> dict[str, Any]:
return {
"runAtMs": self.run_at_ms,
"status": self.status,
"error": self.error,
}
@dataclass
class ExternalTrigger:
"""A session-bound local trigger."""
id: str
name: str
enabled: bool
channel: str
chat_id: str
session_key: str
sender_id: str = "trigger"
origin_metadata: dict[str, Any] = field(default_factory=dict)
created_at_ms: int = 0
updated_at_ms: int = 0
last_run_at_ms: int | None = None
last_status: TriggerStatus | None = None
last_error: str | None = None
run_history: list[TriggerRunRecord] = field(default_factory=list)
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "ExternalTrigger":
history = [
record if isinstance(record, TriggerRunRecord) else TriggerRunRecord.from_dict(record)
for record in data.get("runHistory", data.get("run_history", []))
if isinstance(record, (dict, TriggerRunRecord))
]
return cls(
id=str(data["id"]),
name=str(data.get("name") or data["id"]),
enabled=bool(data.get("enabled", True)),
channel=str(data.get("channel") or ""),
chat_id=str(_get(data, "chatId", "chat_id", "")),
session_key=str(_get(data, "sessionKey", "session_key", "")),
sender_id=str(_get(data, "senderId", "sender_id", "trigger") or "trigger"),
origin_metadata=dict(_get(data, "originMetadata", "origin_metadata", {}) or {}),
created_at_ms=int(_get(data, "createdAtMs", "created_at_ms", 0)),
updated_at_ms=int(_get(data, "updatedAtMs", "updated_at_ms", 0)),
last_run_at_ms=_get(data, "lastRunAtMs", "last_run_at_ms"),
last_status=_get(data, "lastStatus", "last_status"), # type: ignore[arg-type]
last_error=_get(data, "lastError", "last_error"),
run_history=history,
)
def to_dict(self) -> dict[str, Any]:
return {
"id": self.id,
"name": self.name,
"enabled": self.enabled,
"channel": self.channel,
"chatId": self.chat_id,
"sessionKey": self.session_key,
"senderId": self.sender_id,
"originMetadata": self.origin_metadata,
"createdAtMs": self.created_at_ms,
"updatedAtMs": self.updated_at_ms,
"lastRunAtMs": self.last_run_at_ms,
"lastStatus": self.last_status,
"lastError": self.last_error,
"runHistory": [record.to_dict() for record in self.run_history],
}
@dataclass
class TriggerDelivery:
"""One pending local trigger delivery written by the CLI."""
id: str
trigger_id: str
content: str
created_at_ms: int
attempts: int = 0
last_error: str | None = None
path: Path | None = field(default=None, compare=False, repr=False)
@classmethod
def from_dict(
cls,
data: dict[str, Any],
*,
path: Path | None = None,
) -> "TriggerDelivery":
return cls(
id=str(data["id"]),
trigger_id=str(_get(data, "triggerId", "trigger_id", "")),
content=str(data.get("content") or ""),
created_at_ms=int(_get(data, "createdAtMs", "created_at_ms", 0)),
attempts=int(data.get("attempts", 0)),
last_error=data.get("lastError") or data.get("last_error"),
path=path,
)
def to_dict(self) -> dict[str, Any]:
return {
"id": self.id,
"triggerId": self.trigger_id,
"content": self.content,
"createdAtMs": self.created_at_ms,
"attempts": self.attempts,
"lastError": self.last_error,
}

View File

@ -26,6 +26,7 @@ class GatewayServices:
workspaces: WebUIWorkspaceController workspaces: WebUIWorkspaceController
session_manager: Any | None session_manager: Any | None
cron_service: Any | None cron_service: Any | None
external_trigger_store: Any | None
cron_pending_job_ids: Callable[[str], set[str]] | None cron_pending_job_ids: Callable[[str], set[str]] | None
@ -42,6 +43,7 @@ def build_gateway_services(
runtime_capabilities_overrides: dict[str, Any] | None, runtime_capabilities_overrides: dict[str, Any] | None,
disabled_skills: set[str] | None = None, disabled_skills: set[str] | None = None,
cron_service: Any | None = None, cron_service: Any | None = None,
external_trigger_store: Any | None = None,
cron_pending_job_ids: Callable[[str], set[str]] | None = None, cron_pending_job_ids: Callable[[str], set[str]] | None = None,
logger: Any = default_logger, logger: Any = default_logger,
) -> GatewayServices: ) -> GatewayServices:
@ -70,6 +72,7 @@ def build_gateway_services(
skills_workspace_path=workspace_path, skills_workspace_path=workspace_path,
disabled_skills=disabled_skills, disabled_skills=disabled_skills,
cron_service=cron_service, cron_service=cron_service,
external_trigger_store=external_trigger_store,
cron_pending_job_ids=cron_pending_job_ids, cron_pending_job_ids=cron_pending_job_ids,
log=logger, log=logger,
) )
@ -81,5 +84,6 @@ def build_gateway_services(
workspaces=workspaces, workspaces=workspaces,
session_manager=session_manager, session_manager=session_manager,
cron_service=cron_service, cron_service=cron_service,
external_trigger_store=external_trigger_store,
cron_pending_job_ids=cron_pending_job_ids, cron_pending_job_ids=cron_pending_job_ids,
) )

View File

@ -8,6 +8,9 @@ from typing import Any, Protocol
from nanobot.cron.session_turns import CRON_HISTORY_META from nanobot.cron.session_turns import CRON_HISTORY_META
from nanobot.cron.types import CronJob from nanobot.cron.types import CronJob
from nanobot.session.manager import _message_preview_text from nanobot.session.manager import _message_preview_text
from nanobot.triggers.types import ExternalTrigger
AutomationJob = CronJob | ExternalTrigger
class _CronServiceLike(Protocol): class _CronServiceLike(Protocol):
@ -21,6 +24,17 @@ class _CronServiceLike(Protocol):
) -> list[CronJob]: ... ) -> list[CronJob]: ...
class _ExternalTriggerStoreLike(Protocol):
def list_triggers(self, *, include_disabled: bool = False) -> list[ExternalTrigger]: ...
def list_for_session(
self,
session_key: str,
*,
include_disabled: bool = True,
) -> list[ExternalTrigger]: ...
class _SessionManagerLike(Protocol): class _SessionManagerLike(Protocol):
def read_session_file(self, key: str) -> dict[str, Any] | None: ... def read_session_file(self, key: str) -> dict[str, Any] | None: ...
@ -28,26 +42,43 @@ class _SessionManagerLike(Protocol):
def session_automation_jobs( def session_automation_jobs(
cron_service: _CronServiceLike | None, cron_service: _CronServiceLike | None,
session_key: str, session_key: str,
) -> list[CronJob]: *,
external_trigger_store: _ExternalTriggerStoreLike | None = None,
) -> list[AutomationJob]:
"""Return user automations attached to the WebUI session.""" """Return user automations attached to the WebUI session."""
if cron_service is None: jobs: list[AutomationJob] = []
return [] if cron_service is not None:
return cron_service.list_bound_cron_jobs_for_session( jobs.extend(
session_key, cron_service.list_bound_cron_jobs_for_session(
include_disabled=True, session_key,
) include_disabled=True,
)
)
if external_trigger_store is not None:
jobs.extend(
external_trigger_store.list_for_session(
session_key,
include_disabled=True,
)
)
return jobs
def session_automations_payload( def session_automations_payload(
cron_service: _CronServiceLike | None, cron_service: _CronServiceLike | None,
session_key: str, session_key: str,
*, *,
external_trigger_store: _ExternalTriggerStoreLike | None = None,
pending_job_ids: Collection[str] | None = None, pending_job_ids: Collection[str] | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Return user-created automation jobs attached to a WebUI session.""" """Return user-created automation jobs attached to a WebUI session."""
return { return {
"jobs": serialize_automation_jobs( "jobs": serialize_automation_jobs(
session_automation_jobs(cron_service, session_key), session_automation_jobs(
cron_service,
session_key,
external_trigger_store=external_trigger_store,
),
pending_job_ids=pending_job_ids, pending_job_ids=pending_job_ids,
) )
} }
@ -56,11 +87,16 @@ def session_automations_payload(
def all_automations_payload( def all_automations_payload(
cron_service: _CronServiceLike | None, cron_service: _CronServiceLike | None,
*, *,
external_trigger_store: _ExternalTriggerStoreLike | None = None,
session_manager: _SessionManagerLike | None = None, session_manager: _SessionManagerLike | None = None,
pending_job_ids: Collection[str] | None = None, pending_job_ids: Collection[str] | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Return all cron jobs visible to the WebUI automation manager.""" """Return all cron jobs visible to the WebUI automation manager."""
jobs = cron_service.list_jobs(include_disabled=True) if cron_service is not None else [] jobs: list[AutomationJob] = []
if cron_service is not None:
jobs.extend(cron_service.list_jobs(include_disabled=True))
if external_trigger_store is not None:
jobs.extend(external_trigger_store.list_triggers(include_disabled=True))
return { return {
"jobs": serialize_automation_jobs( "jobs": serialize_automation_jobs(
jobs, jobs,
@ -72,7 +108,7 @@ def all_automations_payload(
def serialize_automation_jobs( def serialize_automation_jobs(
jobs: list[CronJob], jobs: list[AutomationJob],
*, *,
pending_job_ids: Collection[str] | None = None, pending_job_ids: Collection[str] | None = None,
include_details: bool = False, include_details: bool = False,
@ -90,12 +126,19 @@ def serialize_automation_jobs(
def _serialize_job( def _serialize_job(
job: CronJob, job: AutomationJob,
*, *,
pending: bool = False, pending: bool = False,
include_details: bool = False, include_details: bool = False,
session_manager: _SessionManagerLike | None = None, session_manager: _SessionManagerLike | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
if isinstance(job, ExternalTrigger):
return _serialize_trigger(
job,
include_details=include_details,
session_manager=session_manager,
)
payload = { payload = {
"id": job.id, "id": job.id,
"name": job.name, "name": job.name,
@ -143,6 +186,66 @@ def _serialize_job(
return payload return payload
def _serialize_trigger(
trigger: ExternalTrigger,
*,
include_details: bool = False,
session_manager: _SessionManagerLike | None = None,
) -> dict[str, Any]:
command = f'nanobot trigger {trigger.id} "message"'
payload = {
"id": trigger.id,
"name": trigger.name,
"enabled": trigger.enabled,
"kind": "external_trigger",
"schedule": {
"kind": "external",
"at_ms": None,
"every_ms": None,
"expr": None,
"tz": None,
},
"payload": {
"kind": "external_trigger",
"message": command,
"command": command,
},
"state": {
"next_run_at_ms": None,
"last_status": trigger.last_status,
"pending": False,
},
}
if not include_details:
return payload
payload["protected"] = False
payload["delete_after_run"] = False
payload["created_at_ms"] = trigger.created_at_ms
payload["updated_at_ms"] = trigger.updated_at_ms
payload["state"].update(
{
"last_run_at_ms": trigger.last_run_at_ms,
"last_error": trigger.last_error,
"run_history": [
{
"run_at_ms": record.run_at_ms,
"status": record.status,
"duration_ms": 0,
"error": record.error,
}
for record in trigger.run_history[-5:]
],
}
)
payload["origin"] = _trigger_origin_payload(trigger, session_manager)
payload["trigger"] = {
"id": trigger.id,
"command": command,
}
return payload
def _origin_payload( def _origin_payload(
job: CronJob, job: CronJob,
session_manager: _SessionManagerLike | None, session_manager: _SessionManagerLike | None,
@ -161,6 +264,46 @@ def _origin_payload(
} }
session_key = f"{channel}:{chat_id}" session_key = f"{channel}:{chat_id}"
return _websocket_origin_payload(
session_key=session_key,
channel=channel,
chat_id=chat_id,
session_manager=session_manager,
)
def _trigger_origin_payload(
trigger: ExternalTrigger,
session_manager: _SessionManagerLike | None,
) -> dict[str, Any] | None:
channel = trigger.channel
chat_id = trigger.chat_id
if not channel or not chat_id:
return None
if channel != "websocket":
return {
"channel": channel,
"title": "",
"preview": "",
}
return _websocket_origin_payload(
session_key=trigger.session_key or f"{channel}:{chat_id}",
channel=channel,
chat_id=chat_id,
session_manager=session_manager,
)
def _websocket_origin_payload(
*,
session_key: str,
channel: str,
chat_id: str,
session_manager: _SessionManagerLike | None,
) -> dict[str, Any]:
title = ""
preview = ""
if session_manager is not None: if session_manager is not None:
data = session_manager.read_session_file(session_key) data = session_manager.read_session_file(session_key)
if isinstance(data, dict): if isinstance(data, dict):

View File

@ -26,6 +26,7 @@ from websockets.http11 import Response
from nanobot.command.builtin import builtin_command_palette from nanobot.command.builtin import builtin_command_palette
from nanobot.cron.session_turns import is_bound_cron_job from nanobot.cron.session_turns import is_bound_cron_job
from nanobot.cron.types import CronJob, CronSchedule from nanobot.cron.types import CronJob, CronSchedule
from nanobot.triggers.types import ExternalTrigger
from nanobot.utils.subagent_channel_display import scrub_subagent_messages_for_channel from nanobot.utils.subagent_channel_display import scrub_subagent_messages_for_channel
from nanobot.webui.file_preview import WebUIFilePreviewError, file_preview_payload from nanobot.webui.file_preview import WebUIFilePreviewError, file_preview_payload
from nanobot.webui.gateway_tokens import GatewayTokenStore, token_response_payload from nanobot.webui.gateway_tokens import GatewayTokenStore, token_response_payload
@ -89,6 +90,7 @@ if TYPE_CHECKING:
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.cron.service import CronService from nanobot.cron.service import CronService
from nanobot.session.manager import SessionManager from nanobot.session.manager import SessionManager
from nanobot.triggers.store import ExternalTriggerStore
def _decode_api_key(raw_key: str) -> str | None: def _decode_api_key(raw_key: str) -> str | None:
@ -153,6 +155,7 @@ class GatewayHTTPHandler:
skills_workspace_path: Path, skills_workspace_path: Path,
disabled_skills: set[str] | None = None, disabled_skills: set[str] | None = None,
cron_service: CronService | None = None, cron_service: CronService | None = None,
external_trigger_store: ExternalTriggerStore | None = None,
cron_pending_job_ids: Callable[[str], set[str]] | None = None, cron_pending_job_ids: Callable[[str], set[str]] | None = None,
log: Any = logger, log: Any = logger,
) -> None: ) -> None:
@ -167,6 +170,7 @@ class GatewayHTTPHandler:
self.skills_workspace_path = skills_workspace_path self.skills_workspace_path = skills_workspace_path
self.disabled_skills = disabled_skills or set() self.disabled_skills = disabled_skills or set()
self.cron_service = cron_service self.cron_service = cron_service
self.external_trigger_store = external_trigger_store
self.cron_pending_job_ids = cron_pending_job_ids self.cron_pending_job_ids = cron_pending_job_ids
self._log = log self._log = log
self._runtime_surface = runtime_surface self._runtime_surface = runtime_surface
@ -490,6 +494,7 @@ class GatewayHTTPHandler:
session_automations_payload( session_automations_payload(
self.cron_service, self.cron_service,
decoded_key, decoded_key,
external_trigger_store=self.external_trigger_store,
pending_job_ids=pending_job_ids, pending_job_ids=pending_job_ids,
) )
) )
@ -506,7 +511,11 @@ class GatewayHTTPHandler:
return _http_error(404, "session not found") return _http_error(404, "session not found")
query = _parse_query(request.path) query = _parse_query(request.path)
delete_automations = (_query_first(query, "delete_automations") or "").lower() delete_automations = (_query_first(query, "delete_automations") or "").lower()
automation_jobs = session_automation_jobs(self.cron_service, decoded_key) automation_jobs = session_automation_jobs(
self.cron_service,
decoded_key,
external_trigger_store=self.external_trigger_store,
)
if automation_jobs and delete_automations not in {"1", "true", "yes"}: if automation_jobs and delete_automations not in {"1", "true", "yes"}:
return _http_json_response( return _http_json_response(
{ {
@ -515,9 +524,13 @@ class GatewayHTTPHandler:
"automations": serialize_automation_jobs(automation_jobs), "automations": serialize_automation_jobs(automation_jobs),
} }
) )
if automation_jobs and self.cron_service is not None: if automation_jobs:
for job in automation_jobs: for job in automation_jobs:
self.cron_service.remove_job(job.id) if isinstance(job, ExternalTrigger):
if self.external_trigger_store is not None:
self.external_trigger_store.delete(job.id)
elif self.cron_service is not None:
self.cron_service.remove_job(job.id)
deleted = self.session_manager.delete_session(decoded_key) deleted = self.session_manager.delete_session(decoded_key)
delete_webui_thread(decoded_key) delete_webui_thread(decoded_key)
return _http_json_response({"deleted": bool(deleted)}) return _http_json_response({"deleted": bool(deleted)})
@ -554,6 +567,7 @@ class GatewayHTTPHandler:
return _http_json_response( return _http_json_response(
all_automations_payload( all_automations_payload(
self.cron_service, self.cron_service,
external_trigger_store=self.external_trigger_store,
session_manager=self.session_manager, session_manager=self.session_manager,
pending_job_ids=self._pending_cron_job_ids_for_all(), pending_job_ids=self._pending_cron_job_ids_for_all(),
) )
@ -566,13 +580,19 @@ class GatewayHTTPHandler:
) -> Response: ) -> Response:
if not self.check_api_token(request): if not self.check_api_token(request):
return _http_error(401, "Unauthorized") return _http_error(401, "Unauthorized")
if self.cron_service is None: if self.cron_service is None and self.external_trigger_store is None:
return _http_error(503, "cron service unavailable") return _http_error(503, "automation service unavailable")
query = _parse_query(request.path) query = _parse_query(request.path)
job_id = (_query_first(query, "id") or _query_first(query, "job_id") or "").strip() job_id = (_query_first(query, "id") or _query_first(query, "job_id") or "").strip()
if not job_id: if not job_id:
return _http_error(400, "missing automation id") return _http_error(400, "missing automation id")
trigger = self.external_trigger_store.get(job_id) if self.external_trigger_store else None
if trigger is not None:
return self._handle_external_trigger_action(request, action, trigger)
if self.cron_service is None:
return _http_error(404, "automation not found")
job = self.cron_service.get_job(job_id) job = self.cron_service.get_job(job_id)
if job is None: if job is None:
return _http_error(404, "automation not found") return _http_error(404, "automation not found")
@ -618,6 +638,40 @@ class GatewayHTTPHandler:
return self._handle_webui_automations(request) return self._handle_webui_automations(request)
def _handle_external_trigger_action(
self,
request: WsRequest,
action: str,
trigger: ExternalTrigger,
) -> Response:
if self.external_trigger_store is None:
return _http_error(503, "trigger service unavailable")
if action == "enable":
if self.external_trigger_store.enable(trigger.id, enabled=True) is None:
return _http_error(404, "automation not found")
elif action == "disable":
if self.external_trigger_store.enable(trigger.id, enabled=False) is None:
return _http_error(404, "automation not found")
elif action == "delete":
if not self.external_trigger_store.delete(trigger.id):
return _http_error(404, "automation not found")
elif action == "run":
return _http_error(409, "external trigger requires a CLI message")
elif action == "update":
values = _automation_values_from_request(request)
if values is None:
return _http_error(400, "invalid automation update payload")
parsed = _parse_external_trigger_update(values)
if isinstance(parsed, str):
return _http_error(400, parsed)
if parsed:
if self.external_trigger_store.update(trigger.id, **parsed) is None:
return _http_error(404, "automation not found")
else:
return _http_error(404, "unknown automation action")
return self._handle_webui_automations(request)
@staticmethod @staticmethod
def _log_automation_run_result(task: asyncio.Task[bool]) -> None: def _log_automation_run_result(task: asyncio.Task[bool]) -> None:
try: try:
@ -830,6 +884,22 @@ def _parse_automation_update(
return update return update
def _parse_external_trigger_update(values: dict[str, Any]) -> dict[str, Any] | str:
update: dict[str, Any] = {}
if "name" in values:
raw_name = values.get("name")
if not isinstance(raw_name, str):
return "name must be a string"
name = raw_name.strip()
if not name:
return "name cannot be empty"
update["name"] = name
forbidden = [key for key in ("message", "schedule") if key in values]
if forbidden:
return "external trigger updates only support name"
return update
def _parse_automation_schedule(values: dict[str, Any]) -> CronSchedule | str: def _parse_automation_schedule(values: dict[str, Any]) -> CronSchedule | str:
raw_kind = values.get("kind") raw_kind = values.get("kind")
if not isinstance(raw_kind, str): if not isinstance(raw_kind, str):

View File

@ -867,7 +867,7 @@ async def test_slash_new_is_blocked_for_disallowed_user() -> None:
assert handled == [] 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 @pytest.mark.asyncio
async def test_slash_commands_forward_via_handle_message(slash_name: str) -> None: async def test_slash_commands_forward_via_handle_message(slash_name: str) -> None:
channel = DiscordChannel(DiscordConfig(enabled=True, allow_from=["*"]), MessageBus()) 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 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 @pytest.mark.asyncio
async def test_slash_help_returns_ephemeral_help_text() -> None: async def test_slash_help_returns_ephemeral_help_text() -> None:
channel = DiscordChannel(DiscordConfig(enabled=True, allow_from=["*"]), MessageBus()) channel = DiscordChannel(DiscordConfig(enabled=True, allow_from=["*"]), MessageBus())

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")
assert pat.fullmatch("/history 5") assert pat.fullmatch("/history 5")
assert pat.fullmatch("/goal ship the feature") assert pat.fullmatch("/goal ship the feature")
assert pat.fullmatch("/trigger PR review")
assert pat.fullmatch("/pairing list") assert pat.fullmatch("/pairing list")
assert pat.fullmatch("/model fast") assert pat.fullmatch("/model fast")
assert pat.fullmatch("/skill") assert pat.fullmatch("/skill")
assert pat.fullmatch("/skill@nanobot_bot") assert pat.fullmatch("/skill@nanobot_bot")
assert pat.fullmatch("/new@nanobot_bot") assert pat.fullmatch("/new@nanobot_bot")
assert pat.fullmatch("/goal@nanobot_bot refine objective") 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-log deadbeef") is None
assert pat.fullmatch("/dream-restore 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" in help_text
assert "/dream-log" in help_text assert "/dream-log" in help_text
assert "/goal" in help_text assert "/goal" in help_text
assert "/trigger" in help_text
assert "/pairing" in help_text assert "/pairing" in help_text
assert "/model" in help_text assert "/model" in help_text
assert "/dream-restore" in help_text assert "/dream-restore" in help_text

View File

@ -20,6 +20,7 @@ from nanobot.cron.service import CronService
from nanobot.cron.types import CronJob, CronPayload, CronSchedule from nanobot.cron.types import CronJob, CronPayload, CronSchedule
from nanobot.session.keys import UNIFIED_SESSION_KEY from nanobot.session.keys import UNIFIED_SESSION_KEY
from nanobot.session.manager import Session, SessionManager from nanobot.session.manager import Session, SessionManager
from nanobot.triggers.store import ExternalTriggerStore
from nanobot.webui.gateway_services import GatewayServices, build_gateway_services from nanobot.webui.gateway_services import GatewayServices, build_gateway_services
_PORT = 29900 _PORT = 29900
@ -46,6 +47,7 @@ def _make_handler(
workspace_path: Path | None = None, workspace_path: Path | None = None,
runtime_model_name: Any | None = None, runtime_model_name: Any | None = None,
cron_service: CronService | None = None, cron_service: CronService | None = None,
external_trigger_store: ExternalTriggerStore | None = None,
cron_pending_job_ids: Any | None = None, cron_pending_job_ids: Any | None = None,
) -> GatewayServices: ) -> GatewayServices:
config = WebSocketConfig.model_validate(cfg) if isinstance(cfg, dict) else cfg config = WebSocketConfig.model_validate(cfg) if isinstance(cfg, dict) else cfg
@ -61,6 +63,7 @@ def _make_handler(
runtime_surface="browser", runtime_surface="browser",
runtime_capabilities_overrides=None, runtime_capabilities_overrides=None,
cron_service=cron_service, cron_service=cron_service,
external_trigger_store=external_trigger_store,
cron_pending_job_ids=cron_pending_job_ids, cron_pending_job_ids=cron_pending_job_ids,
) )
@ -74,6 +77,7 @@ def _ch(
port: int = _PORT, port: int = _PORT,
runtime_model_name: Any | None = None, runtime_model_name: Any | None = None,
cron_service: CronService | None = None, cron_service: CronService | None = None,
external_trigger_store: ExternalTriggerStore | None = None,
cron_pending_job_ids: Any | None = None, cron_pending_job_ids: Any | None = None,
**extra: Any, **extra: Any,
) -> WebSocketChannel: ) -> WebSocketChannel:
@ -93,6 +97,7 @@ def _ch(
workspace_path=workspace_path, workspace_path=workspace_path,
runtime_model_name=runtime_model_name, runtime_model_name=runtime_model_name,
cron_service=cron_service, cron_service=cron_service,
external_trigger_store=external_trigger_store,
cron_pending_job_ids=cron_pending_job_ids, cron_pending_job_ids=cron_pending_job_ids,
) )
return WebSocketChannel(cfg, bus, gateway=gateway) return WebSocketChannel(cfg, bus, gateway=gateway)
@ -319,6 +324,50 @@ async def test_session_automations_route_ignores_unified_owner(
await server_task 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 @pytest.mark.asyncio
async def test_webui_skills_route_requires_token_and_hides_paths( async def test_webui_skills_route_requires_token_and_hides_paths(
bus: MagicMock, tmp_path: Path 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 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 @pytest.mark.asyncio
async def test_session_delete_blocks_when_bound_automation_exists( async def test_session_delete_blocks_when_bound_automation_exists(
bus: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch 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 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 @pytest.mark.asyncio
async def test_session_delete_can_cascade_bound_automations( async def test_session_delete_can_cascade_bound_automations(
bus: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch bus: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch

View File

@ -2516,6 +2516,38 @@ def test_serve_uses_api_config_defaults_and_workspace_override(
assert seen["api_key"] == "" assert seen["api_key"] == ""
def test_trigger_cli_queues_message_in_workspace(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
from nanobot.triggers.store import ExternalTriggerStore
config_file = _write_instance_config(tmp_path)
config = Config()
config.agents.defaults.workspace = str(tmp_path / "workspace")
_patch_cli_command_runtime(monkeypatch, config)
store = ExternalTriggerStore(config.workspace_path)
trigger = store.create(
name="Review hook",
channel="websocket",
chat_id="chat-1",
session_key="websocket:chat-1",
)
result = runner.invoke(
app,
["trigger", "--config", str(config_file), trigger.id, "Review PR #4502"],
)
assert result.exit_code == 0
assert f"Queued {trigger.id}" in result.stdout
deliveries = store.claim_deliveries()
assert len(deliveries) == 1
assert deliveries[0].trigger_id == trigger.id
assert deliveries[0].content == "Review PR #4502"
def test_serve_cli_options_override_api_config(monkeypatch, tmp_path: Path) -> None: def test_serve_cli_options_override_api_config(monkeypatch, tmp_path: Path) -> None:
config_file = _write_instance_config(tmp_path) config_file = _write_instance_config(tmp_path)
config = Config() config = Config()

View File

@ -0,0 +1,49 @@
from __future__ import annotations
from pathlib import Path
from types import SimpleNamespace
import pytest
from nanobot.bus.events import InboundMessage
from nanobot.command.builtin import build_help_text, register_builtin_commands
from nanobot.command.router import CommandContext, CommandRouter
from nanobot.triggers.store import ExternalTriggerStore
@pytest.mark.asyncio
async def test_trigger_command_creates_session_bound_local_trigger(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 PR review",
metadata={"webui": True},
)
ctx = CommandContext(
msg=msg,
session=None,
key="websocket:chat-1",
raw="/trigger@nanobot_bot PR review",
loop=loop,
)
assert router.is_dispatchable_command("/trigger@nanobot_bot PR review") is True
response = await router.dispatch(ctx)
assert response is not None
assert "Trigger created: PR review" in response.content
trigger = store.list_for_session("websocket:chat-1")[0]
assert trigger.name == "PR review"
assert trigger.channel == "websocket"
assert trigger.chat_id == "chat-1"
assert trigger.session_key == "websocket:chat-1"
assert f"nanobot trigger {trigger.id} \"message\"" in response.content
def test_trigger_command_is_in_help_text() -> None:
assert "/trigger [name]" in build_help_text()

View File

@ -0,0 +1,101 @@
from __future__ import annotations
import asyncio
from contextlib import suppress
from pathlib import Path
import pytest
from nanobot.bus.events import InboundMessage
from nanobot.triggers.runner import run_external_trigger_queue
from nanobot.triggers.store import ExternalTriggerStore, TriggerDisabledError
from nanobot.webui.metadata import WEBUI_MESSAGE_SOURCE_METADATA_KEY, WEBUI_TURN_METADATA_KEY
def test_trigger_store_allows_multiple_triggers_per_session(tmp_path: Path) -> None:
store = ExternalTriggerStore(tmp_path)
first = store.create(
name="PR review",
channel="websocket",
chat_id="chat-1",
session_key="websocket:chat-1",
)
second = store.create(
name="CI summary",
channel="websocket",
chat_id="chat-1",
session_key="websocket:chat-1",
)
triggers = store.list_for_session("websocket:chat-1")
assert {trigger.id for trigger in triggers} == {first.id, second.id}
assert first.id.startswith("trg_")
assert second.id.startswith("trg_")
assert first.id != second.id
def test_enqueue_rejects_disabled_trigger(tmp_path: Path) -> None:
store = ExternalTriggerStore(tmp_path)
trigger = store.create(
name="Disabled",
channel="telegram",
chat_id="123",
session_key="telegram:123",
)
store.enable(trigger.id, enabled=False)
with pytest.raises(TriggerDisabledError):
store.enqueue(trigger.id, "Review PR #4502")
@pytest.mark.asyncio
async def test_external_trigger_queue_publishes_bound_inbound_message(tmp_path: Path) -> None:
store = ExternalTriggerStore(tmp_path)
trigger = store.create(
name="PR review",
channel="websocket",
chat_id="chat-1",
session_key="websocket:chat-1",
origin_metadata={"webui": True, WEBUI_TURN_METADATA_KEY: "old-turn"},
)
store.enqueue(trigger.id, "Review PR #4502")
published: list[InboundMessage] = []
class _Bus:
async def publish_inbound(self, msg: InboundMessage) -> None:
published.append(msg)
task = asyncio.create_task(
run_external_trigger_queue(store=store, bus=_Bus(), poll_interval_s=0.01)
)
try:
for _ in range(100):
if published:
break
await asyncio.sleep(0.01)
finally:
task.cancel()
with suppress(asyncio.CancelledError):
await task
assert len(published) == 1
msg = published[0]
assert msg.channel == "websocket"
assert msg.chat_id == "chat-1"
assert msg.sender_id == "trigger"
assert msg.content == "Review PR #4502"
assert msg.session_key_override == "websocket:chat-1"
assert msg.metadata[WEBUI_TURN_METADATA_KEY].startswith(f"trigger:{trigger.id}:")
assert msg.metadata[WEBUI_TURN_METADATA_KEY] != "old-turn"
assert msg.metadata[WEBUI_MESSAGE_SOURCE_METADATA_KEY] == {
"kind": "trigger",
"label": "PR review",
}
assert msg.metadata["_external_trigger"]["trigger_id"] == trigger.id
stored = store.get(trigger.id)
assert stored is not None
assert stored.last_status == "ok"
assert stored.last_run_at_ms is not None
assert store.claim_deliveries() == []

View File

@ -122,6 +122,9 @@ function formatAutomationSchedule(
}) })
: t("deleteConfirm.schedule.cron", { expr: job.schedule.expr }); : t("deleteConfirm.schedule.cron", { expr: job.schedule.expr });
} }
if (job.schedule.kind === "external" || job.payload.kind === "external_trigger") {
return t("deleteConfirm.schedule.external", { defaultValue: "External trigger" });
}
return t("deleteConfirm.schedule.unknown"); return t("deleteConfirm.schedule.unknown");
} }
@ -131,6 +134,9 @@ function formatAutomationNextRun(
locale: string, locale: string,
): string { ): string {
if (!job.enabled) return t("deleteConfirm.next.disabled"); if (!job.enabled) return t("deleteConfirm.next.disabled");
if (job.schedule.kind === "external" || job.payload.kind === "external_trigger") {
return t("deleteConfirm.next.external", { defaultValue: "Waiting for trigger" });
}
const next = job.state.next_run_at_ms; const next = job.state.next_run_at_ms;
if (!next) return t("deleteConfirm.next.none"); if (!next) return t("deleteConfirm.next.none");
return t("deleteConfirm.next.label", { time: fmtDateTime(next, locale) }); return t("deleteConfirm.next.label", { time: fmtDateTime(next, locale) });

View File

@ -167,7 +167,7 @@ export function MessageBubble({
const reasoning = message.role === "assistant" ? message.reasoning ?? "" : ""; const reasoning = message.role === "assistant" ? message.reasoning ?? "" : "";
const reasoningStreaming = !!(message.role === "assistant" && message.reasoningStreaming); const reasoningStreaming = !!(message.role === "assistant" && message.reasoningStreaming);
const hasReasoning = reasoning.length > 0 || reasoningStreaming; const hasReasoning = reasoning.length > 0 || reasoningStreaming;
const automationSourceLabel = message.source?.kind === "cron" const automationSourceLabel = message.source?.kind === "cron" || message.source?.kind === "trigger"
? (message.source.label?.trim() || t("message.automationSourceFallback")) ? (message.source.label?.trim() || t("message.automationSourceFallback"))
: ""; : "";
const automationTriggeredLabel = t("message.automationTriggered"); const automationTriggeredLabel = t("message.automationTriggered");

View File

@ -21,6 +21,7 @@ import {
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
Cloud, Cloud,
Clipboard,
Cpu, Cpu,
Database, Database,
Eye, Eye,
@ -106,6 +107,7 @@ import {
updateWebSearchSettings, updateWebSearchSettings,
} from "@/lib/api"; } from "@/lib/api";
import { notifyCliAppsChanged } from "@/lib/cli-app-events"; import { notifyCliAppsChanged } from "@/lib/cli-app-events";
import { copyTextToClipboard } from "@/lib/clipboard";
import { getHostApi } from "@/lib/runtime"; import { getHostApi } from "@/lib/runtime";
import { notifyMcpPresetsChanged } from "@/lib/mcp-preset-events"; import { notifyMcpPresetsChanged } from "@/lib/mcp-preset-events";
import { fmtDateTime, relativeTime } from "@/lib/format"; import { fmtDateTime, relativeTime } from "@/lib/format";
@ -3671,6 +3673,7 @@ function AutomationListItem({
const status = automationStatus(job, tx); const status = automationStatus(job, tx);
const origin = automationOriginLabel(job, tx); const origin = automationOriginLabel(job, tx);
const nextRun = formatAutomationNext(job, tx); const nextRun = formatAutomationNext(job, tx);
const summary = automationSummary(job, tx);
return ( return (
<div role="listitem"> <div role="listitem">
@ -3696,7 +3699,7 @@ function AutomationListItem({
</span> </span>
</span> </span>
<span className="mt-1.5 line-clamp-2 text-[12px] leading-5 text-muted-foreground"> <span className="mt-1.5 line-clamp-2 text-[12px] leading-5 text-muted-foreground">
{job.payload.message || tx("settings.automations.systemTask", "System-managed automation")} {summary}
</span> </span>
<span className="mt-2.5 flex min-w-0 items-center gap-2 text-[11.5px] leading-none text-muted-foreground"> <span className="mt-2.5 flex min-w-0 items-center gap-2 text-[11.5px] leading-none text-muted-foreground">
<span className="truncate" title={formatAutomationNextTitle(job, locale, tx)}> <span className="truncate" title={formatAutomationNextTitle(job, locale, tx)}>
@ -3753,13 +3756,20 @@ function AutomationDetailPanel({
: null; : null;
const created = job.created_at_ms ? fmtDateTime(job.created_at_ms, locale) : null; const created = job.created_at_ms ? fmtDateTime(job.created_at_ms, locale) : null;
const updated = job.updated_at_ms ? fmtDateTime(job.updated_at_ms, locale) : null; const updated = job.updated_at_ms ? fmtDateTime(job.updated_at_ms, locale) : null;
const message = job.payload.message || tx("settings.automations.systemTask", "System-managed automation"); const externalTrigger = isExternalTriggerAutomation(job);
const triggerCommand = automationTriggerCommand(job);
const message = automationDetailText(job, tx);
const messageLabel = externalTrigger
? tx("settings.automations.fields.command", "Command")
: tx("settings.automations.fields.message", "Message");
const schedule = formatAutomationSchedule(job, locale, tx); const schedule = formatAutomationSchedule(job, locale, tx);
const [messageExpanded, setMessageExpanded] = useState(false); const [messageExpanded, setMessageExpanded] = useState(false);
const [commandCopied, setCommandCopied] = useState(false);
const messageNeedsExpansion = automationMessageNeedsExpansion(message); const messageNeedsExpansion = automationMessageNeedsExpansion(message);
useEffect(() => { useEffect(() => {
setMessageExpanded(false); setMessageExpanded(false);
setCommandCopied(false);
}, [job.id]); }, [job.id]);
return ( return (
@ -3793,12 +3803,37 @@ function AutomationDetailPanel({
<div className="grid min-h-0 min-w-0 flex-1 overflow-hidden lg:grid-cols-[minmax(0,1fr)_14.5rem]"> <div className="grid min-h-0 min-w-0 flex-1 overflow-hidden lg:grid-cols-[minmax(0,1fr)_14.5rem]">
<div className="min-h-0 min-w-0 space-y-3 overflow-y-auto overscroll-contain p-4 sm:p-5"> <div className="min-h-0 min-w-0 space-y-3 overflow-y-auto overscroll-contain p-4 sm:p-5">
<section className="rounded-[20px] border border-border/35 bg-background/62 px-4 py-3.5 shadow-[inset_0_1px_0_rgba(255,255,255,0.58)] dark:border-white/10 dark:bg-background/24"> <section className="rounded-[20px] border border-border/35 bg-background/62 px-4 py-3.5 shadow-[inset_0_1px_0_rgba(255,255,255,0.58)] dark:border-white/10 dark:bg-background/24">
<div className="text-[11px] font-medium leading-none text-muted-foreground/75"> <div className="flex items-center justify-between gap-3">
{tx("settings.automations.fields.message", "Message")} <div className="text-[11px] font-medium leading-none text-muted-foreground/75">
{messageLabel}
</div>
{externalTrigger && triggerCommand ? (
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 shrink-0 rounded-full px-2 text-[11.5px]"
onClick={() => {
void copyTextToClipboard(triggerCommand).then((ok) => {
if (ok) setCommandCopied(true);
});
}}
>
{commandCopied ? (
<Check className="mr-1.5 h-3.5 w-3.5" aria-hidden />
) : (
<Clipboard className="mr-1.5 h-3.5 w-3.5" aria-hidden />
)}
{commandCopied
? tx("settings.automations.commandCopied", "Copied")
: tx("settings.automations.copyCommand", "Copy")}
</Button>
) : null}
</div> </div>
<div <div
className={cn( className={cn(
"mt-3 whitespace-pre-wrap break-words text-[13px] leading-6 text-foreground/85", "mt-3 whitespace-pre-wrap break-words text-[13px] leading-6 text-foreground/85",
externalTrigger && "font-mono text-[12.5px]",
!messageExpanded && messageNeedsExpansion && "line-clamp-6", !messageExpanded && messageNeedsExpansion && "line-clamp-6",
)} )}
> >
@ -3905,7 +3940,8 @@ function AutomationActionGroup({
t(key, { defaultValue: fallback, ...(values ?? {}) }); t(key, { defaultValue: fallback, ...(values ?? {}) });
const canManage = !job.protected; const canManage = !job.protected;
const hasLinkedChat = Boolean(job.origin); const hasLinkedChat = Boolean(job.origin);
const canRun = canManage && hasLinkedChat && job.enabled && !job.state.pending; const externalTrigger = isExternalTriggerAutomation(job);
const canRun = canManage && hasLinkedChat && job.enabled && !job.state.pending && !externalTrigger;
const toggleAction: AutomationAction = job.enabled ? "disable" : "enable"; const toggleAction: AutomationAction = job.enabled ? "disable" : "enable";
const canToggle = canManage && (job.enabled || hasLinkedChat); const canToggle = canManage && (job.enabled || hasLinkedChat);
const toggleBusy = actionKey === `${toggleAction}:${job.id}`; const toggleBusy = actionKey === `${toggleAction}:${job.id}`;
@ -3927,14 +3963,16 @@ function AutomationActionGroup({
> >
<Pencil className="h-4 w-4" aria-hidden /> <Pencil className="h-4 w-4" aria-hidden />
</AppsActionButton> </AppsActionButton>
<AppsActionButton {!externalTrigger ? (
ariaLabel={tx("settings.automations.runNow", "Run now")} <AppsActionButton
busy={actionKey === `run:${job.id}`} ariaLabel={tx("settings.automations.runNow", "Run now")}
disabled={!canRun} busy={actionKey === `run:${job.id}`}
onClick={() => void onAction("run", job)} disabled={!canRun}
> onClick={() => void onAction("run", job)}
<PlayCircle className="h-4 w-4" aria-hidden /> >
</AppsActionButton> <PlayCircle className="h-4 w-4" aria-hidden />
</AppsActionButton>
) : null}
<AppsActionButton <AppsActionButton
ariaLabel={ ariaLabel={
job.enabled job.enabled
@ -4057,6 +4095,7 @@ function AutomationEditDialog({
const tx = (key: string, fallback: string, values?: Record<string, unknown>) => const tx = (key: string, fallback: string, values?: Record<string, unknown>) =>
t(key, { defaultValue: fallback, ...(values ?? {}) }); t(key, { defaultValue: fallback, ...(values ?? {}) });
const [draft, setDraft] = useState<AutomationEditDraft>(() => automationDraftFromJob(null)); const [draft, setDraft] = useState<AutomationEditDraft>(() => automationDraftFromJob(null));
const externalTrigger = isExternalTriggerAutomation(job);
useEffect(() => { useEffect(() => {
setDraft(automationDraftFromJob(job)); setDraft(automationDraftFromJob(job));
@ -4106,34 +4145,38 @@ function AutomationEditDialog({
/> />
</label> </label>
<label className="block space-y-1.5"> {!externalTrigger ? (
<span className="text-[12px] font-medium text-muted-foreground"> <label className="block space-y-1.5">
{tx("settings.automations.fields.message", "Message")} <span className="text-[12px] font-medium text-muted-foreground">
</span> {tx("settings.automations.fields.message", "Message")}
<Textarea </span>
value={draft.message} <Textarea
onChange={(event) => setDraft((prev) => ({ ...prev, message: event.target.value }))} value={draft.message}
className="min-h-[160px] resize-none rounded-[12px] text-[13px] leading-5" onChange={(event) => setDraft((prev) => ({ ...prev, message: event.target.value }))}
/> className="min-h-[160px] resize-none rounded-[12px] text-[13px] leading-5"
</label> />
</label>
) : null}
<div className="space-y-2"> {!externalTrigger ? (
<span className="text-[12px] font-medium text-muted-foreground"> <div className="space-y-2">
{tx("settings.automations.fields.scheduleType", "Schedule type")} <span className="text-[12px] font-medium text-muted-foreground">
</span> {tx("settings.automations.fields.scheduleType", "Schedule type")}
<SegmentedControl </span>
value={draft.scheduleKind} <SegmentedControl
options={scheduleOptions} value={draft.scheduleKind}
onChange={(value) => options={scheduleOptions}
setDraft((prev) => ({ onChange={(value) =>
...prev, setDraft((prev) => ({
scheduleKind: value as AutomationEditDraft["scheduleKind"], ...prev,
})) scheduleKind: value as AutomationEditDraft["scheduleKind"],
} }))
/> }
</div> />
</div>
) : null}
{draft.scheduleKind === "every" ? ( {!externalTrigger && draft.scheduleKind === "every" ? (
<div className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_10rem]"> <div className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_10rem]">
<label className="block space-y-1.5"> <label className="block space-y-1.5">
<span className="text-[12px] font-medium text-muted-foreground"> <span className="text-[12px] font-medium text-muted-foreground">
@ -4174,7 +4217,7 @@ function AutomationEditDialog({
</div> </div>
) : null} ) : null}
{draft.scheduleKind === "cron" ? ( {!externalTrigger && draft.scheduleKind === "cron" ? (
<div className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_12rem]"> <div className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_12rem]">
<label className="block space-y-1.5"> <label className="block space-y-1.5">
<span className="text-[12px] font-medium text-muted-foreground"> <span className="text-[12px] font-medium text-muted-foreground">
@ -4201,7 +4244,7 @@ function AutomationEditDialog({
</div> </div>
) : null} ) : null}
{draft.scheduleKind === "at" ? ( {!externalTrigger && draft.scheduleKind === "at" ? (
<label className="block space-y-1.5"> <label className="block space-y-1.5">
<span className="text-[12px] font-medium text-muted-foreground"> <span className="text-[12px] font-medium text-muted-foreground">
{tx("settings.automations.fields.runAt", "Run at")} {tx("settings.automations.fields.runAt", "Run at")}
@ -4266,7 +4309,7 @@ function AutomationDeleteDialog({
<DialogDescription> <DialogDescription>
{tx( {tx(
"settings.automations.deleteDescription", "settings.automations.deleteDescription",
"This removes {{name}} from the cron store. Past chat messages stay in the session.", "This removes {{name}} from automations. Past chat messages stay in the session.",
{ name: job?.name || job?.id || "" }, { name: job?.name || job?.id || "" },
)} )}
</DialogDescription> </DialogDescription>
@ -4297,6 +4340,34 @@ function AutomationDeleteDialog({
); );
} }
function isExternalTriggerAutomation(job: SessionAutomationJob | null): boolean {
if (!job) return false;
return job.kind === "external_trigger"
|| job.payload.kind === "external_trigger"
|| job.schedule.kind === "external";
}
function automationTriggerCommand(job: SessionAutomationJob): string {
return job.trigger?.command || job.payload.command || job.payload.message || "";
}
function automationSummary(
job: SessionAutomationJob,
tx: (key: string, fallback: string, values?: Record<string, unknown>) => string,
): string {
if (isExternalTriggerAutomation(job)) {
return automationTriggerCommand(job) || tx("settings.automations.externalTrigger", "External trigger");
}
return job.payload.message || tx("settings.automations.systemTask", "System-managed automation");
}
function automationDetailText(
job: SessionAutomationJob,
tx: (key: string, fallback: string, values?: Record<string, unknown>) => string,
): string {
return automationSummary(job, tx);
}
function automationNeedsAttention(job: SessionAutomationJob): boolean { function automationNeedsAttention(job: SessionAutomationJob): boolean {
return job.state.last_status === "error"; return job.state.last_status === "error";
} }
@ -4308,6 +4379,7 @@ function automationStatusKey(
if (job.state.pending) return "running"; if (job.state.pending) return "running";
if (!job.enabled) return "paused"; if (!job.enabled) return "paused";
if (job.state.last_status === "error") return "failed"; if (job.state.last_status === "error") return "failed";
if (isExternalTriggerAutomation(job)) return "active";
if (job.delete_after_run && !job.state.next_run_at_ms && job.state.last_status === "ok") { if (job.delete_after_run && !job.state.next_run_at_ms && job.state.last_status === "ok") {
return "completed"; return "completed";
} }
@ -4371,6 +4443,7 @@ function automationEditDraftError(
tx: (key: string, fallback: string, values?: Record<string, unknown>) => string, tx: (key: string, fallback: string, values?: Record<string, unknown>) => string,
): string | null { ): string | null {
if (!draft.name.trim()) return tx("settings.automations.validation.nameRequired", "Name is required."); if (!draft.name.trim()) return tx("settings.automations.validation.nameRequired", "Name is required.");
if (isExternalTriggerAutomation(job)) return null;
if (!draft.message.trim()) { if (!draft.message.trim()) {
return tx("settings.automations.validation.messageRequired", "Message is required."); return tx("settings.automations.validation.messageRequired", "Message is required.");
} }
@ -4400,6 +4473,10 @@ function automationUpdatePayloadFromDraft(
job: SessionAutomationJob | null, job: SessionAutomationJob | null,
): AutomationUpdatePayload | string { ): AutomationUpdatePayload | string {
const name = draft.name.trim(); const name = draft.name.trim();
if (isExternalTriggerAutomation(job)) {
if (!name) return "invalid";
return { name };
}
const message = draft.message.trim(); const message = draft.message.trim();
if (!name || !message) return "invalid"; if (!name || !message) return "invalid";
const payload: AutomationUpdatePayload = { name, message }; const payload: AutomationUpdatePayload = { name, message };
@ -4517,7 +4594,7 @@ function automationSearchParts(
const scheduleParts = automationScheduleSearchParts(job); const scheduleParts = automationScheduleSearchParts(job);
if (field === "id") return [job.id]; if (field === "id") return [job.id];
if (field === "name") return [job.name, job.id]; if (field === "name") return [job.name, job.id];
if (field === "message") return [job.payload.message]; if (field === "message") return [job.payload.message, job.payload.command, job.trigger?.command];
if (field === "chat") return originParts; if (field === "chat") return originParts;
if (field === "cron" || field === "schedule") return scheduleParts; if (field === "cron" || field === "schedule") return scheduleParts;
if (field === "status") return [automationStatusKey(job), job.enabled ? "enabled" : "disabled"]; if (field === "status") return [automationStatusKey(job), job.enabled ? "enabled" : "disabled"];
@ -4525,6 +4602,9 @@ function automationSearchParts(
job.id, job.id,
job.name, job.name,
job.payload.message, job.payload.message,
job.payload.command,
job.trigger?.command,
isExternalTriggerAutomation(job) ? "trigger external" : null,
...scheduleParts, ...scheduleParts,
automationStatusKey(job), automationStatusKey(job),
...originParts, ...originParts,
@ -4714,6 +4794,9 @@ function formatAutomationSchedule(
}) })
: tx("settings.automations.schedule.cron", "Cron {{expr}}", { expr: job.schedule.expr }); : tx("settings.automations.schedule.cron", "Cron {{expr}}", { expr: job.schedule.expr });
} }
if (isExternalTriggerAutomation(job)) {
return tx("settings.automations.schedule.external", "External trigger");
}
return tx("settings.automations.schedule.custom", "Custom schedule"); return tx("settings.automations.schedule.custom", "Custom schedule");
} }
@ -4768,6 +4851,9 @@ function formatAutomationNext(
): string { ): string {
if (!job.enabled) return tx("settings.automations.next.paused", "Paused"); if (!job.enabled) return tx("settings.automations.next.paused", "Paused");
if (job.state.pending) return tx("settings.automations.next.pending", "Running now"); if (job.state.pending) return tx("settings.automations.next.pending", "Running now");
if (isExternalTriggerAutomation(job)) {
return tx("settings.automations.next.external", "Waiting for trigger");
}
if (!job.state.next_run_at_ms) return tx("settings.automations.next.none", "No next run"); if (!job.state.next_run_at_ms) return tx("settings.automations.next.none", "No next run");
return relativeTime(job.state.next_run_at_ms); return relativeTime(job.state.next_run_at_ms);
} }

View File

@ -918,6 +918,10 @@
"title": "Long-running goal", "title": "Long-running goal",
"description": "Tell the agent to treat this as a sustained multi-step goal." "description": "Tell the agent to treat this as a sustained multi-step goal."
}, },
"trigger": {
"title": "Create local trigger",
"description": "Create a CLI trigger bound to this chat session."
},
"help": { "help": {
"title": "Show help", "title": "Show help",
"description": "List available slash commands." "description": "List available slash commands."

View File

@ -908,6 +908,10 @@
"title": "Objetivo a largo plazo", "title": "Objetivo a largo plazo",
"description": "Indica al agente que trate esto como un objetivo sostenido en varios pasos." "description": "Indica al agente que trate esto como un objetivo sostenido en varios pasos."
}, },
"trigger": {
"title": "Crear trigger local",
"description": "Crea un trigger de CLI vinculado a esta sesion de chat."
},
"help": { "help": {
"title": "Mostrar ayuda", "title": "Mostrar ayuda",
"description": "Lista los comandos slash disponibles." "description": "Lista los comandos slash disponibles."

View File

@ -908,6 +908,10 @@
"title": "Objectif long terme", "title": "Objectif long terme",
"description": "Demandez à lagent de traiter ceci comme un objectif multiétapes durable." "description": "Demandez à lagent de traiter ceci comme un objectif multiétapes durable."
}, },
"trigger": {
"title": "Créer un trigger local",
"description": "Crée un trigger CLI lié à cette session de chat."
},
"help": { "help": {
"title": "Afficher laide", "title": "Afficher laide",
"description": "Lister les commandes slash disponibles." "description": "Lister les commandes slash disponibles."

View File

@ -908,6 +908,10 @@
"title": "Tujuan jangka panjang", "title": "Tujuan jangka panjang",
"description": "Instruksikan agen memperlakukan ini sebagai tujuan multi-langkah yang berkelanjutan." "description": "Instruksikan agen memperlakukan ini sebagai tujuan multi-langkah yang berkelanjutan."
}, },
"trigger": {
"title": "Buat trigger lokal",
"description": "Buat trigger CLI yang terikat ke sesi chat ini."
},
"help": { "help": {
"title": "Tampilkan bantuan", "title": "Tampilkan bantuan",
"description": "Daftar perintah slash yang tersedia." "description": "Daftar perintah slash yang tersedia."

View File

@ -908,6 +908,10 @@
"title": "長期目標", "title": "長期目標",
"description": "持続的な複数ステップの目標として扱うようエージェントに伝えます。" "description": "持続的な複数ステップの目標として扱うようエージェントに伝えます。"
}, },
"trigger": {
"title": "ローカルトリガーを作成",
"description": "このチャットセッションに紐づく CLI トリガーを作成します。"
},
"help": { "help": {
"title": "ヘルプを表示", "title": "ヘルプを表示",
"description": "利用可能なスラッシュコマンドを一覧表示します。" "description": "利用可能なスラッシュコマンドを一覧表示します。"

View File

@ -908,6 +908,10 @@
"title": "장기 목표", "title": "장기 목표",
"description": "에이전트에게 지속적인 다단계 목표로 처리하도록 지시합니다." "description": "에이전트에게 지속적인 다단계 목표로 처리하도록 지시합니다."
}, },
"trigger": {
"title": "로컬 트리거 만들기",
"description": "이 채팅 세션에 연결된 CLI 트리거를 만듭니다."
},
"help": { "help": {
"title": "도움말 보기", "title": "도움말 보기",
"description": "사용 가능한 슬래시 명령을 나열합니다." "description": "사용 가능한 슬래시 명령을 나열합니다."

View File

@ -908,6 +908,10 @@
"title": "Mục tiêu dài hạn", "title": "Mục tiêu dài hạn",
"description": "Yêu cầu agent xử lý đây là mục tiêu nhiều bước kéo dài." "description": "Yêu cầu agent xử lý đây là mục tiêu nhiều bước kéo dài."
}, },
"trigger": {
"title": "Tạo trigger cục bộ",
"description": "Tạo trigger CLI gắn với phiên chat này."
},
"help": { "help": {
"title": "Hiển thị trợ giúp", "title": "Hiển thị trợ giúp",
"description": "Liệt kê các lệnh slash có sẵn." "description": "Liệt kê các lệnh slash có sẵn."

View File

@ -917,6 +917,10 @@
"title": "长期目标", "title": "长期目标",
"description": "让助手把当前请求当作需要多步骤持续推进的目标。" "description": "让助手把当前请求当作需要多步骤持续推进的目标。"
}, },
"trigger": {
"title": "创建本地触发器",
"description": "创建绑定到当前聊天会话的 CLI 触发器。"
},
"help": { "help": {
"title": "查看帮助", "title": "查看帮助",
"description": "列出可用的斜杠命令。" "description": "列出可用的斜杠命令。"

View File

@ -908,6 +908,10 @@
"title": "長期目標", "title": "長期目標",
"description": "請助理把這則請求當成需要多步驟持續推進的目標。" "description": "請助理把這則請求當成需要多步驟持續推進的目標。"
}, },
"trigger": {
"title": "建立本機觸發器",
"description": "建立綁定到目前聊天工作階段的 CLI 觸發器。"
},
"help": { "help": {
"title": "查看說明", "title": "查看說明",
"description": "列出可用的斜線命令。" "description": "列出可用的斜線命令。"

View File

@ -32,7 +32,7 @@ export interface UIMediaAttachment {
name?: string; name?: string;
} }
export interface UIMessageSource { kind: "cron"; label?: string; } export interface UIMessageSource { kind: "cron" | "trigger" | string; label?: string; }
export interface UIMessage { export interface UIMessage {
id: string; id: string;
@ -104,8 +104,9 @@ export interface SessionAutomationJob {
delete_after_run?: boolean; delete_after_run?: boolean;
created_at_ms?: number | null; created_at_ms?: number | null;
updated_at_ms?: number | null; updated_at_ms?: number | null;
kind?: "external_trigger" | "cron" | string;
schedule: { schedule: {
kind: "at" | "every" | "cron" | string; kind: "at" | "every" | "cron" | "external" | string;
at_ms?: number | null; at_ms?: number | null;
every_ms?: number | null; every_ms?: number | null;
expr?: string | null; expr?: string | null;
@ -113,7 +114,8 @@ export interface SessionAutomationJob {
}; };
payload: { payload: {
message: string; message: string;
kind?: "agent_turn" | "system_event" | string; kind?: "agent_turn" | "system_event" | "external_trigger" | string;
command?: string;
}; };
state: { state: {
next_run_at_ms?: number | null; next_run_at_ms?: number | null;
@ -135,6 +137,10 @@ export interface SessionAutomationJob {
title?: string; title?: string;
preview?: string; preview?: string;
} | null; } | null;
trigger?: {
id: string;
command: string;
};
} }
export interface SessionAutomationsPayload { jobs: SessionAutomationJob[]; } export interface SessionAutomationsPayload { jobs: SessionAutomationJob[]; }

View File

@ -21,6 +21,7 @@ const SLASH_COMMAND_KEYS = [
"dream_log", "dream_log",
"dream_restore", "dream_restore",
"goal", "goal",
"trigger",
"help", "help",
"pairing", "pairing",
]; ];