feat(trigger): add session-bound local triggers
This commit is contained in:
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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(),
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
Reference in New Issue
Block a user