659 lines
27 KiB
Python
659 lines
27 KiB
Python
"""Validated, revision-safe notification routing for workflow outputs."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import hmac
|
|
import json
|
|
import os
|
|
import re
|
|
import unicodedata
|
|
import uuid
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from types import MappingProxyType
|
|
from typing import Any, Mapping, Sequence
|
|
|
|
from gyxx_flow.core.artifacts import atomic_write_json
|
|
from gyxx_flow.core.config import Settings
|
|
from gyxx_flow.core.locks import LockManager, ResourceBusyError
|
|
|
|
NOTIFICATION_ROUTING_SCHEMA_VERSION = 1
|
|
NOTIFICATION_ROUTING_RELATIVE_PATH = Path("state") / "notifications" / "routing.json"
|
|
NOTIFICATION_ROUTING_CONFIG_NAME = "notification-routing.json"
|
|
|
|
ANALYZER_NOTIFICATION_APP_PROFILE = "hermes-analyzer"
|
|
ANALYZER_NOTIFICATION_APP_ID = "cli_aa8c4fc918b85cce"
|
|
ANALYZER_HERMES_PROFILE = "data-analyzer"
|
|
ANALYZER_ACCEPTANCE_RECIPIENT_OPEN_ID = (
|
|
"ou_7ad5fc8012e2f741afc5346e05ffd447"
|
|
)
|
|
|
|
GYXX_NOTIFICATION_ROUTE_MODE = "GYXX_NOTIFICATION_ROUTE_MODE"
|
|
GYXX_NOTIFICATION_APP_PROFILE = "GYXX_NOTIFICATION_APP_PROFILE"
|
|
GYXX_NOTIFICATION_HERMES_PROFILE = "GYXX_NOTIFICATION_HERMES_PROFILE"
|
|
GYXX_NOTIFICATION_RECIPIENTS_JSON = "GYXX_NOTIFICATION_RECIPIENTS_JSON"
|
|
GYXX_NOTIFICATION_ROUTE_REVISION = "GYXX_NOTIFICATION_ROUTE_REVISION"
|
|
GYXX_NOTIFICATION_ROUTE_WORKFLOW_ID = "GYXX_NOTIFICATION_ROUTE_WORKFLOW_ID"
|
|
|
|
_ROUTE_MODES = frozenset({"legacy", "enabled", "disabled"})
|
|
_SAFE_ID = re.compile(r"^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$")
|
|
_SAFE_OPEN_ID = re.compile(r"^ou_[A-Za-z0-9_-]{8,128}$")
|
|
_SAFE_APP_ID = re.compile(r"^cli_[A-Za-z0-9_-]{6,128}$")
|
|
_SAFE_SOURCE = frozenset({"provided", "repository", "mobile_lookup", "manual"})
|
|
_REVISION = re.compile(r"^[0-9a-f]{64}$")
|
|
|
|
MAX_NOTIFICATION_ROUTING_BYTES = 256 * 1024
|
|
MAX_NOTIFICATION_PEOPLE = 500
|
|
MAX_NOTIFICATION_APP_PROFILES = 16
|
|
MAX_NOTIFICATION_RECIPIENTS = 100
|
|
MAX_NOTIFICATION_BINDINGS_PER_PERSON = 16
|
|
|
|
NOTIFICATION_CAPABILITIES: Mapping[str, str] = MappingProxyType(
|
|
{
|
|
"content.marketing_report.daily": "正式执行且启用发送时投递最终营销日报。",
|
|
"content.relogin.weekly": "刷新期间投递各平台扫码二维码,并在平台失败时投递失败汇总。",
|
|
"product.alert.daily": "命中销量下滑规则且尚未重复通知时投递告警。",
|
|
"product.market_rank": "存在本次新生成的平台报告链接且尚未重复通知时投递。",
|
|
"product.online_stock_alert.daily": "线上商品当前库存低于 3 时向配置接收人发送库存预警。",
|
|
"supply.purchase_confirmation.daily": "采购确认结果生成完成后投递最终业务通知。",
|
|
"supply.replenishment.weekly": "补货处理完成后投递最终结果,包括无新增补货项的结果。",
|
|
"supply.replenishment_alert.daily": "存在命中库存阈值的 SKU 时投递预警。",
|
|
}
|
|
)
|
|
|
|
|
|
class NotificationRoutingError(ValueError):
|
|
"""Raised when notification routing is missing, corrupt, or unsafe."""
|
|
|
|
|
|
class NotificationRoutingConflictError(NotificationRoutingError):
|
|
"""Raised when a routing update uses a stale revision or cannot lock."""
|
|
|
|
|
|
class NotificationRoutingPreconditionError(NotificationRoutingError):
|
|
"""Raised when a routing mutation omits its expected revision."""
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class ResolvedNotificationRoute:
|
|
"""One immutable workflow notification decision."""
|
|
|
|
configured: bool
|
|
enabled: bool
|
|
app_profile: str | None
|
|
open_ids: tuple[str, ...]
|
|
revision: str | None = None
|
|
|
|
|
|
class NotificationRoutingStore:
|
|
"""Load the project baseline and atomically persist runtime routing state."""
|
|
|
|
def __init__(self, settings: Settings) -> None:
|
|
self.settings = settings
|
|
self.baseline_path = (
|
|
settings.project_root / "config" / NOTIFICATION_ROUTING_CONFIG_NAME
|
|
)
|
|
self.path = settings.data_root / NOTIFICATION_ROUTING_RELATIVE_PATH
|
|
self._locks = LockManager(settings.data_root / "state" / "locks")
|
|
|
|
def snapshot(self) -> tuple[dict[str, Any], str]:
|
|
"""Return one validated snapshot and the digest of the same raw bytes."""
|
|
|
|
source = self.path if self.path.is_file() else self.baseline_path
|
|
try:
|
|
raw = source.read_bytes()
|
|
except OSError as exc:
|
|
raise NotificationRoutingError(
|
|
f"cannot read notification routing: {source.name}"
|
|
) from exc
|
|
if len(raw) > MAX_NOTIFICATION_ROUTING_BYTES:
|
|
raise NotificationRoutingError("notification routing file is too large")
|
|
revision = hashlib.sha256(raw).hexdigest()
|
|
try:
|
|
payload = json.loads(raw.decode("utf-8"))
|
|
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
raise NotificationRoutingError("notification routing JSON is invalid") from exc
|
|
return _normalize_payload(payload), revision
|
|
|
|
def update(
|
|
self,
|
|
payload: Mapping[str, Any],
|
|
*,
|
|
expected_revision: str | None,
|
|
) -> tuple[dict[str, Any], str]:
|
|
"""Validate and atomically replace runtime state under optimistic locking."""
|
|
|
|
if expected_revision is None or not expected_revision.strip():
|
|
raise NotificationRoutingPreconditionError(
|
|
"notification routing revision is required"
|
|
)
|
|
expected = expected_revision.strip().strip('"')
|
|
normalized = _normalize_payload(payload)
|
|
_require_payload_size(normalized)
|
|
owner = f"notification-{uuid.uuid4().hex}"
|
|
try:
|
|
with self._locks.acquire("config:notification-routing", owner=owner):
|
|
_current, current_revision = self.snapshot()
|
|
if not hmac.compare_digest(current_revision, expected):
|
|
raise NotificationRoutingConflictError(
|
|
"notification routing was updated; refresh and retry"
|
|
)
|
|
atomic_write_json(self.path, normalized)
|
|
updated, revision = self.snapshot()
|
|
return updated, revision
|
|
except ResourceBusyError as exc:
|
|
raise NotificationRoutingConflictError(
|
|
"notification routing is being updated"
|
|
) from exc
|
|
|
|
|
|
def resolve_notification_route(
|
|
workflow_id: str,
|
|
defaults: Sequence[str],
|
|
environment: Mapping[str, str] | None = None,
|
|
) -> ResolvedNotificationRoute:
|
|
"""Resolve a route, preferring the immutable environment snapshot."""
|
|
|
|
_require_safe_id(workflow_id, field="workflow id")
|
|
values = os.environ if environment is None else environment
|
|
mode = values.get(GYXX_NOTIFICATION_ROUTE_MODE)
|
|
if mode is not None:
|
|
snapshot_workflow = values.get(GYXX_NOTIFICATION_ROUTE_WORKFLOW_ID, "").strip()
|
|
if snapshot_workflow != workflow_id:
|
|
raise NotificationRoutingError(
|
|
"notification route snapshot belongs to another workflow"
|
|
)
|
|
resolved = _route_from_environment(values, defaults)
|
|
elif workflow_id not in NOTIFICATION_CAPABILITIES:
|
|
resolved = ResolvedNotificationRoute(
|
|
configured=False,
|
|
enabled=True,
|
|
app_profile=None,
|
|
open_ids=_normalize_legacy_defaults(defaults),
|
|
)
|
|
else:
|
|
settings = Settings.from_env(
|
|
project_root=values.get("GYXX_PROJECT_ROOT") or None,
|
|
env=values,
|
|
)
|
|
payload, revision = NotificationRoutingStore(settings).snapshot()
|
|
route = payload["routes"].get(workflow_id)
|
|
if route is None:
|
|
resolved = ResolvedNotificationRoute(
|
|
configured=False,
|
|
enabled=True,
|
|
app_profile=None,
|
|
open_ids=_normalize_legacy_defaults(defaults),
|
|
revision=revision,
|
|
)
|
|
elif not route["enabled"]:
|
|
resolved = ResolvedNotificationRoute(
|
|
configured=True,
|
|
enabled=False,
|
|
app_profile=route["app_profile"],
|
|
open_ids=(),
|
|
revision=revision,
|
|
)
|
|
else:
|
|
open_ids = tuple(
|
|
payload["people"][person_id]["bindings"][route["app_profile"]][
|
|
"open_id"
|
|
]
|
|
for person_id in route["person_ids"]
|
|
)
|
|
resolved = ResolvedNotificationRoute(
|
|
configured=True,
|
|
enabled=True,
|
|
app_profile=route["app_profile"],
|
|
open_ids=open_ids,
|
|
revision=revision,
|
|
)
|
|
return _apply_acceptance_recipient(workflow_id, resolved, values)
|
|
|
|
|
|
def notification_environment_for_workflow(
|
|
workflow_id: str,
|
|
*,
|
|
project_root: Path | str,
|
|
data_root: Path | str,
|
|
environment: Mapping[str, str] | None = None,
|
|
) -> dict[str, str]:
|
|
"""Build the stable routing environment injected into one workflow run."""
|
|
|
|
values = dict(os.environ if environment is None else environment)
|
|
values["GYXX_PROJECT_ROOT"] = str(Path(project_root).expanduser().resolve())
|
|
values["GYXX_DATA_ROOT"] = str(Path(data_root).expanduser().resolve())
|
|
resolved = resolve_notification_route(workflow_id, (), values)
|
|
if resolved.configured:
|
|
mode = "enabled" if resolved.enabled else "disabled"
|
|
else:
|
|
mode = "legacy"
|
|
return {
|
|
GYXX_NOTIFICATION_ROUTE_MODE: mode,
|
|
GYXX_NOTIFICATION_APP_PROFILE: resolved.app_profile or "",
|
|
GYXX_NOTIFICATION_HERMES_PROFILE: (
|
|
ANALYZER_HERMES_PROFILE if resolved.configured else ""
|
|
),
|
|
GYXX_NOTIFICATION_RECIPIENTS_JSON: json.dumps(
|
|
resolved.open_ids,
|
|
ensure_ascii=True,
|
|
separators=(",", ":"),
|
|
),
|
|
GYXX_NOTIFICATION_ROUTE_REVISION: resolved.revision or "",
|
|
GYXX_NOTIFICATION_ROUTE_WORKFLOW_ID: workflow_id,
|
|
}
|
|
|
|
|
|
def _route_from_environment(
|
|
values: Mapping[str, str],
|
|
defaults: Sequence[str],
|
|
) -> ResolvedNotificationRoute:
|
|
mode = values.get(GYXX_NOTIFICATION_ROUTE_MODE, "").strip()
|
|
if mode not in _ROUTE_MODES:
|
|
raise NotificationRoutingError("notification route snapshot mode is invalid")
|
|
revision = values.get(GYXX_NOTIFICATION_ROUTE_REVISION, "").strip() or None
|
|
if revision is not None and not _REVISION.fullmatch(revision):
|
|
raise NotificationRoutingError("notification route snapshot revision is invalid")
|
|
app_profile = values.get(GYXX_NOTIFICATION_APP_PROFILE, "").strip() or None
|
|
hermes_profile = (
|
|
values.get(GYXX_NOTIFICATION_HERMES_PROFILE, "").strip() or None
|
|
)
|
|
raw_recipients = values.get(GYXX_NOTIFICATION_RECIPIENTS_JSON, "[]")
|
|
try:
|
|
recipients = json.loads(raw_recipients)
|
|
except json.JSONDecodeError as exc:
|
|
raise NotificationRoutingError(
|
|
"notification route snapshot recipients are invalid"
|
|
) from exc
|
|
if not isinstance(recipients, list) or not all(
|
|
isinstance(item, str) and _SAFE_OPEN_ID.fullmatch(item) for item in recipients
|
|
):
|
|
raise NotificationRoutingError(
|
|
"notification route snapshot recipients are invalid"
|
|
)
|
|
open_ids = tuple(dict.fromkeys(recipients))
|
|
if len(open_ids) > MAX_NOTIFICATION_RECIPIENTS:
|
|
raise NotificationRoutingError("notification route has too many recipients")
|
|
if mode == "legacy":
|
|
if app_profile is not None or hermes_profile is not None or open_ids:
|
|
raise NotificationRoutingError("legacy notification snapshot is inconsistent")
|
|
return ResolvedNotificationRoute(
|
|
configured=False,
|
|
enabled=True,
|
|
app_profile=None,
|
|
open_ids=_normalize_legacy_defaults(defaults),
|
|
revision=revision,
|
|
)
|
|
if app_profile is None:
|
|
raise NotificationRoutingError("notification app profile is required")
|
|
if hermes_profile != ANALYZER_HERMES_PROFILE:
|
|
raise NotificationRoutingError(
|
|
"notification Hermes profile snapshot is invalid"
|
|
)
|
|
_require_safe_id(app_profile, field="app profile")
|
|
if mode == "disabled":
|
|
if open_ids:
|
|
raise NotificationRoutingError("disabled notification snapshot has recipients")
|
|
return ResolvedNotificationRoute(True, False, app_profile, (), revision)
|
|
if not open_ids:
|
|
raise NotificationRoutingError("enabled notification snapshot has no recipients")
|
|
return ResolvedNotificationRoute(True, True, app_profile, open_ids, revision)
|
|
|
|
|
|
def _apply_acceptance_recipient(
|
|
workflow_id: str,
|
|
resolved: ResolvedNotificationRoute,
|
|
values: Mapping[str, str],
|
|
) -> ResolvedNotificationRoute:
|
|
if not resolved.enabled or not _read_bool(values.get("GYXX_WORKFLOW_ACCEPTANCE")):
|
|
return resolved
|
|
if workflow_id in NOTIFICATION_CAPABILITIES:
|
|
# These capabilities are delivered by the analyzer app. The global
|
|
# acceptance recipient belongs to the collector app and cannot be used
|
|
# with analyzer-scoped OpenIDs.
|
|
recipient = ANALYZER_ACCEPTANCE_RECIPIENT_OPEN_ID
|
|
else:
|
|
recipient = values.get("GYXX_NOTIFICATION_RECIPIENT_OPEN_ID", "").strip()
|
|
if not recipient:
|
|
return resolved
|
|
if not _SAFE_OPEN_ID.fullmatch(recipient):
|
|
raise NotificationRoutingError("acceptance notification recipient is invalid")
|
|
return ResolvedNotificationRoute(
|
|
configured=resolved.configured,
|
|
enabled=True,
|
|
app_profile=resolved.app_profile,
|
|
open_ids=(recipient,),
|
|
revision=resolved.revision,
|
|
)
|
|
|
|
|
|
def _normalize_payload(payload: object) -> dict[str, Any]:
|
|
if not isinstance(payload, Mapping):
|
|
raise NotificationRoutingError("notification routing must be an object")
|
|
required = {
|
|
"schema_version",
|
|
"app_profile",
|
|
"app_profiles",
|
|
"people",
|
|
"capabilities",
|
|
"routes",
|
|
}
|
|
if set(payload) != required:
|
|
raise NotificationRoutingError("notification routing fields are invalid")
|
|
schema_version = payload.get("schema_version")
|
|
if (
|
|
isinstance(schema_version, bool)
|
|
or schema_version != NOTIFICATION_ROUTING_SCHEMA_VERSION
|
|
):
|
|
raise NotificationRoutingError("unsupported notification routing schema")
|
|
app_profiles = _normalize_app_profiles(payload.get("app_profiles"))
|
|
app_profile = payload.get("app_profile")
|
|
if not isinstance(app_profile, str) or app_profile not in app_profiles:
|
|
raise NotificationRoutingError("default notification app profile is invalid")
|
|
_require_analyzer_delivery_profile(app_profile, app_profiles)
|
|
capabilities = _normalize_capabilities(payload.get("capabilities"))
|
|
people = _normalize_people(payload.get("people"), app_profiles)
|
|
routes = _normalize_routes(
|
|
payload.get("routes"),
|
|
app_profiles=app_profiles,
|
|
people=people,
|
|
)
|
|
normalized = {
|
|
"schema_version": NOTIFICATION_ROUTING_SCHEMA_VERSION,
|
|
"app_profile": app_profile,
|
|
"app_profiles": app_profiles,
|
|
"people": people,
|
|
"capabilities": capabilities,
|
|
"routes": routes,
|
|
}
|
|
_require_payload_size(normalized)
|
|
return normalized
|
|
|
|
|
|
def _normalize_app_profiles(value: object) -> dict[str, dict[str, str]]:
|
|
if not isinstance(value, Mapping) or not value:
|
|
raise NotificationRoutingError("notification app profiles are invalid")
|
|
if len(value) > MAX_NOTIFICATION_APP_PROFILES:
|
|
raise NotificationRoutingError("too many notification app profiles")
|
|
profiles: dict[str, dict[str, str]] = {}
|
|
for profile_id, raw in value.items():
|
|
_require_safe_id(profile_id, field="app profile")
|
|
if not isinstance(raw, Mapping) or set(raw) != {
|
|
"label",
|
|
"app_id",
|
|
"hermes_profile",
|
|
}:
|
|
raise NotificationRoutingError(f"invalid app profile: {profile_id}")
|
|
app_id = raw.get("app_id")
|
|
if not isinstance(app_id, str) or not _SAFE_APP_ID.fullmatch(app_id):
|
|
raise NotificationRoutingError(f"invalid app id for profile: {profile_id}")
|
|
hermes_profile = raw.get("hermes_profile")
|
|
if not isinstance(hermes_profile, str):
|
|
raise NotificationRoutingError(
|
|
f"invalid Hermes profile for app profile: {profile_id}"
|
|
)
|
|
_require_safe_id(hermes_profile, field="Hermes profile")
|
|
profiles[profile_id] = {
|
|
"label": _normalize_text(raw.get("label"), field="app profile label"),
|
|
"app_id": app_id,
|
|
"hermes_profile": hermes_profile,
|
|
}
|
|
return profiles
|
|
|
|
|
|
def _normalize_capabilities(value: object) -> dict[str, dict[str, str]]:
|
|
if not isinstance(value, Mapping) or set(value) != set(NOTIFICATION_CAPABILITIES):
|
|
raise NotificationRoutingError("notification capabilities do not match the catalog")
|
|
capabilities: dict[str, dict[str, str]] = {}
|
|
for workflow_id, expected_condition in NOTIFICATION_CAPABILITIES.items():
|
|
raw = value.get(workflow_id)
|
|
if not isinstance(raw, Mapping) or set(raw) != {"condition"}:
|
|
raise NotificationRoutingError(
|
|
f"invalid notification capability: {workflow_id}"
|
|
)
|
|
if raw.get("condition") != expected_condition:
|
|
raise NotificationRoutingError(
|
|
f"notification capability condition changed: {workflow_id}"
|
|
)
|
|
capabilities[workflow_id] = {"condition": expected_condition}
|
|
return capabilities
|
|
|
|
|
|
def _normalize_people(
|
|
value: object,
|
|
app_profiles: Mapping[str, object],
|
|
) -> dict[str, dict[str, Any]]:
|
|
if not isinstance(value, Mapping):
|
|
raise NotificationRoutingError("notification people must be an object")
|
|
if len(value) > MAX_NOTIFICATION_PEOPLE:
|
|
raise NotificationRoutingError("too many notification people")
|
|
people: dict[str, dict[str, Any]] = {}
|
|
identities: dict[tuple[str, str], str] = {}
|
|
for person_id, raw in value.items():
|
|
_require_safe_id(person_id, field="person id")
|
|
if not isinstance(raw, Mapping) or set(raw) != {"name", "mobile", "bindings"}:
|
|
raise NotificationRoutingError(f"invalid person: {person_id}")
|
|
bindings_value = raw.get("bindings")
|
|
if not isinstance(bindings_value, Mapping):
|
|
raise NotificationRoutingError(f"invalid bindings for person: {person_id}")
|
|
if len(bindings_value) > MAX_NOTIFICATION_BINDINGS_PER_PERSON:
|
|
raise NotificationRoutingError(f"too many bindings for person: {person_id}")
|
|
bindings: dict[str, dict[str, Any]] = {}
|
|
for profile_id, binding in bindings_value.items():
|
|
_require_safe_id(profile_id, field="app profile")
|
|
if profile_id not in app_profiles:
|
|
raise NotificationRoutingError(
|
|
f"unknown app profile for person {person_id}: {profile_id}"
|
|
)
|
|
if not isinstance(binding, Mapping) or set(binding) != {
|
|
"open_id",
|
|
"verified",
|
|
"source",
|
|
}:
|
|
raise NotificationRoutingError(
|
|
f"invalid binding for person {person_id}: {profile_id}"
|
|
)
|
|
open_id = binding.get("open_id")
|
|
if not isinstance(open_id, str) or not _SAFE_OPEN_ID.fullmatch(open_id):
|
|
raise NotificationRoutingError(
|
|
f"invalid open_id for person {person_id}: {profile_id}"
|
|
)
|
|
verified = binding.get("verified")
|
|
source = binding.get("source")
|
|
if (
|
|
not isinstance(verified, bool)
|
|
or not isinstance(source, str)
|
|
or source not in _SAFE_SOURCE
|
|
):
|
|
raise NotificationRoutingError(
|
|
f"invalid binding metadata for person {person_id}: {profile_id}"
|
|
)
|
|
identity_key = (profile_id, open_id)
|
|
previous = identities.get(identity_key)
|
|
if previous is not None and previous != person_id:
|
|
raise NotificationRoutingError(
|
|
f"open_id is bound to multiple people in {profile_id}"
|
|
)
|
|
identities[identity_key] = person_id
|
|
bindings[profile_id] = {
|
|
"open_id": open_id,
|
|
"verified": verified,
|
|
"source": source,
|
|
}
|
|
mobile = _normalize_mobile(raw.get("mobile"))
|
|
if not bindings and mobile is None:
|
|
raise NotificationRoutingError(
|
|
f"person requires a mobile or app binding: {person_id}"
|
|
)
|
|
people[person_id] = {
|
|
"name": _normalize_text(raw.get("name"), field="person name"),
|
|
"mobile": mobile,
|
|
"bindings": bindings,
|
|
}
|
|
return people
|
|
|
|
|
|
def _normalize_routes(
|
|
value: object,
|
|
*,
|
|
app_profiles: Mapping[str, object],
|
|
people: Mapping[str, Mapping[str, Any]],
|
|
) -> dict[str, dict[str, Any]]:
|
|
if not isinstance(value, Mapping):
|
|
raise NotificationRoutingError("notification routes must be an object")
|
|
routes: dict[str, dict[str, Any]] = {}
|
|
for workflow_id, raw in value.items():
|
|
_require_safe_id(workflow_id, field="workflow id")
|
|
if workflow_id not in NOTIFICATION_CAPABILITIES:
|
|
raise NotificationRoutingError(
|
|
f"workflow does not support notification routing: {workflow_id}"
|
|
)
|
|
if not isinstance(raw, Mapping) or set(raw) != {
|
|
"enabled",
|
|
"app_profile",
|
|
"person_ids",
|
|
}:
|
|
raise NotificationRoutingError(f"invalid notification route: {workflow_id}")
|
|
enabled = raw.get("enabled")
|
|
profile_id = raw.get("app_profile")
|
|
person_ids_value = raw.get("person_ids")
|
|
if not isinstance(enabled, bool):
|
|
raise NotificationRoutingError(f"invalid enabled flag: {workflow_id}")
|
|
if not isinstance(profile_id, str) or profile_id not in app_profiles:
|
|
raise NotificationRoutingError(f"invalid app profile: {workflow_id}")
|
|
_require_analyzer_delivery_profile(profile_id, app_profiles)
|
|
if not isinstance(person_ids_value, list) or not all(
|
|
isinstance(person_id, str) for person_id in person_ids_value
|
|
):
|
|
raise NotificationRoutingError(f"invalid person list: {workflow_id}")
|
|
person_ids = list(dict.fromkeys(person_ids_value))
|
|
if len(person_ids) > MAX_NOTIFICATION_RECIPIENTS:
|
|
raise NotificationRoutingError(f"too many route recipients: {workflow_id}")
|
|
if enabled and not person_ids:
|
|
raise NotificationRoutingError(
|
|
f"enabled notification route has no recipients: {workflow_id}"
|
|
)
|
|
for person_id in person_ids:
|
|
_require_safe_id(person_id, field="person id")
|
|
person = people.get(person_id)
|
|
if person is None:
|
|
raise NotificationRoutingError(
|
|
f"notification route references unknown person: {person_id}"
|
|
)
|
|
if enabled and profile_id not in person["bindings"]:
|
|
raise NotificationRoutingError(
|
|
f"person {person_id} has no binding for {profile_id}"
|
|
)
|
|
routes[workflow_id] = {
|
|
"enabled": enabled,
|
|
"app_profile": profile_id,
|
|
"person_ids": person_ids,
|
|
}
|
|
return routes
|
|
|
|
|
|
def _normalize_legacy_defaults(defaults: Sequence[str]) -> tuple[str, ...]:
|
|
normalized: list[str] = []
|
|
for value in defaults:
|
|
if not isinstance(value, str):
|
|
raise NotificationRoutingError("legacy notification recipient must be a string")
|
|
recipient = value.strip()
|
|
if not recipient or len(recipient) > 256 or _has_unsafe_characters(recipient):
|
|
raise NotificationRoutingError("legacy notification recipient is unsafe")
|
|
if recipient not in normalized:
|
|
normalized.append(recipient)
|
|
if len(normalized) > MAX_NOTIFICATION_RECIPIENTS:
|
|
raise NotificationRoutingError("too many legacy notification recipients")
|
|
return tuple(normalized)
|
|
|
|
|
|
def _normalize_mobile(value: object) -> str | None:
|
|
if value is None:
|
|
return None
|
|
if not isinstance(value, str):
|
|
raise NotificationRoutingError("person mobile must be a string or null")
|
|
mobile = re.sub(r"[\s()-]", "", value)
|
|
if re.fullmatch(r"1\d{10}", mobile):
|
|
mobile = f"+86{mobile}"
|
|
elif re.fullmatch(r"86\d{11}", mobile):
|
|
mobile = f"+{mobile}"
|
|
if not re.fullmatch(r"\+[1-9]\d{6,14}", mobile):
|
|
raise NotificationRoutingError("person mobile must use E.164 format")
|
|
return mobile
|
|
|
|
|
|
def _normalize_text(value: object, *, field: str) -> str:
|
|
if not isinstance(value, str):
|
|
raise NotificationRoutingError(f"{field} must be a string")
|
|
normalized = unicodedata.normalize("NFC", value).strip()
|
|
if (
|
|
not normalized
|
|
or len(normalized) > 64
|
|
or len(normalized.encode("utf-8")) > 256
|
|
or _has_unsafe_characters(normalized)
|
|
):
|
|
raise NotificationRoutingError(f"{field} is invalid")
|
|
return normalized
|
|
|
|
|
|
def _has_unsafe_characters(value: str) -> bool:
|
|
return any(
|
|
unicodedata.category(character) in {"Cc", "Cf", "Cs"}
|
|
for character in value
|
|
)
|
|
|
|
|
|
def _require_safe_id(value: object, *, field: str) -> None:
|
|
if not isinstance(value, str) or not _SAFE_ID.fullmatch(value):
|
|
raise NotificationRoutingError(f"invalid {field}: {value!r}")
|
|
|
|
|
|
def _require_analyzer_delivery_profile(
|
|
profile_id: str,
|
|
app_profiles: Mapping[str, Mapping[str, str]],
|
|
) -> None:
|
|
profile = app_profiles.get(profile_id)
|
|
if profile is None or profile.get("hermes_profile") != ANALYZER_HERMES_PROFILE:
|
|
raise NotificationRoutingError(
|
|
"business notification routes must use an analyzer Hermes app profile"
|
|
)
|
|
|
|
|
|
def _require_payload_size(payload: Mapping[str, Any]) -> None:
|
|
encoded = json.dumps(
|
|
payload,
|
|
ensure_ascii=False,
|
|
separators=(",", ":"),
|
|
).encode("utf-8")
|
|
if len(encoded) > MAX_NOTIFICATION_ROUTING_BYTES:
|
|
raise NotificationRoutingError("notification routing payload is too large")
|
|
|
|
|
|
def _read_bool(value: str | None) -> bool:
|
|
return bool(value and value.strip().casefold() in {"1", "true", "yes", "on"})
|
|
|
|
|
|
__all__ = [
|
|
"ANALYZER_ACCEPTANCE_RECIPIENT_OPEN_ID",
|
|
"ANALYZER_HERMES_PROFILE",
|
|
"ANALYZER_NOTIFICATION_APP_ID",
|
|
"ANALYZER_NOTIFICATION_APP_PROFILE",
|
|
"GYXX_NOTIFICATION_APP_PROFILE",
|
|
"GYXX_NOTIFICATION_HERMES_PROFILE",
|
|
"GYXX_NOTIFICATION_RECIPIENTS_JSON",
|
|
"GYXX_NOTIFICATION_ROUTE_MODE",
|
|
"GYXX_NOTIFICATION_ROUTE_REVISION",
|
|
"GYXX_NOTIFICATION_ROUTE_WORKFLOW_ID",
|
|
"MAX_NOTIFICATION_ROUTING_BYTES",
|
|
"NOTIFICATION_CAPABILITIES",
|
|
"NOTIFICATION_ROUTING_SCHEMA_VERSION",
|
|
"NotificationRoutingConflictError",
|
|
"NotificationRoutingError",
|
|
"NotificationRoutingPreconditionError",
|
|
"NotificationRoutingStore",
|
|
"ResolvedNotificationRoute",
|
|
"notification_environment_for_workflow",
|
|
"resolve_notification_route",
|
|
]
|