Mission Control: notify OpenClaw via tools/invoke on task events
This commit is contained in:
135
backend/app/integrations/notify.py
Normal file
135
backend/app/integrations/notify.py
Normal file
@@ -0,0 +1,135 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Iterable
|
||||
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.integrations.openclaw import OpenClawClient
|
||||
from app.models.org import Employee
|
||||
from app.models.projects import ProjectMember
|
||||
from app.models.work import Task, TaskComment
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NotifyContext:
|
||||
event: str # task.created | task.updated | task.assigned | comment.created | status.changed
|
||||
actor_employee_id: int
|
||||
task: Task
|
||||
comment: TaskComment | None = None
|
||||
changed_fields: dict | None = None
|
||||
|
||||
|
||||
def _employee_session_keys(session: Session, employee_ids: Iterable[int]) -> list[str]:
|
||||
ids = sorted({i for i in employee_ids if i is not None})
|
||||
if not ids:
|
||||
return []
|
||||
|
||||
emps = session.exec(select(Employee).where(Employee.id.in_(ids))).all()
|
||||
keys: list[str] = []
|
||||
for e in emps:
|
||||
if not getattr(e, "notify_enabled", True):
|
||||
continue
|
||||
sk = getattr(e, "openclaw_session_key", None)
|
||||
if sk:
|
||||
keys.append(sk)
|
||||
return sorted(set(keys))
|
||||
|
||||
|
||||
def _project_pm_employee_ids(session: Session, project_id: int) -> set[int]:
|
||||
# Generic, data-driven: PMs are determined by project_members.role.
|
||||
pms = session.exec(select(ProjectMember).where(ProjectMember.project_id == project_id)).all()
|
||||
pm_ids: set[int] = set()
|
||||
for m in pms:
|
||||
role = (m.role or "").lower()
|
||||
if role in {"pm", "product", "product_manager", "manager"}:
|
||||
pm_ids.add(m.employee_id)
|
||||
return pm_ids
|
||||
|
||||
|
||||
def resolve_recipients(session: Session, ctx: NotifyContext) -> set[int]:
|
||||
t = ctx.task
|
||||
recipients: set[int] = set()
|
||||
|
||||
if ctx.event == "task.created":
|
||||
# notify assignee + PMs
|
||||
if t.assignee_employee_id:
|
||||
recipients.add(t.assignee_employee_id)
|
||||
recipients |= _project_pm_employee_ids(session, t.project_id)
|
||||
|
||||
elif ctx.event == "task.assigned":
|
||||
if t.assignee_employee_id:
|
||||
recipients.add(t.assignee_employee_id)
|
||||
recipients |= _project_pm_employee_ids(session, t.project_id)
|
||||
|
||||
elif ctx.event == "comment.created":
|
||||
# notify assignee + reviewer + PMs, excluding author
|
||||
if t.assignee_employee_id:
|
||||
recipients.add(t.assignee_employee_id)
|
||||
if t.reviewer_employee_id:
|
||||
recipients.add(t.reviewer_employee_id)
|
||||
recipients |= _project_pm_employee_ids(session, t.project_id)
|
||||
if ctx.comment and ctx.comment.author_employee_id:
|
||||
recipients.discard(ctx.comment.author_employee_id)
|
||||
|
||||
elif ctx.event == "status.changed":
|
||||
new_status = (getattr(t, "status", None) or "").lower()
|
||||
if new_status in {"review", "ready_for_review"} and t.reviewer_employee_id:
|
||||
recipients.add(t.reviewer_employee_id)
|
||||
recipients |= _project_pm_employee_ids(session, t.project_id)
|
||||
|
||||
elif ctx.event == "task.updated":
|
||||
# conservative: PMs only
|
||||
recipients |= _project_pm_employee_ids(session, t.project_id)
|
||||
|
||||
recipients.discard(ctx.actor_employee_id)
|
||||
return recipients
|
||||
|
||||
|
||||
def build_message(ctx: NotifyContext) -> str:
|
||||
t = ctx.task
|
||||
base = f"Task #{t.id}: {t.title}" if t.id is not None else f"Task: {t.title}"
|
||||
|
||||
if ctx.event == "task.assigned":
|
||||
return f"Assigned: {base}.\nWork ONE task only; update Mission Control with a comment when you make progress."
|
||||
|
||||
if ctx.event == "comment.created":
|
||||
snippet = ""
|
||||
if ctx.comment and ctx.comment.body:
|
||||
snippet = ctx.comment.body.strip().replace("\n", " ")
|
||||
if len(snippet) > 180:
|
||||
snippet = snippet[:177] + "..."
|
||||
snippet = f"\nComment: {snippet}"
|
||||
return f"New comment on {base}.{snippet}\nWork ONE task only; reply/update in Mission Control."
|
||||
|
||||
if ctx.event == "status.changed":
|
||||
return f"Status changed on {base} → {t.status}.\nWork ONE task only; update Mission Control with next step."
|
||||
|
||||
if ctx.event == "task.created":
|
||||
return f"New task created: {base}.\nWork ONE task only; add acceptance criteria / next step in Mission Control."
|
||||
|
||||
return f"Update on {base}.\nWork ONE task only; update Mission Control."
|
||||
|
||||
|
||||
def notify_openclaw(session: Session, ctx: NotifyContext) -> None:
|
||||
client = OpenClawClient.from_env()
|
||||
if client is None:
|
||||
return
|
||||
|
||||
recipient_ids = resolve_recipients(session, ctx)
|
||||
session_keys = _employee_session_keys(session, recipient_ids)
|
||||
if not session_keys:
|
||||
return
|
||||
|
||||
message = build_message(ctx)
|
||||
|
||||
for sk in session_keys:
|
||||
try:
|
||||
client.tools_invoke(
|
||||
"sessions_send",
|
||||
{"sessionKey": sk, "message": message},
|
||||
timeout_s=3.0,
|
||||
)
|
||||
except Exception:
|
||||
# best-effort; never break Mission Control writes
|
||||
continue
|
||||
34
backend/app/integrations/openclaw.py
Normal file
34
backend/app/integrations/openclaw.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
class OpenClawClient:
|
||||
def __init__(self, base_url: str, token: str):
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.token = token
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> "OpenClawClient | None":
|
||||
url = os.environ.get("OPENCLAW_GATEWAY_URL")
|
||||
token = os.environ.get("OPENCLAW_GATEWAY_TOKEN")
|
||||
if not url or not token:
|
||||
return None
|
||||
return cls(url, token)
|
||||
|
||||
def tools_invoke(self, tool: str, args: dict[str, Any], *, session_key: str | None = None, timeout_s: float = 5.0) -> dict[str, Any]:
|
||||
payload: dict[str, Any] = {"tool": tool, "args": args}
|
||||
if session_key is not None:
|
||||
payload["sessionKey"] = session_key
|
||||
|
||||
r = requests.post(
|
||||
f"{self.base_url}/tools/invoke",
|
||||
headers={"Authorization": f"Bearer {self.token}", "Content-Type": "application/json"},
|
||||
json=payload,
|
||||
timeout=timeout_s,
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
Reference in New Issue
Block a user