diff --git a/Meshflow/ws/serializers.py b/Meshflow/ws/serializers.py index 4a95867..41232af 100644 --- a/Meshflow/ws/serializers.py +++ b/Meshflow/ws/serializers.py @@ -1,5 +1,6 @@ from rest_framework import serializers +from common.protocol import Protocol from nodes.models import ObservedNode from text_messages.models import TextMessage @@ -12,17 +13,27 @@ class Meta: class TextMessageWSSerializer(serializers.ModelSerializer): id = serializers.UUIDField(format="hex", read_only=True) + protocol = serializers.SerializerMethodField() sender = ObservedNodeWSBriefSerializer(read_only=True) channel = serializers.PrimaryKeyRelatedField(read_only=True) sent_at = serializers.DateTimeField(format="%Y-%m-%dT%H:%M:%S.%fZ", read_only=True) original_packet_id = serializers.SerializerMethodField() + original_mc_packet_id = serializers.SerializerMethodField() heard = serializers.SerializerMethodField() + def get_protocol(self, obj): + return Protocol(obj.protocol).label.lower() + + def get_original_mc_packet_id(self, obj): + return str(obj.original_mc_packet_id) if obj.original_mc_packet_id else None + class Meta: model = TextMessage fields = [ "id", + "protocol", "original_packet_id", + "original_mc_packet_id", "sender", "recipient_meshtastic_node_id", "channel", diff --git a/Meshflow/ws/tests/test_text_message_ws_serializer.py b/Meshflow/ws/tests/test_text_message_ws_serializer.py new file mode 100644 index 0000000..f4af7c3 --- /dev/null +++ b/Meshflow/ws/tests/test_text_message_ws_serializer.py @@ -0,0 +1,52 @@ +"""TextMessage WebSocket payload serialization.""" + +from django.utils import timezone + +import pytest + +from common.protocol import Protocol +from constellations.models import MessageChannel +from text_messages.models import TextMessage +from ws.serializers import TextMessageWSSerializer + + +@pytest.mark.django_db +def test_ws_serializer_includes_meshtastic_protocol(create_constellation, create_observed_node): + constellation = create_constellation() + channel = MessageChannel.objects.create(name="LongFast", constellation=constellation) + sender = create_observed_node() + message = TextMessage.objects.create( + protocol=Protocol.MESHTASTIC, + sender=sender, + channel=channel, + sent_at=timezone.now(), + message_text="hello mt", + ) + + data = TextMessageWSSerializer(message).data + + assert data["protocol"] == "meshtastic" + assert data["channel"] == channel.id + + +@pytest.mark.django_db +def test_ws_serializer_includes_meshcore_protocol(create_constellation): + constellation = create_constellation() + channel = MessageChannel.objects.create( + name="Public", + constellation=constellation, + protocol=Protocol.MESHCORE, + ) + message = TextMessage.objects.create( + protocol=Protocol.MESHCORE, + sender=None, + channel=channel, + sent_at=timezone.now(), + message_text="hello mc", + ) + + data = TextMessageWSSerializer(message).data + + assert data["protocol"] == "meshcore" + assert data["channel"] == channel.id + assert data["original_mc_packet_id"] is None diff --git a/docs/features/README.md b/docs/features/README.md index 0f96b9b..89d3b2a 100644 --- a/docs/features/README.md +++ b/docs/features/README.md @@ -88,6 +88,9 @@ the mesh. Because feeders are geographically distributed, Meshflow can show both sides of conversations even when no single radio could hear them all. Realtime updates are pushed over WebSocket. +Reference: [text-messages/](text-messages/) (REST, WS fan-out, unread/badge +contract with meshflow-ui); MeshCore ingest [meshcore/text-message-channels.md](meshcore/text-message-channels.md). + ### Mesh infrastructure monitoring A dedicated page surfacing the health of every node with an infrastructure diff --git a/docs/features/text-messages/README.md b/docs/features/text-messages/README.md new file mode 100644 index 0000000..cf1bac3 --- /dev/null +++ b/docs/features/text-messages/README.md @@ -0,0 +1,80 @@ +# Text messages + +Meshflow stores normalised **text messages** from Meshtastic and MeshCore feeders: public channel chat, emoji reactions, and (Meshtastic) threaded replies. Rows live in the `text_messages` Django app; ingest paths differ by protocol but both emit the same `text_message_received` signal for WebSocket fan-out. + +This folder documents cross-cutting text-message behaviour. MeshCore-specific ingest and channel modelling remain under [meshcore/text-message-channels.md](../meshcore/text-message-channels.md) and [meshcore/mc-channel-sync/](../meshcore/mc-channel-sync/). + +## Implementation status + +| Area | Status | Notes | +| --- | --- | --- | +| REST list/detail `GET /api/messages/text/` | Shipped | `protocol`, `channel_id`, `constellation_id`, pagination | +| `TextMessage.protocol` on DB row | Shipped | `Protocol.MESHTASTIC` / `Protocol.MESHCORE` | +| WebSocket `/ws/messages/` | Shipped | JWT; Redis group `text_messages` | +| WS payload `protocol` | Shipped | `TextMessageWSSerializer` (see [unread-count.md](unread-count.md)) | +| Server-side unread / read receipts | Not implemented | Unread is entirely client-side in meshflow-ui | + +## Documentation map + +| Doc | Purpose | +| --- | --- | +| [unread-count.md](unread-count.md) | Realtime push, WS serializer fields, UI nav badges ([#279](https://github.com/pskillen/meshflow-ui/issues/279)) | +| [../meshcore/text-message-channels.md](../meshcore/text-message-channels.md) | MC ingest, channels, sender inference | +| [../packet-ingestion/meshtastic.md](../packet-ingestion/meshtastic.md) | MT `TEXT_MESSAGE_APP` → `TextMessage` | + +## Concepts + +- **`TextMessage`** — business row with `protocol`, optional `sender`, `channel` FK, provenance via `original_packet` (MT) or `original_mc_packet` (MC). +- **`text_message_received`** — Django signal (`packets.signals`) fired after a row is created; `ws.receivers` pushes to connected UI clients. +- **Unread** — not stored in Postgres; the SPA keeps an in-memory list keyed by protocol (see UI doc). + +## Flow (high level) + +```mermaid +sequenceDiagram + participant Ingest as packets_or_meshcore_packets + participant TM as TextMessage + participant Sig as text_message_received + participant WS as TextMessageWebSocketNotifier + participant Redis as channel_layer_text_messages + participant UI as meshflow_ui + + Ingest->>TM: create row (protocol set on model) + Ingest->>Sig: send(message, observer) + Sig->>WS: notify(message) + WS->>Redis: group_send text_messages + Redis->>UI: JSON frame on /ws/messages/ +``` + +## HTTP API + +- **List:** `GET /api/messages/text/` — filter `protocol=meshtastic|meshcore`, `channel_id`, `constellation_id`, `sender_node_id`; paginated. +- **OpenAPI:** `TextMessage` schema includes `protocol` (`MeshProtocol` string). +- **Auth:** JWT (same as rest of API). + +## WebSocket + +- **Path:** `/ws/messages/?token={jwt}` — see [openapi.yaml](../../../openapi.yaml) and [REDIS.md](../../REDIS.md) (Channels group `text_messages`). +- **Consumer:** `ws.consumers.TextMessageConsumer` — adds socket to group `text_messages`; handler `text_message` forwards `event["message"]` as JSON. +- **Notifier:** `ws.services.text_message.TextMessageWebSocketNotifier` serializes with `TextMessageWSSerializer`. + +## Consumers + +| Consumer | Use | +| --- | --- | +| meshflow-ui `WebSocketProvider` | Nav unread badges, toasts when off messages page | +| meshflow-ui `useMessagesWithWebSocket` | Prepend to active channel list on messages page | +| (none server-side) | No unread persistence | + +## Related issues + +| Issue | Repo | Topic | +| --- | --- | --- | +| [#279](https://github.com/pskillen/meshflow-ui/issues/279) | meshflow-ui | Protocol-scoped unread nav badges | +| [#341](https://github.com/pskillen/meshflow-api/issues/341) | meshflow-api | Messages UI epic (parent) | +| [#277](https://github.com/pskillen/meshflow-ui/issues/277)–[#281](https://github.com/pskillen/meshflow-ui/issues/281) | meshflow-ui | Picker / layout rework | + +## Cross-repo docs + +- UI messages feature hub: [meshflow-ui `docs/features/messages/README.md`](https://github.com/pskillen/meshflow-ui/blob/main/docs/features/messages/README.md) +- UI legacy overview: [meshflow-ui `docs/messages/`](https://github.com/pskillen/meshflow-ui/blob/main/docs/messages/README.md) diff --git a/docs/features/text-messages/unread-count.md b/docs/features/text-messages/unread-count.md new file mode 100644 index 0000000..3514950 --- /dev/null +++ b/docs/features/text-messages/unread-count.md @@ -0,0 +1,55 @@ +# Text messages — unread count (WebSocket → UI) + +**Purpose:** How new text messages reach the browser for realtime UI and unread badges. Unread state is **client-only** in meshflow-ui. + +**UI counterpart:** [meshflow-ui `docs/features/messages/unread-count.md`](https://github.com/pskillen/meshflow-ui/blob/main/docs/features/messages/unread-count.md) + +**Tracking:** [meshflow-ui#279](https://github.com/pskillen/meshflow-ui/issues/279). Deferred UI rollup: [meshflow-api#396](https://github.com/pskillen/meshflow-api/issues/396). Epic: [#341](https://github.com/pskillen/meshflow-api/issues/341). + +--- + +## What the API does (and does not do) + +| Responsibility | Owner | +| --- | --- | +| Persist `TextMessage` with correct `protocol` | `text_messages` + ingest | +| Broadcast new rows to UI clients | `ws` via Channels group `text_messages` | +| Unread / read receipts | **Not implemented** — SPA only | + +--- + +## Code anchors + +| Piece | Path | +| --- | --- | +| Model `protocol` | `Meshflow/text_messages/models.py` | +| REST serializer | `Meshflow/text_messages/serializers.py` | +| WS serializer | `Meshflow/ws/serializers.py` — `TextMessageWSSerializer` (includes `protocol`) | +| WS notify | `Meshflow/ws/services/text_message.py` | +| Tests | `Meshflow/ws/tests/test_text_message_ws_serializer.py` | + +--- + +## WebSocket payload shape + +`TextMessageWSSerializer` includes `protocol` (`meshtastic` / `meshcore`, same labels as REST). Also: `id`, `channel`, `sender`, `sent_at`, `message_text`, `is_emoji`, `reply_to_meshtastic_packet_id`, `original_packet_id`, `heard` (MT prefetch when available). + +Still narrower than REST for MC helper fields (`mc_sender_label`, `mc_sender_candidates`). + +OpenAPI documents WS frames as the `TextMessage` schema; `protocol` is required for correct UI classification ([#279](https://github.com/pskillen/meshflow-ui/issues/279)). + +--- + +## Known gaps + +- MC sender helper fields not on WS payload. +- `TextMessageConsumer` uses `print` when forwarding (log noise). +- UI dedupes by message `id`; API does not suppress duplicate pushes. + +--- + +## Related + +- [README.md](README.md) +- [meshflow-ui unread-count.md](https://github.com/pskillen/meshflow-ui/blob/main/docs/features/messages/unread-count.md) +- [#396](https://github.com/pskillen/meshflow-api/issues/396) — multi-constellation unread rollup (UI)