extract backend message lifecycle service

This commit is contained in:
Jack Kingsman
2026-03-09 16:56:23 -07:00
parent 9421c10e8f
commit 557af55ee8
5 changed files with 511 additions and 365 deletions
+24 -256
View File
@@ -30,8 +30,6 @@ from app.decoder import (
from app.keystore import get_private_key, get_public_key, has_private_key
from app.models import (
CONTACT_TYPE_REPEATER,
Message,
MessagePath,
RawPacketBroadcast,
RawPacketDecryptedInfo,
)
@@ -43,6 +41,12 @@ from app.repository import (
MessageRepository,
RawPacketRepository,
)
from app.services.messages import (
create_dm_message_from_decrypted as _create_dm_message_from_decrypted,
)
from app.services.messages import (
create_message_from_decrypted as _create_message_from_decrypted,
)
from app.websocket import broadcast_error, broadcast_event
logger = logging.getLogger(__name__)
@@ -50,77 +54,6 @@ logger = logging.getLogger(__name__)
_raw_observation_counter = count(1)
async def _handle_duplicate_message(
packet_id: int,
msg_type: str,
conversation_key: str,
text: str,
sender_timestamp: int,
path: str | None,
received: int,
path_len: int | None = None,
) -> None:
"""Handle a duplicate message by updating paths/acks on the existing record.
Called when MessageRepository.create returns None (INSERT OR IGNORE hit a duplicate).
Looks up the existing message, adds the new path, increments ack count for outgoing
messages, and broadcasts the update to clients.
"""
existing_msg = await MessageRepository.get_by_content(
msg_type=msg_type,
conversation_key=conversation_key,
text=text,
sender_timestamp=sender_timestamp,
)
if not existing_msg:
label = "message" if msg_type == "CHAN" else "DM"
logger.warning(
"Duplicate %s for %s but couldn't find existing",
label,
conversation_key[:12],
)
return
logger.debug(
"Duplicate %s for %s (msg_id=%d, outgoing=%s) - adding path",
msg_type,
conversation_key[:12],
existing_msg.id,
existing_msg.outgoing,
)
# Add path if provided
if path is not None:
paths = await MessageRepository.add_path(existing_msg.id, path, received, path_len)
else:
# Get current paths for broadcast
paths = existing_msg.paths or []
# Increment ack count for outgoing messages (echo confirmation)
if existing_msg.outgoing:
ack_count = await MessageRepository.increment_ack_count(existing_msg.id)
else:
ack_count = existing_msg.acked
# Only broadcast when something actually changed:
# - outgoing: ack count was incremented
# - path provided: a new path entry was appended
# The path=None case happens for direct-delivery DMs (0-hop, no routing bytes).
# A non-outgoing duplicate with no new path changes nothing in the DB, so skip.
if existing_msg.outgoing or path is not None:
broadcast_event(
"message_acked",
{
"message_id": existing_msg.id,
"ack_count": ack_count,
"paths": [p.model_dump() for p in paths] if paths else [],
},
)
# Mark this packet as decrypted
await RawPacketRepository.mark_decrypted(packet_id, existing_msg.id)
async def create_message_from_decrypted(
packet_id: int,
channel_key: str,
@@ -133,95 +66,21 @@ async def create_message_from_decrypted(
channel_name: str | None = None,
realtime: bool = True,
) -> int | None:
"""Create a message record from decrypted channel packet content.
This is the shared logic for storing decrypted channel messages,
used by both real-time packet processing and historical decryption.
Args:
packet_id: ID of the raw packet being processed
channel_key: Hex string channel key
channel_name: Channel name (e.g. "#general"), for bot context
sender: Sender name (will be prefixed to message) or None
message_text: The decrypted message content
timestamp: Sender timestamp from the packet
received_at: When the packet was received (defaults to now)
path: Hex-encoded routing path
realtime: If False, skip fanout dispatch (used for historical decryption)
Returns the message ID if created, None if duplicate.
"""
received = received_at or int(time.time())
# Format the message text with sender prefix if present
text = f"{sender}: {message_text}" if sender else message_text
# Normalize channel key to uppercase for consistency
channel_key_normalized = channel_key.upper()
# Resolve sender_key: look up contact by exact name match
resolved_sender_key: str | None = None
if sender:
candidates = await ContactRepository.get_by_name(sender)
if len(candidates) == 1:
resolved_sender_key = candidates[0].public_key
# Try to create message - INSERT OR IGNORE handles duplicates atomically
msg_id = await MessageRepository.create(
msg_type="CHAN",
text=text,
conversation_key=channel_key_normalized,
sender_timestamp=timestamp,
received_at=received,
"""Store a decrypted channel message via the shared message service."""
return await _create_message_from_decrypted(
packet_id=packet_id,
channel_key=channel_key,
sender=sender,
message_text=message_text,
timestamp=timestamp,
received_at=received_at,
path=path,
path_len=path_len,
sender_name=sender,
sender_key=resolved_sender_key,
)
if msg_id is None:
# Duplicate message detected - this happens when:
# 1. Our own outgoing message echoes back (flood routing)
# 2. Same message arrives via multiple paths before first is committed
# In either case, add the path to the existing message.
await _handle_duplicate_message(
packet_id, "CHAN", channel_key_normalized, text, timestamp, path, received, path_len
)
return None
logger.info("Stored channel message %d for channel %s", msg_id, channel_key_normalized[:8])
# Mark the raw packet as decrypted
await RawPacketRepository.mark_decrypted(packet_id, msg_id)
# Build paths array for broadcast
# Use "is not None" to include empty string (direct/0-hop messages)
paths = (
[MessagePath(path=path or "", received_at=received, path_len=path_len)]
if path is not None
else None
)
# Broadcast new message to connected clients (and fanout modules when realtime)
broadcast_event(
"message",
Message(
id=msg_id,
type="CHAN",
conversation_key=channel_key_normalized,
text=text,
sender_timestamp=timestamp,
received_at=received,
paths=paths,
sender_name=sender,
sender_key=resolved_sender_key,
channel_name=channel_name,
).model_dump(),
channel_name=channel_name,
realtime=realtime,
broadcast_fn=broadcast_event,
)
return msg_id
async def create_dm_message_from_decrypted(
packet_id: int,
@@ -234,111 +93,20 @@ async def create_dm_message_from_decrypted(
outgoing: bool = False,
realtime: bool = True,
) -> int | None:
"""Create a message record from decrypted direct message packet content.
This is the shared logic for storing decrypted direct messages,
used by both real-time packet processing and historical decryption.
Args:
packet_id: ID of the raw packet being processed
decrypted: DecryptedDirectMessage from decoder
their_public_key: The contact's full 64-char public key (conversation_key)
our_public_key: Our public key (to determine direction), or None
received_at: When the packet was received (defaults to now)
path: Hex-encoded routing path
outgoing: Whether this is an outgoing message (we sent it)
realtime: If False, skip fanout dispatch (used for historical decryption)
Returns the message ID if created, None if duplicate.
"""
# Check if sender is a repeater - repeaters only send CLI responses, not chat messages.
# CLI responses are handled by the command endpoint, not stored in chat history.
contact = await ContactRepository.get_by_key(their_public_key)
if contact and contact.type == CONTACT_TYPE_REPEATER:
logger.debug(
"Skipping message from repeater %s (CLI responses not stored): %s",
their_public_key[:12],
(decrypted.message or "")[:50],
)
return None
received = received_at or int(time.time())
# conversation_key is always the other party's public key
conversation_key = their_public_key.lower()
# Resolve sender name for incoming messages (used for name-based blocking)
sender_name = contact.name if contact and not outgoing else None
# Try to create message - INSERT OR IGNORE handles duplicates atomically
msg_id = await MessageRepository.create(
msg_type="PRIV",
text=decrypted.message,
conversation_key=conversation_key,
sender_timestamp=decrypted.timestamp,
received_at=received,
"""Store a decrypted direct message via the shared message service."""
return await _create_dm_message_from_decrypted(
packet_id=packet_id,
decrypted=decrypted,
their_public_key=their_public_key,
our_public_key=our_public_key,
received_at=received_at,
path=path,
path_len=path_len,
outgoing=outgoing,
sender_key=conversation_key if not outgoing else None,
sender_name=sender_name,
)
if msg_id is None:
# Duplicate message detected
await _handle_duplicate_message(
packet_id,
"PRIV",
conversation_key,
decrypted.message,
decrypted.timestamp,
path,
received,
path_len,
)
return None
logger.info(
"Stored direct message %d for contact %s (outgoing=%s)",
msg_id,
conversation_key[:12],
outgoing,
)
# Mark the raw packet as decrypted
await RawPacketRepository.mark_decrypted(packet_id, msg_id)
# Build paths array for broadcast
paths = (
[MessagePath(path=path or "", received_at=received, path_len=path_len)]
if path is not None
else None
)
# Broadcast new message to connected clients (and fanout modules when realtime)
sender_name = contact.name if contact and not outgoing else None
broadcast_event(
"message",
Message(
id=msg_id,
type="PRIV",
conversation_key=conversation_key,
text=decrypted.message,
sender_timestamp=decrypted.timestamp,
received_at=received,
paths=paths,
outgoing=outgoing,
sender_name=sender_name,
sender_key=conversation_key if not outgoing else None,
).model_dump(),
realtime=realtime,
broadcast_fn=broadcast_event,
)
# Update contact's last_contacted timestamp (for sorting)
await ContactRepository.update_last_contacted(conversation_key, received)
return msg_id
async def run_historical_dm_decryption(
private_key_bytes: bytes,