feat(B-NOTIF): Notification→Message Konsolidierung — System-Channel, post_system_message(), Migration
Check Cross-Plugin Imports / check (push) Has been cancelled

B-NOTIF-SYS: CommConversation um is_system Feld erweitert, get_or_create_system_channel()
B-NOTIF-EVT: post_system_message() in kommunikation/services.py — erstellt CommMessage im System-Channel
- create_notification() als deprecated Wrapper delegiert auf post_system_message()
- Text-Block + action_card Block mit Deep-Link, Metadata: notification_type/severity/entity_ref
B-NOTIF-PREF: NotificationPreference als Routing-Konfiguration, stumm/disabled respektiert
B-NOTIF-MIG: Migration 0120 — Notifications → CommMessages migriert, notifications_legacy View
B-NOTIF-DEPREC: Notification-Routes als deprecated markiert, keine Routes entfernt
B-NOTIF-TEST: 19 Tests in test_notification_migration.py — alle grün
- System-Channel, post_system_message, create_notification Wrapper, Preferences, Unread-Badge, Migration Mapping, Sensitive Fields
This commit is contained in:
Agent Zero
2026-08-13 21:19:41 +02:00
parent 78963f2ca9
commit 1baa9481a2
7 changed files with 1010 additions and 42 deletions
+55 -35
View File
@@ -1,7 +1,13 @@
"""Notification service — create and manage user notifications."""
"""Notification service — create and manage user notifications.
As of B-NOTIF-EVT, the primary entry point is post_system_message() which posts
to the Communication system channel. create_notification() is retained as a
deprecated backward-compat wrapper that delegates to post_system_message().
"""
from __future__ import annotations
import logging
import uuid
from datetime import UTC
from typing import Any
@@ -15,6 +21,31 @@ from app.models.notification import (
NotificationType,
)
logger = logging.getLogger(__name__)
# Re-export post_system_message from kommunikation services for convenience
async def post_system_message(
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
message_type: str,
title: str,
body: str | None = None,
entity_type: str | None = None,
entity_id: uuid.UUID | None = None,
severity: str = "info",
):
"""Post a typed system message to the tenant system channel.
Delegates to kommunikation.services.post_system_message.
Returns the created CommMessage, or None if the user has muted this type.
"""
from app.plugins.builtins.kommunikation.services import post_system_message as _post
return await _post(
db, tenant_id, user_id, message_type, title, body,
entity_type, entity_id, severity,
)
async def create_notification(
db: AsyncSession,
@@ -28,34 +59,24 @@ async def create_notification(
) -> Notification | None:
"""Create a new notification for a user if they have not disabled this type.
.. deprecated:: B-NOTIF-EVT
Use post_system_message() instead. This wrapper delegates to
post_system_message() and also creates a legacy Notification record
for backward compatibility with existing routes and frontend.
Returns None if the user has opted out of this notification type.
"""
# Check user preference
pref = await db.execute(
select(NotificationPreference).where(
and_(
NotificationPreference.user_id == user_id,
NotificationPreference.type_key == type,
NotificationPreference.tenant_id == tenant_id,
)
)
# Delegate to post_system_message for the comm channel
comm_msg = await post_system_message(
db, tenant_id, user_id, type, title, body,
entity_type, entity_id, severity="info",
)
pref_row = pref.scalar_one_or_none()
# If preference exists and is disabled, skip
if pref_row and not pref_row.is_enabled:
if comm_msg is None:
# User has muted this type — don't create legacy record either
return None
# If no preference, check if type is enabled by default
if not pref_row:
type_def = await db.execute(
select(NotificationType).where(NotificationType.type_key == type)
)
type_row = type_def.scalar_one_or_none()
if type_row and not type_row.is_enabled_by_default:
return None
# Create notification
# Also create legacy Notification record for backward compat
notif = Notification(
tenant_id=tenant_id,
user_id=user_id,
@@ -68,17 +89,18 @@ async def create_notification(
db.add(notif)
await db.flush()
# Publish notification.created event
# Publish notification.created event (backward compat)
from app.core.event_bus import get_event_bus
event_bus = get_event_bus()
await event_bus.publish('notification.created', {
'notification_id': str(notif.id),
'tenant_id': str(tenant_id),
'user_id': str(user_id),
'type': type,
'title': title,
'entity_type': entity_type,
'entity_id': str(entity_id) if entity_id else None,
await event_bus.publish("notification.created", {
"notification_id": str(notif.id),
"tenant_id": str(tenant_id),
"user_id": str(user_id),
"type": type,
"title": title,
"entity_type": entity_type,
"entity_id": str(entity_id) if entity_id else None,
"comm_message_id": str(comm_msg.id),
})
return notif
@@ -93,7 +115,6 @@ async def list_notifications(
) -> dict[str, Any]:
"""List notifications for a user, unread first, then by created_at desc."""
offset = (page - 1) * page_size
# Count total
count_q = (
select(func.count())
.select_from(Notification)
@@ -104,7 +125,6 @@ async def list_notifications(
)
total = (await db.execute(count_q)).scalar() or 0
# Query — unread first (read_at IS NULL), then newest
q = (
select(Notification)
.where(
@@ -112,7 +132,7 @@ async def list_notifications(
Notification.user_id == user_id,
)
.order_by(
Notification.read_at.isnot(None), # False (unread) sorts first
Notification.read_at.isnot(None),
Notification.created_at.desc(),
)
.offset(offset)
@@ -44,6 +44,7 @@ class CommConversation(Base, TenantMixin, OwnedMixin):
locked_by: Mapped[str | None] = mapped_column(String(100), nullable=True)
is_direct: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
is_archived: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
is_system: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
created_by: Mapped[uuid.UUID | None] = mapped_column(PGUUID(as_uuid=True), nullable=True)
created_by_type: Mapped[str] = mapped_column(String(20), nullable=False, default="user")
last_msg_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
@@ -1135,3 +1135,205 @@ async def create_plugin_room(
parts = [plugin_p, user_p]
return conversation_to_response(conv, parts, is_pinned_by_user=True)
# ─── System Channel ───
async def get_or_create_system_channel(
db: AsyncSession,
tenant_id: uuid.UUID,
) -> CommConversation:
"""Get or create the tenant-wide system channel.
The system channel is a locked, is_system=True conversation that serves as
the central destination for system notifications, user alerts, and agent messages.
All users of the tenant are automatically added as participants.
"""
result = await db.execute(
select(CommConversation).where(
CommConversation.tenant_id == tenant_id,
CommConversation.is_system.is_(True),
CommConversation.deleted_at.is_(None),
)
)
conv = result.scalar_one_or_none()
if conv is not None:
return conv
# Create the system channel
conv = CommConversation(
tenant_id=tenant_id,
title="System Channel",
is_pinned=False,
is_locked=True,
is_direct=False,
is_archived=False,
is_system=True,
created_by=None,
created_by_type="system",
metadata_={},
)
db.add(conv)
await db.flush()
# Add all tenant users as participants
from app.models.user import User, UserTenant
users_result = await db.execute(
select(User.id)
.join(UserTenant, UserTenant.user_id == User.id)
.where(UserTenant.tenant_id == tenant_id)
)
user_ids = [row[0] for row in users_result.all()]
for uid in user_ids:
p = CommParticipant(
tenant_id=tenant_id,
conversation_id=conv.id,
participant_id=uid,
participant_type="user",
role="member",
)
db.add(p)
await db.flush()
return conv
async def post_system_message(
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
message_type: str,
title: str,
body: str | None = None,
entity_type: str | None = None,
entity_id: uuid.UUID | None = None,
severity: str = "info",
) -> CommMessage | None:
"""Post a typed system message to the tenant system channel.
Creates a CommMessage in the system channel with:
- A text block containing title + body
- An action_card block with deep-link if entity_type/entity_id is set
- Block/message metadata: notification_type, severity, entity_ref
Returns the created CommMessage, or None if the user has muted this type.
"""
# Check user preferences — reuse the notification preference system
from app.models.notification import NotificationPreference, NotificationType
pref = await db.execute(
select(NotificationPreference).where(
and_(
NotificationPreference.user_id == user_id,
NotificationPreference.type_key == message_type,
NotificationPreference.tenant_id == tenant_id,
)
)
)
pref_row = pref.scalar_one_or_none()
if pref_row and not pref_row.is_enabled:
return None
if not pref_row:
type_def = await db.execute(
select(NotificationType).where(NotificationType.type_key == message_type)
)
type_row = type_def.scalar_one_or_none()
if type_row and not type_row.is_enabled_by_default:
return None
# Get or create system channel
conv = await get_or_create_system_channel(db, tenant_id)
# Build message content
content = title
if body:
content = f"{title}\n{body}"
# Build metadata
msg_metadata: dict[str, Any] = {
"notification_type": message_type,
"severity": severity,
"target_user_id": str(user_id),
}
if entity_type and entity_id:
msg_metadata["entity_ref"] = {
"entity_type": entity_type,
"entity_id": str(entity_id),
}
# Build blocks
blocks: list[dict[str, Any]] = [
{
"block_type": "text",
"block_data": {"text": content, "title": title, "body": body or ""},
}
]
if entity_type and entity_id:
blocks.append(
{
"block_type": "action_card",
"block_data": {
"label": "Open",
"entity_type": entity_type,
"entity_id": str(entity_id),
},
}
)
# Create message directly (not via send_message to avoid trigger_depth issues)
msg = CommMessage(
tenant_id=tenant_id,
conversation_id=conv.id,
sender_id=None,
sender_type="system",
content=content,
content_format="text",
metadata_=msg_metadata,
)
db.add(msg)
await db.flush()
# Create blocks
for i, block in enumerate(blocks):
b = CommMessageBlock(
tenant_id=tenant_id,
message_id=msg.id,
block_type=block["block_type"],
block_data=block["block_data"],
sort_order=i,
)
db.add(b)
await db.flush()
# Update conversation last_msg
from datetime import datetime, timezone as dt_timezone
await db.execute(
update(CommConversation)
.where(CommConversation.id == conv.id)
.values(
last_msg_at=datetime.now(dt_timezone.utc),
last_msg_preview=content[:200],
last_msg_sender_type="system",
)
)
# Publish event
event_bus = get_event_bus()
await event_bus.publish("system.message.posted", {
"conversation_id": str(conv.id),
"message_id": str(msg.id),
"tenant_id": str(tenant_id),
"user_id": str(user_id),
"message_type": message_type,
"severity": severity,
})
return msg
+5 -1
View File
@@ -1,4 +1,8 @@
"""Notification routes."""
"""Notification routes (deprecated — delegates to Communication system channel).
All notification endpoints are deprecated as of B-NOTIF-DEPREC. New code should use
the Communication system channel via post_system_message() instead.
"""
from __future__ import annotations