2025-12-13 00:00:52 +01:00
|
|
|
"""
|
|
|
|
|
Messaging Service
|
|
|
|
|
|
|
|
|
|
Comprehensive service for managing communication:
|
|
|
|
|
- Channels (top-level containers)
|
|
|
|
|
- Groups (role-based containers within channels)
|
|
|
|
|
- Sessions (message boundaries)
|
|
|
|
|
- Messages (individual communications)
|
|
|
|
|
|
2025-12-26 18:01:53 +01:00
|
|
|
Implements the communication model.
|
2025-12-13 00:00:52 +01:00
|
|
|
"""
|
|
|
|
|
|
2025-12-30 02:35:58 +01:00
|
|
|
import asyncio
|
2025-12-13 00:00:52 +01:00
|
|
|
from datetime import UTC, datetime
|
2025-12-30 02:35:58 +01:00
|
|
|
from typing import Any, ClassVar, cast
|
2025-12-13 00:00:52 +01:00
|
|
|
from uuid import UUID
|
|
|
|
|
|
|
|
|
|
from sqlalchemy import select
|
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
2025-12-22 18:04:31 +01:00
|
|
|
from sqlalchemy.orm import joinedload
|
2025-12-13 00:00:52 +01:00
|
|
|
|
|
|
|
|
from roboco.db.tables import (
|
|
|
|
|
ChannelTable,
|
|
|
|
|
GroupTable,
|
|
|
|
|
MessageTable,
|
2025-12-28 19:52:01 +01:00
|
|
|
NotificationTable,
|
2025-12-13 00:00:52 +01:00
|
|
|
SessionTable,
|
2025-12-22 18:04:31 +01:00
|
|
|
SessionTaskTable,
|
2025-12-13 00:00:52 +01:00
|
|
|
)
|
2025-12-22 04:30:50 +01:00
|
|
|
from roboco.enforcement import validate_channel_access
|
|
|
|
|
from roboco.events import Event, EventType, get_event_bus
|
2025-12-13 00:00:52 +01:00
|
|
|
from roboco.models.base import (
|
|
|
|
|
MessageType,
|
2025-12-28 19:52:01 +01:00
|
|
|
NotificationPriority,
|
|
|
|
|
NotificationType,
|
2025-12-13 00:00:52 +01:00
|
|
|
SessionStatus,
|
|
|
|
|
)
|
2025-12-14 01:09:57 +01:00
|
|
|
from roboco.models.messaging import (
|
|
|
|
|
ChannelCreateRequest,
|
|
|
|
|
GroupCreateRequest,
|
|
|
|
|
MessageCreateRequest,
|
|
|
|
|
SessionCreateRequest,
|
|
|
|
|
)
|
2025-12-22 18:04:31 +01:00
|
|
|
from roboco.models.session import (
|
|
|
|
|
SessionForTasksCreate,
|
|
|
|
|
SessionTaskRelationshipType,
|
|
|
|
|
)
|
|
|
|
|
from roboco.services.base import BaseService, ConflictError, NotFoundError
|
2025-12-13 00:00:52 +01:00
|
|
|
|
|
|
|
|
# =============================================================================
|
|
|
|
|
# MESSAGING SERVICE
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
|
|
|
|
|
2025-12-22 04:30:50 +01:00
|
|
|
class MessagingService(BaseService):
|
2025-12-13 00:00:52 +01:00
|
|
|
"""
|
|
|
|
|
Service for managing all messaging operations.
|
|
|
|
|
|
|
|
|
|
Provides:
|
|
|
|
|
- Channel CRUD with access control
|
|
|
|
|
- Group management within channels
|
|
|
|
|
- Session lifecycle with automatic boundaries
|
|
|
|
|
- Message CRUD with edit history
|
|
|
|
|
|
|
|
|
|
Usage:
|
|
|
|
|
service = MessagingService(db_session)
|
|
|
|
|
|
|
|
|
|
# Create channel
|
|
|
|
|
channel = await service.create_channel(ChannelCreateRequest(...))
|
|
|
|
|
|
|
|
|
|
# Send message (handles session automatically)
|
|
|
|
|
message = await service.send_message(MessageCreateRequest(...))
|
|
|
|
|
"""
|
|
|
|
|
|
2025-12-22 04:30:50 +01:00
|
|
|
service_name: ClassVar[str] = "messaging"
|
2025-12-30 02:35:58 +01:00
|
|
|
_background_tasks: ClassVar[set[asyncio.Task[Any]]] = set()
|
2025-12-13 00:00:52 +01:00
|
|
|
|
|
|
|
|
# =========================================================================
|
|
|
|
|
# CHANNEL OPERATIONS (TASK-013)
|
|
|
|
|
# =========================================================================
|
|
|
|
|
|
|
|
|
|
async def create_channel(self, req: ChannelCreateRequest) -> ChannelTable:
|
|
|
|
|
"""
|
|
|
|
|
Create a new channel.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
req: Channel creation request
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
Created channel
|
|
|
|
|
|
|
|
|
|
Raises:
|
|
|
|
|
ValueError: If slug already exists
|
|
|
|
|
"""
|
|
|
|
|
# Check slug uniqueness
|
|
|
|
|
existing = await self.session.execute(
|
|
|
|
|
select(ChannelTable).where(ChannelTable.slug == req.slug)
|
|
|
|
|
)
|
|
|
|
|
if existing.scalar_one_or_none():
|
|
|
|
|
raise ValueError(f"Channel with slug '{req.slug}' already exists")
|
|
|
|
|
|
|
|
|
|
channel = ChannelTable(
|
|
|
|
|
name=req.name,
|
|
|
|
|
slug=req.slug,
|
|
|
|
|
type=req.channel_type,
|
|
|
|
|
description=req.description,
|
|
|
|
|
members=list(req.members) if req.members else [],
|
|
|
|
|
writers=list(req.writers) if req.writers else [],
|
|
|
|
|
silent_observers=list(req.silent_observers) if req.silent_observers else [],
|
|
|
|
|
is_private=req.is_private,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
self.session.add(channel)
|
|
|
|
|
await self.session.flush()
|
|
|
|
|
|
|
|
|
|
self.log.info(
|
|
|
|
|
"Channel created",
|
|
|
|
|
channel_id=str(channel.id),
|
|
|
|
|
slug=req.slug,
|
|
|
|
|
type=req.channel_type.value,
|
|
|
|
|
)
|
|
|
|
|
return channel
|
|
|
|
|
|
|
|
|
|
async def get_channel(self, channel_id: UUID) -> ChannelTable | None:
|
|
|
|
|
"""Get a channel by ID."""
|
|
|
|
|
result = await self.session.execute(
|
|
|
|
|
select(ChannelTable).where(ChannelTable.id == channel_id)
|
|
|
|
|
)
|
|
|
|
|
return result.scalar_one_or_none()
|
|
|
|
|
|
|
|
|
|
async def get_channel_by_slug(self, slug: str) -> ChannelTable | None:
|
|
|
|
|
"""Get a channel by slug."""
|
|
|
|
|
result = await self.session.execute(
|
|
|
|
|
select(ChannelTable).where(ChannelTable.slug == slug)
|
|
|
|
|
)
|
|
|
|
|
return result.scalar_one_or_none()
|
|
|
|
|
|
2025-12-24 23:05:26 +01:00
|
|
|
async def get_or_create_channel_by_slug(self, slug: str) -> ChannelTable | None:
|
|
|
|
|
"""Get a channel by slug, auto-creating from config if needed.
|
|
|
|
|
|
|
|
|
|
If the channel doesn't exist in the database but is defined in
|
|
|
|
|
DEFAULT_CHANNELS, it will be automatically created.
|
|
|
|
|
|
|
|
|
|
This allows the system to work without requiring explicit database
|
|
|
|
|
seeding while still validating that only configured channels are used.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
slug: Channel slug to look up
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
Channel if found or created, None if not a valid channel
|
|
|
|
|
"""
|
|
|
|
|
# First try database
|
|
|
|
|
channel = await self.get_channel_by_slug(slug)
|
|
|
|
|
if channel:
|
|
|
|
|
return channel
|
|
|
|
|
|
|
|
|
|
# Not in DB - check if it's a valid channel from config
|
|
|
|
|
from roboco.models.base import ChannelType
|
|
|
|
|
from roboco.seeds import DEFAULT_CHANNELS
|
|
|
|
|
|
|
|
|
|
channel_data = next(
|
|
|
|
|
(c for c in DEFAULT_CHANNELS if c["slug"] == slug),
|
|
|
|
|
None,
|
|
|
|
|
)
|
|
|
|
|
if not channel_data:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
# Auto-create from config
|
|
|
|
|
channel = ChannelTable(
|
|
|
|
|
name=channel_data["name"],
|
|
|
|
|
slug=channel_data["slug"],
|
|
|
|
|
type=ChannelType(channel_data["channel_type"]),
|
|
|
|
|
description=channel_data.get("description", ""),
|
|
|
|
|
)
|
|
|
|
|
self.session.add(channel)
|
|
|
|
|
await self.session.flush()
|
|
|
|
|
|
|
|
|
|
self.log.info(
|
|
|
|
|
"Channel auto-created from config",
|
|
|
|
|
slug=slug,
|
|
|
|
|
type=channel_data["channel_type"],
|
|
|
|
|
)
|
|
|
|
|
return channel
|
|
|
|
|
|
2025-12-13 00:00:52 +01:00
|
|
|
async def list_channels_for_agent(
|
|
|
|
|
self,
|
|
|
|
|
agent_id: UUID,
|
|
|
|
|
include_archived: bool = False,
|
|
|
|
|
) -> list[ChannelTable]:
|
|
|
|
|
"""List channels an agent can access (member or silent observer)."""
|
|
|
|
|
query = select(ChannelTable).where(
|
|
|
|
|
(ChannelTable.members.contains([agent_id]))
|
|
|
|
|
| (ChannelTable.silent_observers.contains([agent_id]))
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if not include_archived:
|
|
|
|
|
query = query.where(ChannelTable.is_archived.is_(False))
|
|
|
|
|
|
|
|
|
|
query = query.order_by(ChannelTable.name)
|
|
|
|
|
result = await self.session.execute(query)
|
|
|
|
|
return list(result.scalars().all())
|
|
|
|
|
|
|
|
|
|
async def add_channel_member(
|
|
|
|
|
self,
|
|
|
|
|
channel_id: UUID,
|
|
|
|
|
agent_id: UUID,
|
|
|
|
|
can_write: bool = True,
|
|
|
|
|
) -> ChannelTable:
|
|
|
|
|
"""Add a member to a channel."""
|
|
|
|
|
channel = await self.get_channel(channel_id)
|
|
|
|
|
if not channel:
|
|
|
|
|
raise ValueError(f"Channel {channel_id} not found")
|
|
|
|
|
|
|
|
|
|
# Add to members
|
|
|
|
|
if agent_id not in channel.members:
|
2025-12-13 13:25:37 +01:00
|
|
|
channel.members = [*channel.members, agent_id]
|
2025-12-13 00:00:52 +01:00
|
|
|
|
|
|
|
|
# Add to writers if requested
|
|
|
|
|
if can_write and agent_id not in channel.writers:
|
2025-12-13 13:25:37 +01:00
|
|
|
channel.writers = [*channel.writers, agent_id]
|
2025-12-13 00:00:52 +01:00
|
|
|
|
|
|
|
|
await self.session.flush()
|
|
|
|
|
|
|
|
|
|
self.log.info(
|
|
|
|
|
"Member added to channel",
|
|
|
|
|
channel_id=str(channel_id),
|
|
|
|
|
agent_id=str(agent_id),
|
|
|
|
|
can_write=can_write,
|
|
|
|
|
)
|
|
|
|
|
return channel
|
|
|
|
|
|
|
|
|
|
async def remove_channel_member(
|
|
|
|
|
self,
|
|
|
|
|
channel_id: UUID,
|
|
|
|
|
agent_id: UUID,
|
|
|
|
|
) -> ChannelTable:
|
|
|
|
|
"""Remove a member from a channel."""
|
|
|
|
|
channel = await self.get_channel(channel_id)
|
|
|
|
|
if not channel:
|
|
|
|
|
raise ValueError(f"Channel {channel_id} not found")
|
|
|
|
|
|
|
|
|
|
channel.members = [m for m in channel.members if m != agent_id]
|
|
|
|
|
channel.writers = [w for w in channel.writers if w != agent_id]
|
|
|
|
|
|
|
|
|
|
await self.session.flush()
|
|
|
|
|
|
|
|
|
|
self.log.info(
|
|
|
|
|
"Member removed from channel",
|
|
|
|
|
channel_id=str(channel_id),
|
|
|
|
|
agent_id=str(agent_id),
|
|
|
|
|
)
|
|
|
|
|
return channel
|
|
|
|
|
|
|
|
|
|
async def archive_channel(self, channel_id: UUID) -> ChannelTable:
|
|
|
|
|
"""Archive a channel."""
|
|
|
|
|
channel = await self.get_channel(channel_id)
|
|
|
|
|
if not channel:
|
|
|
|
|
raise ValueError(f"Channel {channel_id} not found")
|
|
|
|
|
|
|
|
|
|
channel.is_archived = True
|
|
|
|
|
await self.session.flush()
|
|
|
|
|
|
|
|
|
|
self.log.info("Channel archived", channel_id=str(channel_id))
|
|
|
|
|
return channel
|
|
|
|
|
|
|
|
|
|
# =========================================================================
|
|
|
|
|
# GROUP OPERATIONS
|
|
|
|
|
# =========================================================================
|
|
|
|
|
|
|
|
|
|
async def create_group(self, req: GroupCreateRequest) -> GroupTable:
|
|
|
|
|
"""Create a group within a channel."""
|
|
|
|
|
# Verify channel exists
|
|
|
|
|
channel = await self.get_channel(req.channel_id)
|
|
|
|
|
if not channel:
|
|
|
|
|
raise ValueError(f"Channel {req.channel_id} not found")
|
|
|
|
|
|
|
|
|
|
group = GroupTable(
|
|
|
|
|
name=req.name,
|
|
|
|
|
channel_id=req.channel_id,
|
|
|
|
|
allowed_roles=list(req.allowed_roles) if req.allowed_roles else [],
|
|
|
|
|
hierarchy_level=req.hierarchy_level,
|
|
|
|
|
members=list(req.members) if req.members else [],
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
self.session.add(group)
|
|
|
|
|
|
|
|
|
|
# Update channel group count
|
|
|
|
|
channel.group_count += 1
|
|
|
|
|
|
|
|
|
|
await self.session.flush()
|
|
|
|
|
|
|
|
|
|
self.log.info(
|
|
|
|
|
"Group created",
|
|
|
|
|
group_id=str(group.id),
|
|
|
|
|
channel_id=str(req.channel_id),
|
|
|
|
|
name=req.name,
|
|
|
|
|
)
|
|
|
|
|
return group
|
|
|
|
|
|
|
|
|
|
async def get_group(self, group_id: UUID) -> GroupTable | None:
|
|
|
|
|
"""Get a group by ID."""
|
|
|
|
|
result = await self.session.execute(
|
|
|
|
|
select(GroupTable).where(GroupTable.id == group_id)
|
|
|
|
|
)
|
|
|
|
|
return result.scalar_one_or_none()
|
|
|
|
|
|
|
|
|
|
async def list_groups_in_channel(self, channel_id: UUID) -> list[GroupTable]:
|
|
|
|
|
"""List all groups in a channel."""
|
|
|
|
|
result = await self.session.execute(
|
|
|
|
|
select(GroupTable)
|
|
|
|
|
.where(GroupTable.channel_id == channel_id)
|
|
|
|
|
.order_by(GroupTable.hierarchy_level, GroupTable.name)
|
|
|
|
|
)
|
|
|
|
|
return list(result.scalars().all())
|
|
|
|
|
|
|
|
|
|
# =========================================================================
|
|
|
|
|
# SESSION OPERATIONS (TASK-015)
|
|
|
|
|
# =========================================================================
|
|
|
|
|
|
|
|
|
|
async def create_session(self, req: SessionCreateRequest) -> SessionTable:
|
|
|
|
|
"""
|
|
|
|
|
Create a new session in a group.
|
|
|
|
|
|
|
|
|
|
Sets the session as the group's active session.
|
|
|
|
|
"""
|
|
|
|
|
# Verify group exists
|
|
|
|
|
group = await self.get_group(req.group_id)
|
|
|
|
|
if not group:
|
|
|
|
|
raise ValueError(f"Group {req.group_id} not found")
|
|
|
|
|
|
|
|
|
|
# Close existing active session if any
|
|
|
|
|
if group.active_session_id:
|
2025-12-13 05:50:34 +01:00
|
|
|
session_id = cast("UUID", group.active_session_id)
|
|
|
|
|
await self.close_session(session_id, "New session started")
|
2025-12-13 00:00:52 +01:00
|
|
|
|
|
|
|
|
session = SessionTable(
|
|
|
|
|
group_id=req.group_id,
|
|
|
|
|
max_message_count=req.max_message_count,
|
|
|
|
|
max_content_length=req.max_content_length,
|
|
|
|
|
timeout_seconds=req.timeout_seconds,
|
|
|
|
|
status=SessionStatus.ACTIVE,
|
2025-12-22 18:04:31 +01:00
|
|
|
scope=req.scope,
|
2025-12-13 00:00:52 +01:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
self.session.add(session)
|
|
|
|
|
|
|
|
|
|
# Update group
|
|
|
|
|
group.active_session_id = session.id
|
|
|
|
|
group.total_sessions += 1
|
|
|
|
|
group.last_activity = datetime.now(UTC)
|
|
|
|
|
|
|
|
|
|
await self.session.flush()
|
|
|
|
|
|
|
|
|
|
# Publish event
|
|
|
|
|
try:
|
|
|
|
|
bus = get_event_bus()
|
|
|
|
|
if bus._redis:
|
|
|
|
|
await bus.publish(
|
|
|
|
|
Event(
|
|
|
|
|
type=EventType.SESSION_CREATED,
|
|
|
|
|
data={
|
|
|
|
|
"session_id": str(session.id),
|
|
|
|
|
"group_id": str(req.group_id),
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
self.log.warning("Failed to publish session event", error=str(e))
|
|
|
|
|
|
|
|
|
|
self.log.info(
|
|
|
|
|
"Session created",
|
|
|
|
|
session_id=str(session.id),
|
|
|
|
|
group_id=str(req.group_id),
|
|
|
|
|
)
|
|
|
|
|
return session
|
|
|
|
|
|
|
|
|
|
async def get_session(self, session_id: UUID) -> SessionTable | None:
|
|
|
|
|
"""Get a session by ID."""
|
|
|
|
|
result = await self.session.execute(
|
|
|
|
|
select(SessionTable).where(SessionTable.id == session_id)
|
|
|
|
|
)
|
|
|
|
|
return result.scalar_one_or_none()
|
|
|
|
|
|
|
|
|
|
async def close_session(
|
|
|
|
|
self,
|
|
|
|
|
session_id: UUID,
|
|
|
|
|
reason: str = "Manual close",
|
|
|
|
|
) -> SessionTable | None:
|
|
|
|
|
"""Close a session."""
|
|
|
|
|
session = await self.get_session(session_id)
|
|
|
|
|
if not session:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
if session.status != SessionStatus.ACTIVE:
|
|
|
|
|
return session # Already closed
|
|
|
|
|
|
|
|
|
|
session.status = SessionStatus.CLOSED
|
|
|
|
|
session.closed_at = datetime.now(UTC)
|
|
|
|
|
|
|
|
|
|
# Clear group's active session
|
2025-12-13 05:50:34 +01:00
|
|
|
group = await self.get_group(cast("UUID", session.group_id))
|
2025-12-13 00:00:52 +01:00
|
|
|
if group and group.active_session_id == session_id:
|
|
|
|
|
group.active_session_id = None
|
|
|
|
|
|
|
|
|
|
await self.session.flush()
|
|
|
|
|
|
|
|
|
|
# Publish event
|
|
|
|
|
try:
|
|
|
|
|
bus = get_event_bus()
|
|
|
|
|
if bus._redis:
|
|
|
|
|
await bus.publish(
|
|
|
|
|
Event(
|
|
|
|
|
type=EventType.SESSION_CLOSED,
|
|
|
|
|
data={
|
|
|
|
|
"session_id": str(session_id),
|
|
|
|
|
"reason": reason,
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
self.log.warning("Failed to publish session event", error=str(e))
|
|
|
|
|
|
|
|
|
|
self.log.info(
|
|
|
|
|
"Session closed",
|
|
|
|
|
session_id=str(session_id),
|
|
|
|
|
reason=reason,
|
|
|
|
|
)
|
|
|
|
|
return session
|
|
|
|
|
|
|
|
|
|
async def get_or_create_active_session(
|
|
|
|
|
self,
|
|
|
|
|
group_id: UUID,
|
|
|
|
|
) -> SessionTable:
|
|
|
|
|
"""Get the active session for a group, or create one if none exists."""
|
|
|
|
|
group = await self.get_group(group_id)
|
|
|
|
|
if not group:
|
|
|
|
|
raise ValueError(f"Group {group_id} not found")
|
|
|
|
|
|
|
|
|
|
# Return active session if exists
|
|
|
|
|
if group.active_session_id:
|
2025-12-13 05:50:34 +01:00
|
|
|
session = await self.get_session(cast("UUID", group.active_session_id))
|
2025-12-13 00:00:52 +01:00
|
|
|
if session and session.status == SessionStatus.ACTIVE:
|
|
|
|
|
return session
|
|
|
|
|
|
|
|
|
|
# Create new session
|
|
|
|
|
return await self.create_session(SessionCreateRequest(group_id=group_id))
|
|
|
|
|
|
2025-12-22 18:04:31 +01:00
|
|
|
# =========================================================================
|
|
|
|
|
# SESSION-TASK LINKING OPERATIONS
|
|
|
|
|
# =========================================================================
|
|
|
|
|
|
|
|
|
|
async def link_session_to_task(
|
|
|
|
|
self,
|
|
|
|
|
session_id: UUID,
|
|
|
|
|
task_id: UUID,
|
|
|
|
|
added_by: UUID,
|
|
|
|
|
is_primary: bool = False,
|
|
|
|
|
relationship_type: SessionTaskRelationshipType = (
|
|
|
|
|
SessionTaskRelationshipType.DISCUSSION
|
|
|
|
|
),
|
|
|
|
|
) -> SessionTaskTable:
|
|
|
|
|
"""
|
|
|
|
|
Link a session to a task.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
session_id: Session to link
|
|
|
|
|
task_id: Task to link
|
|
|
|
|
added_by: PM who created this link
|
|
|
|
|
is_primary: Mark as primary discussion session for this task
|
|
|
|
|
relationship_type: Type of relationship
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
Created link
|
|
|
|
|
|
|
|
|
|
Raises:
|
|
|
|
|
NotFoundError: If session not found
|
|
|
|
|
ConflictError: If link already exists or primary constraint violated
|
|
|
|
|
"""
|
|
|
|
|
# Verify session exists
|
|
|
|
|
session = await self.get_session(session_id)
|
|
|
|
|
if not session:
|
|
|
|
|
raise NotFoundError(f"Session {session_id} not found")
|
|
|
|
|
|
|
|
|
|
# Check if link already exists
|
|
|
|
|
existing = await self.session.execute(
|
|
|
|
|
select(SessionTaskTable).where(
|
|
|
|
|
SessionTaskTable.session_id == session_id,
|
|
|
|
|
SessionTaskTable.task_id == task_id,
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
if existing.scalar_one_or_none():
|
|
|
|
|
raise ConflictError(
|
|
|
|
|
f"Session {session_id} is already linked to task {task_id}"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# If marking as primary, check if task already has a primary session
|
|
|
|
|
if is_primary:
|
|
|
|
|
existing_primary = await self.session.execute(
|
|
|
|
|
select(SessionTaskTable).where(
|
|
|
|
|
SessionTaskTable.task_id == task_id,
|
|
|
|
|
SessionTaskTable.is_primary.is_(True),
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
if existing_primary.scalar_one_or_none():
|
|
|
|
|
raise ConflictError(f"Task {task_id} already has a primary session")
|
|
|
|
|
|
2025-12-23 04:39:46 +01:00
|
|
|
# Handle both enum and string (RobocoBase uses use_enum_values=True)
|
|
|
|
|
rel_value = getattr(relationship_type, "value", relationship_type)
|
|
|
|
|
|
2025-12-22 18:04:31 +01:00
|
|
|
link = SessionTaskTable(
|
|
|
|
|
session_id=session_id,
|
|
|
|
|
task_id=task_id,
|
|
|
|
|
is_primary=is_primary,
|
2025-12-23 04:39:46 +01:00
|
|
|
relationship_type=rel_value,
|
2025-12-22 18:04:31 +01:00
|
|
|
added_by=added_by,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
self.session.add(link)
|
|
|
|
|
await self.session.flush()
|
|
|
|
|
|
|
|
|
|
self.log.info(
|
|
|
|
|
"Session linked to task",
|
|
|
|
|
session_id=str(session_id),
|
|
|
|
|
task_id=str(task_id),
|
|
|
|
|
is_primary=is_primary,
|
2025-12-23 04:39:46 +01:00
|
|
|
relationship_type=rel_value,
|
2025-12-22 18:04:31 +01:00
|
|
|
)
|
|
|
|
|
return link
|
|
|
|
|
|
|
|
|
|
async def unlink_session_from_task(
|
|
|
|
|
self,
|
|
|
|
|
session_id: UUID,
|
|
|
|
|
task_id: UUID,
|
|
|
|
|
) -> bool:
|
|
|
|
|
"""
|
|
|
|
|
Remove a session-task link.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
session_id: Session to unlink
|
|
|
|
|
task_id: Task to unlink
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
True if link was removed, False if not found
|
|
|
|
|
"""
|
|
|
|
|
result = await self.session.execute(
|
|
|
|
|
select(SessionTaskTable).where(
|
|
|
|
|
SessionTaskTable.session_id == session_id,
|
|
|
|
|
SessionTaskTable.task_id == task_id,
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
link = result.scalar_one_or_none()
|
|
|
|
|
|
|
|
|
|
if not link:
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
await self.session.delete(link)
|
|
|
|
|
await self.session.flush()
|
|
|
|
|
|
|
|
|
|
self.log.info(
|
|
|
|
|
"Session unlinked from task",
|
|
|
|
|
session_id=str(session_id),
|
|
|
|
|
task_id=str(task_id),
|
|
|
|
|
)
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
async def get_sessions_for_task(
|
|
|
|
|
self,
|
|
|
|
|
task_id: UUID,
|
|
|
|
|
relationship_type: SessionTaskRelationshipType | None = None,
|
|
|
|
|
) -> list[SessionTaskTable]:
|
|
|
|
|
"""
|
|
|
|
|
Get all sessions linked to a task.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
task_id: Task to get sessions for
|
|
|
|
|
relationship_type: Filter by relationship type
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
List of session-task links (with session→group→channel loaded)
|
|
|
|
|
"""
|
|
|
|
|
query = (
|
|
|
|
|
select(SessionTaskTable)
|
|
|
|
|
.where(SessionTaskTable.task_id == task_id)
|
|
|
|
|
.options(
|
|
|
|
|
joinedload(SessionTaskTable.session)
|
|
|
|
|
.joinedload(SessionTable.group)
|
|
|
|
|
.joinedload(GroupTable.channel)
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if relationship_type:
|
2025-12-23 04:39:46 +01:00
|
|
|
rel_value = getattr(relationship_type, "value", relationship_type)
|
|
|
|
|
query = query.where(SessionTaskTable.relationship_type == rel_value)
|
2025-12-22 18:04:31 +01:00
|
|
|
|
|
|
|
|
query = query.order_by(SessionTaskTable.added_at.desc())
|
|
|
|
|
result = await self.session.execute(query)
|
|
|
|
|
return list(result.scalars().unique().all())
|
|
|
|
|
|
|
|
|
|
async def get_primary_session_for_task(
|
|
|
|
|
self,
|
|
|
|
|
task_id: UUID,
|
|
|
|
|
) -> SessionTaskTable | None:
|
|
|
|
|
"""
|
|
|
|
|
Get the primary session for a task.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
task_id: Task to get primary session for
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
Primary session-task link, or None if no primary session
|
|
|
|
|
"""
|
|
|
|
|
result = await self.session.execute(
|
|
|
|
|
select(SessionTaskTable).where(
|
|
|
|
|
SessionTaskTable.task_id == task_id,
|
|
|
|
|
SessionTaskTable.is_primary.is_(True),
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
return result.scalar_one_or_none()
|
|
|
|
|
|
|
|
|
|
async def get_tasks_for_session(
|
|
|
|
|
self,
|
|
|
|
|
session_id: UUID,
|
|
|
|
|
) -> list[SessionTaskTable]:
|
|
|
|
|
"""
|
|
|
|
|
Get all tasks linked to a session.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
session_id: Session to get tasks for
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
List of session-task links (with task relationship loaded)
|
|
|
|
|
"""
|
|
|
|
|
result = await self.session.execute(
|
|
|
|
|
select(SessionTaskTable)
|
|
|
|
|
.where(SessionTaskTable.session_id == session_id)
|
|
|
|
|
.order_by(SessionTaskTable.added_at.desc())
|
|
|
|
|
)
|
|
|
|
|
return list(result.scalars().all())
|
|
|
|
|
|
|
|
|
|
async def create_session_for_tasks(
|
|
|
|
|
self,
|
|
|
|
|
req: SessionForTasksCreate,
|
|
|
|
|
pm_agent_id: UUID,
|
|
|
|
|
) -> tuple[SessionTable, list[SessionTaskTable]]:
|
|
|
|
|
"""
|
|
|
|
|
Create a new session linked to one or more tasks (PM operation).
|
|
|
|
|
|
|
|
|
|
This is the main entry point for PMs to create work sessions.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
req: Session creation request with task IDs
|
|
|
|
|
pm_agent_id: PM agent creating the session
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
Tuple of (created session, list of created links)
|
|
|
|
|
|
|
|
|
|
Raises:
|
|
|
|
|
NotFoundError: If channel not found
|
|
|
|
|
ValueError: If no groups found in channel
|
|
|
|
|
"""
|
|
|
|
|
# Get channel
|
|
|
|
|
channel = await self.get_channel_by_slug(req.channel_slug)
|
|
|
|
|
if not channel:
|
|
|
|
|
raise NotFoundError(f"Channel '{req.channel_slug}' not found")
|
|
|
|
|
|
|
|
|
|
# Get first group in channel (or create default)
|
|
|
|
|
groups = await self.list_groups_in_channel(cast("UUID", channel.id))
|
|
|
|
|
if not groups:
|
|
|
|
|
raise ValueError(f"No groups found in channel '{req.channel_slug}'")
|
|
|
|
|
group = groups[0]
|
|
|
|
|
|
|
|
|
|
# Create session with config and scope
|
|
|
|
|
session_req = SessionCreateRequest(
|
|
|
|
|
group_id=cast("UUID", group.id),
|
|
|
|
|
max_message_count=(req.config.max_message_count if req.config else None),
|
|
|
|
|
max_content_length=(req.config.max_content_length if req.config else None),
|
|
|
|
|
timeout_seconds=(req.config.timeout_seconds if req.config else 300),
|
|
|
|
|
scope=req.scope,
|
|
|
|
|
)
|
|
|
|
|
session = await self.create_session(session_req)
|
|
|
|
|
|
|
|
|
|
# Link all tasks
|
|
|
|
|
links: list[SessionTaskTable] = []
|
|
|
|
|
for i, task_id in enumerate(req.task_ids):
|
|
|
|
|
# First task is primary
|
|
|
|
|
is_primary = i == 0
|
|
|
|
|
link = await self.link_session_to_task(
|
|
|
|
|
session_id=cast("UUID", session.id),
|
|
|
|
|
task_id=task_id,
|
|
|
|
|
added_by=pm_agent_id,
|
|
|
|
|
is_primary=is_primary,
|
|
|
|
|
relationship_type=req.relationship_type,
|
|
|
|
|
)
|
|
|
|
|
links.append(link)
|
|
|
|
|
|
|
|
|
|
self.log.info(
|
|
|
|
|
"Session created for tasks",
|
|
|
|
|
session_id=str(session.id),
|
|
|
|
|
task_count=len(req.task_ids),
|
|
|
|
|
channel_slug=req.channel_slug,
|
|
|
|
|
pm_agent_id=str(pm_agent_id),
|
|
|
|
|
)
|
|
|
|
|
return session, links
|
|
|
|
|
|
2025-12-13 00:00:52 +01:00
|
|
|
def _check_session_boundaries(self, session: SessionTable) -> bool:
|
|
|
|
|
"""Check if session has exceeded boundaries. Returns True if should close."""
|
|
|
|
|
# Check message count
|
|
|
|
|
if (
|
|
|
|
|
session.max_message_count
|
|
|
|
|
and session.message_count >= session.max_message_count
|
|
|
|
|
):
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
# Check content length
|
|
|
|
|
return bool(
|
|
|
|
|
session.max_content_length
|
|
|
|
|
and session.total_content_length >= session.max_content_length
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# =========================================================================
|
|
|
|
|
# MESSAGE OPERATIONS (TASK-014)
|
|
|
|
|
# =========================================================================
|
|
|
|
|
|
2025-12-13 17:50:47 +01:00
|
|
|
async def _get_message_context(
|
|
|
|
|
self,
|
|
|
|
|
session_id: UUID,
|
|
|
|
|
) -> tuple[SessionTable, GroupTable, ChannelTable]:
|
|
|
|
|
"""Get session, group, and channel for sending a message."""
|
|
|
|
|
session = await self.get_session(session_id)
|
|
|
|
|
if not session:
|
|
|
|
|
raise ValueError(f"Session {session_id} not found")
|
|
|
|
|
if session.status != SessionStatus.ACTIVE:
|
|
|
|
|
raise ValueError("Session is not active")
|
|
|
|
|
|
|
|
|
|
group = await self.get_group(cast("UUID", session.group_id))
|
|
|
|
|
if not group:
|
|
|
|
|
raise ValueError(f"Group {session.group_id} not found")
|
|
|
|
|
|
|
|
|
|
channel = await self.get_channel(cast("UUID", group.channel_id))
|
|
|
|
|
if not channel:
|
|
|
|
|
raise ValueError(f"Channel {group.channel_id} not found")
|
|
|
|
|
|
|
|
|
|
return session, group, channel
|
|
|
|
|
|
|
|
|
|
async def _validate_reply_target(
|
|
|
|
|
self,
|
|
|
|
|
reply_to: UUID,
|
|
|
|
|
session_id: UUID,
|
|
|
|
|
) -> None:
|
|
|
|
|
"""Validate reply target exists in session."""
|
|
|
|
|
reply_msg = await self.get_message(reply_to)
|
|
|
|
|
if not reply_msg or reply_msg.session_id != session_id:
|
|
|
|
|
raise ValueError("Reply target not found in this session")
|
|
|
|
|
|
|
|
|
|
def _update_message_stats(
|
|
|
|
|
self,
|
|
|
|
|
session: SessionTable,
|
|
|
|
|
group: GroupTable,
|
|
|
|
|
channel: ChannelTable,
|
|
|
|
|
content_length: int,
|
|
|
|
|
) -> None:
|
|
|
|
|
"""Update statistics after sending a message."""
|
|
|
|
|
now = datetime.now(UTC)
|
|
|
|
|
session.message_count += 1
|
|
|
|
|
session.total_content_length += content_length
|
|
|
|
|
session.last_activity_at = now
|
|
|
|
|
group.total_messages += 1
|
|
|
|
|
group.last_activity = now
|
|
|
|
|
channel.message_count += 1
|
|
|
|
|
channel.last_activity = now
|
|
|
|
|
|
2025-12-28 19:52:01 +01:00
|
|
|
async def _notify_mentions(
|
|
|
|
|
self,
|
|
|
|
|
message: MessageTable,
|
|
|
|
|
sender_id: UUID,
|
|
|
|
|
channel_slug: str,
|
|
|
|
|
) -> None:
|
|
|
|
|
"""
|
|
|
|
|
Create and deliver notifications for mentioned agents.
|
|
|
|
|
|
|
|
|
|
Publishes NOTIFICATION_SENT events to Redis Streams for real-time
|
|
|
|
|
delivery via the WebSocket bridge.
|
|
|
|
|
"""
|
|
|
|
|
if not message.mentions:
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
# Lazy import to avoid circular dependency
|
2025-12-29 00:30:18 +01:00
|
|
|
from roboco.services.notification_delivery import (
|
|
|
|
|
get_notification_delivery_service,
|
|
|
|
|
)
|
2025-12-28 19:52:01 +01:00
|
|
|
|
|
|
|
|
delivery_service = get_notification_delivery_service(self.session)
|
|
|
|
|
|
|
|
|
|
for mentioned_id in message.mentions:
|
|
|
|
|
# Don't notify yourself
|
|
|
|
|
if mentioned_id == sender_id:
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
# Create mention notification
|
|
|
|
|
notification = NotificationTable(
|
|
|
|
|
type=NotificationType.MENTION,
|
|
|
|
|
priority=NotificationPriority.NORMAL,
|
|
|
|
|
from_agent=sender_id,
|
|
|
|
|
to_agents=[mentioned_id],
|
|
|
|
|
subject=f"You were mentioned in #{channel_slug}",
|
|
|
|
|
body=message.content[:500], # Truncate for notification
|
|
|
|
|
related_task_id=message.task_id,
|
|
|
|
|
)
|
|
|
|
|
self.session.add(notification)
|
|
|
|
|
await self.session.flush()
|
|
|
|
|
|
|
|
|
|
# Deliver via Redis Streams -> WebSocket
|
|
|
|
|
await delivery_service.deliver(notification.id)
|
|
|
|
|
|
|
|
|
|
self.log.debug(
|
|
|
|
|
"Mention notification sent",
|
|
|
|
|
mentioned_id=str(mentioned_id),
|
|
|
|
|
message_id=str(message.id),
|
|
|
|
|
)
|
|
|
|
|
|
2025-12-30 02:35:58 +01:00
|
|
|
async def _index_message_async(self, message: MessageTable) -> None:
|
|
|
|
|
"""Index message in RAG system (fire-and-forget)."""
|
|
|
|
|
from roboco.models.optimal import IndexConversationParams
|
|
|
|
|
from roboco.services.optimal import get_optimal_service
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
optimal = await get_optimal_service()
|
|
|
|
|
await optimal.index_conversation(
|
|
|
|
|
IndexConversationParams(
|
|
|
|
|
content=message.content,
|
|
|
|
|
channel_id=message.channel_id,
|
|
|
|
|
session_id=message.session_id,
|
|
|
|
|
agent_id=message.agent_id,
|
|
|
|
|
task_id=message.task_id,
|
|
|
|
|
message_type=message.type.value if message.type else None,
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
self.log.debug("Message indexed", message_id=str(message.id))
|
|
|
|
|
except Exception as e:
|
|
|
|
|
self.log.warning(
|
|
|
|
|
"Failed to index message",
|
|
|
|
|
message_id=str(message.id),
|
|
|
|
|
error=str(e),
|
|
|
|
|
)
|
|
|
|
|
|
2025-12-13 00:00:52 +01:00
|
|
|
async def send_message(
|
|
|
|
|
self,
|
|
|
|
|
req: MessageCreateRequest,
|
|
|
|
|
agent_slug: str | None = None,
|
|
|
|
|
) -> MessageTable:
|
|
|
|
|
"""
|
|
|
|
|
Send a message to a session.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
req: Message creation request
|
|
|
|
|
agent_slug: Agent slug for channel access validation (optional)
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
Created message
|
|
|
|
|
|
|
|
|
|
Raises:
|
|
|
|
|
ValueError: If session not found or not active
|
|
|
|
|
ChannelAccessDeniedError: If agent cannot write to channel
|
|
|
|
|
"""
|
2025-12-13 17:50:47 +01:00
|
|
|
session, group, channel = await self._get_message_context(req.session_id)
|
2025-12-13 00:00:52 +01:00
|
|
|
|
|
|
|
|
if agent_slug:
|
|
|
|
|
validate_channel_access(agent_slug, channel.slug, "write")
|
|
|
|
|
if req.reply_to:
|
2025-12-13 17:50:47 +01:00
|
|
|
await self._validate_reply_target(req.reply_to, req.session_id)
|
2025-12-13 00:00:52 +01:00
|
|
|
|
|
|
|
|
content_length = len(req.content)
|
|
|
|
|
message = MessageTable(
|
|
|
|
|
agent_id=req.agent_id,
|
|
|
|
|
channel_id=channel.id,
|
|
|
|
|
group_id=group.id,
|
|
|
|
|
session_id=session.id,
|
|
|
|
|
type=req.message_type,
|
|
|
|
|
content=req.content,
|
|
|
|
|
content_length=content_length,
|
|
|
|
|
is_reply=req.reply_to is not None,
|
|
|
|
|
reply_to=req.reply_to,
|
|
|
|
|
mentions=list(req.mentions) if req.mentions else [],
|
|
|
|
|
task_id=req.task_id,
|
|
|
|
|
commit_ref=req.commit_ref,
|
|
|
|
|
)
|
|
|
|
|
self.session.add(message)
|
2025-12-13 17:50:47 +01:00
|
|
|
self._update_message_stats(session, group, channel, content_length)
|
2025-12-13 00:00:52 +01:00
|
|
|
await self.session.flush()
|
|
|
|
|
|
2025-12-28 19:52:01 +01:00
|
|
|
# Notify mentioned agents via Redis Streams
|
|
|
|
|
await self._notify_mentions(message, req.agent_id, channel.slug)
|
|
|
|
|
|
2025-12-30 02:35:58 +01:00
|
|
|
# Index message in RAG (fire-and-forget)
|
|
|
|
|
bg_task = asyncio.create_task(self._index_message_async(message))
|
|
|
|
|
self._background_tasks.add(bg_task)
|
|
|
|
|
bg_task.add_done_callback(self._background_tasks.discard)
|
|
|
|
|
|
2025-12-13 00:00:52 +01:00
|
|
|
if self._check_session_boundaries(session):
|
2025-12-13 05:50:34 +01:00
|
|
|
await self.close_session(cast("UUID", session.id), "Boundary exceeded")
|
2025-12-13 00:00:52 +01:00
|
|
|
|
|
|
|
|
self.log.info(
|
|
|
|
|
"Message sent",
|
|
|
|
|
message_id=str(message.id),
|
|
|
|
|
session_id=str(session.id),
|
|
|
|
|
agent_id=str(req.agent_id),
|
|
|
|
|
type=req.message_type.value,
|
|
|
|
|
)
|
|
|
|
|
return message
|
|
|
|
|
|
|
|
|
|
async def get_message(self, message_id: UUID) -> MessageTable | None:
|
|
|
|
|
"""Get a message by ID."""
|
|
|
|
|
result = await self.session.execute(
|
|
|
|
|
select(MessageTable).where(MessageTable.id == message_id)
|
|
|
|
|
)
|
|
|
|
|
return result.scalar_one_or_none()
|
|
|
|
|
|
|
|
|
|
async def get_messages(
|
|
|
|
|
self,
|
|
|
|
|
session_id: UUID,
|
|
|
|
|
before: datetime | None = None,
|
|
|
|
|
after: datetime | None = None,
|
|
|
|
|
message_type: MessageType | None = None,
|
|
|
|
|
limit: int = 50,
|
|
|
|
|
) -> tuple[list[MessageTable], bool]:
|
|
|
|
|
"""
|
|
|
|
|
Get messages from a session.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
session_id: Session to get messages from
|
|
|
|
|
before: Get messages before this timestamp
|
|
|
|
|
after: Get messages after this timestamp
|
|
|
|
|
message_type: Filter by message type
|
|
|
|
|
limit: Maximum messages to return
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
Tuple of (messages, has_more)
|
|
|
|
|
"""
|
|
|
|
|
query = select(MessageTable).where(MessageTable.session_id == session_id)
|
|
|
|
|
|
|
|
|
|
if before:
|
|
|
|
|
query = query.where(MessageTable.timestamp < before)
|
|
|
|
|
if after:
|
|
|
|
|
query = query.where(MessageTable.timestamp > after)
|
|
|
|
|
if message_type:
|
|
|
|
|
query = query.where(MessageTable.type == message_type)
|
|
|
|
|
|
|
|
|
|
# Get one extra to check if there are more
|
|
|
|
|
query = query.order_by(MessageTable.timestamp.desc()).limit(limit + 1)
|
|
|
|
|
|
|
|
|
|
result = await self.session.execute(query)
|
|
|
|
|
messages = list(result.scalars().all())
|
|
|
|
|
|
|
|
|
|
has_more = len(messages) > limit
|
|
|
|
|
if has_more:
|
|
|
|
|
messages = messages[:limit]
|
|
|
|
|
|
|
|
|
|
return messages, has_more
|
|
|
|
|
|
|
|
|
|
async def edit_message(
|
|
|
|
|
self,
|
|
|
|
|
message_id: UUID,
|
|
|
|
|
agent_id: UUID,
|
|
|
|
|
new_content: str,
|
|
|
|
|
edit_reason: str | None = None,
|
|
|
|
|
) -> MessageTable:
|
|
|
|
|
"""
|
|
|
|
|
Edit a message.
|
|
|
|
|
|
|
|
|
|
Only the author can edit their own messages.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
message_id: Message to edit
|
|
|
|
|
agent_id: Agent requesting the edit (must be author)
|
|
|
|
|
new_content: New content
|
|
|
|
|
edit_reason: Optional reason for the edit
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
Updated message
|
|
|
|
|
|
|
|
|
|
Raises:
|
|
|
|
|
ValueError: If message not found or agent is not author
|
|
|
|
|
"""
|
|
|
|
|
message = await self.get_message(message_id)
|
|
|
|
|
if not message:
|
|
|
|
|
raise ValueError(f"Message {message_id} not found")
|
|
|
|
|
|
|
|
|
|
if message.agent_id != agent_id:
|
|
|
|
|
raise ValueError("Only the author can edit this message")
|
|
|
|
|
|
|
|
|
|
# Store edit history
|
|
|
|
|
edit_entry = {
|
|
|
|
|
"edited_at": datetime.now(UTC).isoformat(),
|
|
|
|
|
"previous_content": message.content,
|
|
|
|
|
"edit_reason": edit_reason,
|
|
|
|
|
}
|
|
|
|
|
message.edit_history = [*message.edit_history, edit_entry]
|
|
|
|
|
|
|
|
|
|
# Calculate content length delta
|
|
|
|
|
old_length = message.content_length
|
|
|
|
|
new_length = len(new_content)
|
|
|
|
|
delta = new_length - old_length
|
|
|
|
|
|
|
|
|
|
# Update message
|
|
|
|
|
message.content = new_content
|
|
|
|
|
message.content_length = new_length
|
|
|
|
|
message.edited_at = datetime.now(UTC)
|
|
|
|
|
|
|
|
|
|
# Update session total content length
|
2025-12-13 05:50:34 +01:00
|
|
|
session = await self.get_session(cast("UUID", message.session_id))
|
2025-12-13 00:00:52 +01:00
|
|
|
if session:
|
|
|
|
|
session.total_content_length += delta
|
|
|
|
|
|
|
|
|
|
await self.session.flush()
|
|
|
|
|
|
|
|
|
|
self.log.info(
|
|
|
|
|
"Message edited",
|
|
|
|
|
message_id=str(message_id),
|
|
|
|
|
agent_id=str(agent_id),
|
|
|
|
|
)
|
|
|
|
|
return message
|
|
|
|
|
|
|
|
|
|
async def delete_message(
|
|
|
|
|
self,
|
|
|
|
|
message_id: UUID,
|
|
|
|
|
agent_id: UUID,
|
|
|
|
|
) -> bool:
|
|
|
|
|
"""
|
|
|
|
|
Soft delete a message.
|
|
|
|
|
|
|
|
|
|
Only the author can delete their own messages.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
message_id: Message to delete
|
|
|
|
|
agent_id: Agent requesting deletion (must be author)
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
True if deleted
|
|
|
|
|
|
|
|
|
|
Raises:
|
|
|
|
|
ValueError: If message not found or agent is not author
|
|
|
|
|
"""
|
|
|
|
|
message = await self.get_message(message_id)
|
|
|
|
|
if not message:
|
|
|
|
|
raise ValueError(f"Message {message_id} not found")
|
|
|
|
|
|
|
|
|
|
if message.agent_id != agent_id:
|
|
|
|
|
raise ValueError("Only the author can delete this message")
|
|
|
|
|
|
|
|
|
|
# Soft delete - mark content as deleted
|
|
|
|
|
message.content = "[deleted]"
|
|
|
|
|
message.edited_at = datetime.now(UTC)
|
|
|
|
|
|
|
|
|
|
await self.session.flush()
|
|
|
|
|
|
|
|
|
|
self.log.info(
|
|
|
|
|
"Message deleted",
|
|
|
|
|
message_id=str(message_id),
|
|
|
|
|
agent_id=str(agent_id),
|
|
|
|
|
)
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# =============================================================================
|
|
|
|
|
# SERVICE FACTORY
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_messaging_service(session: AsyncSession) -> MessagingService:
|
|
|
|
|
"""Factory function to create a MessagingService instance."""
|
|
|
|
|
return MessagingService(session)
|