Files
AgentLab/backend/app/api/notify.py
T

230 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""通知渠道与投递记录(BE-2)。设计文档 §2.4、§3.10。
渠道的地址和签名密钥加密落库,接口只回主机名、配没配密钥;改渠道时不传地址(或传空)表示保持原值。格式和地址的
校验同定时任务的 Webhook(app/notify/core.py)。个人渠道(scope=user)只有开了登录才有:它挂在用户 id 上,只有本人
能看能改;全局、应用的渠道关登录时谁都能改,开了登录要 builder 以上。定时任务的通知仍在定时任务上设,不迁移(Q7)。
个人渠道谁都能建(权限最低的使用者也能),地址另过一遍 SSRF 检查(tools/net.py,不开内网),保存和发送时各一次:
不然服务端替它往内网地址发 POST,测试接口交回的状态码和报错就成了探测内网的信号(A6.2:只有登记的端点能访问内网)。
全局、应用的渠道只有搭建者以上能建,和定时任务的 Webhook 同一个信任级别,地址照旧只查格式(内网的接收端很常见)。
"""
from __future__ import annotations
from typing import Any, Literal
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel, Field
from sqlalchemy import or_, select
from app.api.deps import current_principal
from app.core.crypto import encrypt
from app.db.base import SessionLocal
from app.db.models import NotifyChannel, NotifyDelivery
from app.notify import core
from app.notify.service import EVENTS, EVENT_LABEL, channel_out
router = APIRouter(prefix="/api/notify", tags=["notify"])
class ChannelIn(BaseModel):
name: str = Field(min_length=1, max_length=100)
scope: Literal["global", "app", "user"] = "global"
owner_id: str | None = None
format: str = "generic"
#: 新建必填;修改时不传或传空表示保持原值
url: str | None = None
#: 签名密钥:不传保持原值,传空字符串表示去掉
secret: str | None = None
events: list[str] = Field(default_factory=list)
enabled: bool = True
def _can_edit(row_scope: str, owner_id: str | None) -> None:
principal = current_principal()
if principal.mode == "off":
if row_scope == "user":
raise HTTPException(422, "个人通知渠道需要开启登录后才能使用:关闭登录时没有用户,无法认出是谁的渠道")
return
if row_scope == "user":
if owner_id != principal.user_id:
raise HTTPException(403, "只能修改自己的通知渠道")
return
if not principal.at_least("builder"):
raise HTTPException(403, "只有搭建者和管理员可以修改全局或应用的通知渠道")
async def _safe_personal(scope: str, url: str) -> None:
"""个人渠道的地址过 SSRF 检查(见模块说明)。"""
from app.notify.service import personal_url_problem
if scope == "user" and (problem := await personal_url_problem(url)):
raise HTTPException(422, problem)
def _validated(payload: ChannelIn, *, url: str | None) -> None:
if payload.format not in core.FORMATS:
raise HTTPException(422, f"消息格式只能是{'、'.join(core.FORMAT_LABEL.values())}之一")
unknown = [e for e in payload.events if e not in EVENTS]
if unknown:
raise HTTPException(422, f"不认识的通知事件:{'、'.join(unknown)}。可选:{'、'.join(EVENT_LABEL.values())}")
if url is not None and (problem := core.format_problem(payload.format, url)):
raise HTTPException(422, problem)
@router.get("/channels")
async def list_channels() -> dict[str, Any]:
"""{items}:形状同前端的接口约定(frontend/src/api/capabilities.ts)。"""
principal = current_principal()
stmt = select(NotifyChannel).order_by(NotifyChannel.created_at)
if principal.mode != "off":
# 个人渠道只给本人看
stmt = stmt.where(or_(NotifyChannel.scope != "user", NotifyChannel.owner_id == principal.user_id))
async with SessionLocal() as session:
return {"items": [channel_out(r) for r in (await session.execute(stmt)).scalars()]}
@router.get("/events")
async def list_events() -> list[dict[str, str]]:
return [{"event": e, "label": EVENT_LABEL[e]} for e in EVENTS]
@router.post("/channels", status_code=201)
async def create_channel(payload: ChannelIn) -> dict[str, Any]:
owner = current_principal().user_id if payload.scope == "user" else payload.owner_id
_can_edit(payload.scope, owner)
try:
url = core.check_url(payload.url)
except ValueError as e:
raise HTTPException(422, str(e)) from e
_validated(payload, url=url)
await _safe_personal(payload.scope, url)
row = NotifyChannel(name=payload.name.strip(), scope=payload.scope, owner_id=owner, format=payload.format,
url=encrypt(url), secret=encrypt(payload.secret) if payload.secret else None,
events=list(dict.fromkeys(payload.events)), enabled=payload.enabled)
async with SessionLocal() as session:
session.add(row)
await session.commit()
return channel_out(row)
class ChannelPatch(BaseModel):
"""改一部分:没给的项保持原值(界面上的启用开关只传 enabled)。"""
name: str | None = Field(default=None, min_length=1, max_length=100)
scope: Literal["global", "app", "user"] | None = None
owner_id: str | None = None
format: str | None = None
url: str | None = None
secret: str | None = None
events: list[str] | None = None
enabled: bool | None = None
@router.patch("/channels/{channel_id}")
async def patch_channel(channel_id: str, payload: ChannelPatch) -> dict[str, Any]:
async with SessionLocal() as session:
row = await session.get(NotifyChannel, channel_id)
if row is None:
raise HTTPException(404, "通知渠道不存在,可能已被删除")
current = {"name": row.name, "scope": row.scope, "owner_id": row.owner_id, "format": row.format,
"events": list(row.events or []), "enabled": row.enabled}
given = {k: v for k, v in payload.model_dump(exclude_unset=True).items() if v is not None}
return await update_channel(channel_id, ChannelIn(**{**current, **given}))
@router.put("/channels/{channel_id}")
async def update_channel(channel_id: str, payload: ChannelIn) -> dict[str, Any]:
async with SessionLocal() as session:
row = await session.get(NotifyChannel, channel_id)
if row is None:
raise HTTPException(404, "通知渠道不存在,可能已被删除")
_can_edit(row.scope, row.owner_id)
if payload.scope != row.scope:
raise HTTPException(422, "通知渠道的范围建好之后不能修改,请新建一个")
url = None
if payload.url:
try:
url = core.check_url(payload.url)
except ValueError as e:
raise HTTPException(422, str(e)) from e
if url is None:
from app.core.crypto import decrypt
current = decrypt(row.url) or ""
_validated(payload, url=current or None)
else:
_validated(payload, url=url)
await _safe_personal(row.scope, url)
row.url = encrypt(url)
row.name, row.format = payload.name.strip(), payload.format
row.events, row.enabled = list(dict.fromkeys(payload.events)), payload.enabled
if payload.secret is not None:
row.secret = encrypt(payload.secret) if payload.secret else None
if row.scope == "app":
row.owner_id = payload.owner_id
await session.commit()
return channel_out(row)
@router.delete("/channels/{channel_id}", status_code=204)
async def delete_channel(channel_id: str) -> None:
async with SessionLocal() as session:
row = await session.get(NotifyChannel, channel_id)
if row is None:
raise HTTPException(404, "通知渠道不存在,可能已被删除")
_can_edit(row.scope, row.owner_id)
await session.delete(row)
await session.commit()
@router.post("/channels/{channel_id}/test")
async def test_channel(channel_id: str) -> dict[str, Any]:
"""发一条测试消息(不重试),记一条 event=test 的投递记录。返回 {ok, http_status, message},同定时任务的测试。"""
from app.notify import send
async with SessionLocal() as session:
row = await session.get(NotifyChannel, channel_id)
if row is None:
raise HTTPException(404, "通知渠道不存在,可能已被删除")
_can_edit(row.scope, row.owner_id)
message = {"event": "test", "title": f"通知渠道「{row.name}」测试", "ok": True, "retry": False,
"lines": [("说明", "这是一条测试消息。收到即表示渠道地址配置正确"
+ (";请求头带有签名,接收端可据此核对签名密钥" if row.secret and row.format == "generic"
else ""))]}
outcome = await send(row, message)
async with SessionLocal() as session:
session.add(NotifyDelivery(channel_id=row.id, subject_kind="channel", subject_id=row.id, event="test",
status="ok" if outcome.ok else "failed", http_status=outcome.http_status,
attempts=outcome.attempts, error=outcome.error))
await session.commit()
if outcome.ok:
return {"ok": True, "http_status": outcome.http_status, "message": "请到接收端确认已收到"}
return {"ok": False, "http_status": outcome.http_status, "message": outcome.error or "发送失败"}
@router.get("/deliveries")
async def list_deliveries(
channel_id: str | None = None,
run_id: str | None = None,
task_id: str | None = None,
limit: int = Query(default=100, ge=1, le=500),
) -> dict[str, Any]:
"""投递记录。开登录后 user 角色只看发往自己的个人渠道的:别的渠道的投递记录里有所有应用、所有运行的待办和动作。"""
stmt = select(NotifyDelivery).order_by(NotifyDelivery.created_at.desc()).limit(limit)
principal = current_principal()
if principal.mode != "off" and not principal.at_least("builder"):
stmt = stmt.join(NotifyChannel, NotifyChannel.id == NotifyDelivery.channel_id).where(
NotifyChannel.scope == "user", NotifyChannel.owner_id == principal.user_id)
if channel_id:
stmt = stmt.where(NotifyDelivery.channel_id == channel_id)
if run_id:
stmt = stmt.where(NotifyDelivery.run_id == run_id)
if task_id:
stmt = stmt.where(NotifyDelivery.task_id == task_id)
async with SessionLocal() as session:
rows = (await session.execute(stmt)).scalars()
return {"items": [{
"id": r.id, "channel_id": r.channel_id, "subject_kind": r.subject_kind, "subject_id": r.subject_id,
"event": r.event, "run_id": r.run_id, "task_id": r.task_id, "status": r.status,
"http_status": r.http_status, "attempts": r.attempts, "error": r.error,
"created_at": r.created_at.isoformat() if r.created_at else None} for r in rows]}