From 2d80020bb15b6202dfa5f4a9721e574703875a7d Mon Sep 17 00:00:00 2001 From: ann0see <20726856+ann0see@users.noreply.github.com> Date: Sun, 9 Aug 2026 22:10:54 +0200 Subject: [PATCH] Add structured chat messages with channel-scoped client rendering Introduces chat message type 37 carrying semantic chat data (channel id, timestamp, sender name, text) alongside the legacy message 18, negotiated via new REQ_CHAT_TEXT_SUPPORT / CHAT_TEXT_SUPPORTED messages (38/39). The server fans out structured chat to all clients and exposes it over JSON-RPC (jamulusserver/chatMessageReceived, jamulusclient/chatTextReceived). The client renders messages client-side with per-channel colors and safe linkification, escaping user text before any HTML interpretation. --- docs/JSON-RPC.md | 15 +- docs/design-chat-redesign.md | 224 +++++++++++ src/channel.cpp | 7 + src/channel.h | 13 +- src/chatdlg.cpp | 90 ++++- src/chatdlg.h | 6 + src/chatmessage.h | 43 +++ src/client.cpp | 11 + src/client.h | 3 + src/clientdlg.cpp | 17 + src/clientdlg.h | 1 + src/clientrpc.cpp | 14 +- src/global.h | 7 + src/protocol.cpp | 106 +++++ src/protocol.h | 12 + src/server.cpp | 73 +++- src/server.h | 6 +- src/serverrpc.cpp | 19 +- src/util.cpp | 32 ++ src/util.h | 6 + tests/chatprotocol/chatprotocol.pro | 25 ++ tests/chatprotocol/tst_chatprotocol.cpp | 490 ++++++++++++++++++++++++ tools/generate_json_rpc_docs.py | 2 +- 23 files changed, 1156 insertions(+), 66 deletions(-) create mode 100644 docs/design-chat-redesign.md create mode 100644 src/chatmessage.h create mode 100644 tests/chatprotocol/chatprotocol.pro create mode 100644 tests/chatprotocol/tst_chatprotocol.cpp diff --git a/docs/JSON-RPC.md b/docs/JSON-RPC.md index fbcd2d641d..db6d2240e9 100644 --- a/docs/JSON-RPC.md +++ b/docs/JSON-RPC.md @@ -74,7 +74,7 @@ The request must be sent as a single line of JSON-encoded data, followed by a ne Jamulus will also send **notifications** to the consumer: ```json -{"jsonrpc":"2.0","method":"jamulusclient/chatTextReceived","params":{"text":"(01:23:45 AM) user test"}} +{"jsonrpc":"2.0","method":"jamulusclient/chatTextReceived","params":{"channelId":12,"timestamp":1786298460,"senderName":"user","text":"test"}} ``` ## Method reference @@ -600,13 +600,16 @@ Parameters: ### jamulusclient/chatTextReceived -Emitted when a chat text is received. +Emitted when a structured chat message (message 37) is received. Carries semantic data, never presentation markup. Parameters: | Name | Type | Description | | --- | --- | --- | -| params.chatText | string | The chat text. | +| params.channelId | number | Channel ID of the sending client, or 255 for server/RPC-originated messages. | +| params.timestamp | number | Unix timestamp (seconds) stamped at the server. | +| params.senderName | string | Name of the sending client (empty for server/RPC-originated messages). | +| params.text | string | Chat message text. | ### jamulusclient/clientListReceived @@ -698,8 +701,10 @@ Parameters: | Name | Type | Description | | --- | --- | --- | -| params.id | number | Channel ID of sending client or -1 for RPC sent messages. | -| params.chatMessage | string | Chat message text. | +| params.channelId | number | Channel ID of sending client or -1 for RPC sent messages. | +| params.timestamp | number | Unix timestamp (seconds) stamped at the server. | +| params.senderName | string | Name of the sending client (empty for RPC sent messages). | +| params.text | string | Chat message text. | ### jamulusserver/clientConnected diff --git a/docs/design-chat-redesign.md b/docs/design-chat-redesign.md new file mode 100644 index 0000000000..f5a17d00dc --- /dev/null +++ b/docs/design-chat-redesign.md @@ -0,0 +1,224 @@ +# Redesigned Chat Message — Design Document (v2) + +Status: Draft (for review) · Date: 2026-08-09 · Scope: Protocol, server, client, JSON-RPC, accessibility, compatibility, tests + +Incorporates the revised brief. Resolves open decisions A–E and replaces the earlier version-based capability gate with an explicit in-session handshake. + +## 1. Objective + +Normal chat messages contain **data, not presentation markup**. The server supplies facts (channel ID, timestamp, sender name, plain UTF-8 text); the client supplies presentation (timezone/locale/colours/typography/a11y/URLs/filtering/mute). + +```text +Client chat input → Server → legacy client → message 18 (legacy HTML) + └─ structured client → message 37 (data) → ChatMessage → Client UI +``` + +**Invariant: normal user chat text is never interpreted as HTML by the receiver.** Compatibility with existing clients and JSON-RPC semantics preserved where practical. + +## 2. Problems solved + +1. `PROTMESSID_CHAT_TEXT` (18) carries a server-generated HTML display string (`src/server.cpp:1352-1367`). +2. Server picks colours (palette `src/server.cpp:150-156`) and time formatting. +3. Desktop themes cannot control server HTML (issue #3740). +4. Accessibility constrained by the rich-text model. +5. RPC injection can bypass escaping (`src/serverrpc.cpp:112`, `:132`). +6. URL linkification done via HTML manipulation (`src/chatdlg.cpp:142-157`). +7. Inconsistent length limits (1600 vs 1800 vs unclamped). +8. Welcome coupled to raw-HTML mechanism (`src/clientdlg.cpp:887`). +9. Client JSON-RPC exposes server HTML (`src/clientrpc.cpp:54-62`). +10. Per-channel mute/filtering need structured source info. + +## 3. Design decisions (resolved) + +| Question | Decision | +| --- | --- | +| Wire format | Channel ID + uint32 epoch-seconds + sender name + UTF-8 text | +| Compatibility | Dual-format: 37 to capable clients, legacy HTML 18 to others | +| **A. Capability** | **Explicit in-session handshake** (new IDs 38/39), not version guessing (§4) | +| Timestamp | Epoch UTC stamped at fan-out; client renders local, locale-aware short format | +| **B. Sender identity** | **Name snapshotted onto the wire at fan-out** (§5.2) — history stable across renames/channel reuse | +| Welcome | Classified **administrative rich-content message**; raw HTML retained (§9) | +| **C. Chat UI** | **Evaluate** `QListWidget` vs `QListView`+model+delegate vs retained `QTextBrowser`; list-based preferred, `QTextBrowser` lowest (§12) | +| URL linkification | Preserved; escape-then-linkify of bare `http(s)://`; confirm-before-open stays | +| Old-format parsing in client | Kept for the compatibility window (new client ↔ old server) | +| **E. Client RPC** | **Structured** `{channelId, timestamp, text, senderName}`, breaking, changelog-flagged (§15.2) | +| Order | Slice 1 protocol+capability+server, Slice 2 client data/model, Slice 3 client UI (§18) | + +## 4. Capability negotiation (decision A) + +**Explicit in-session handshake, not version numbers** (forks, backports, dev builds, unusual version strings make thresholds unreliable). Version-gate failure mode — server sends 37 to a client that cannot parse it → message silently lost — is unacceptable because unknown IDs are ACKed unconditionally (`src/protocol.cpp:806-887`, `CreateAndImmSendAcknMess` at `:887`). + +Model on the split handshake (`PROTMESSID_REQ_SPLIT_MESS_SUPPORT` 34 / `PROTMESSID_SPLIT_MESS_SUPPORTED` 35, `src/protocol.h:85-86`): + +- Server sends `REQ_CHAT_TEXT_SUPPORT` (38, server→client) during connection bootstrap, mirroring `CreateReqSplitMessSupportMes()` at `src/server.cpp:417`. +- Client replies `CHAT_TEXT_SUPPORTED` (39, client→server), mirroring `src/channel.cpp:500-505`. +- IDs 38/39 are free (body 0–36; CLM 1000–1999; split 2001). +- Store per-connection `bSupportsStructuredChat` on `CChannel`. Today nothing is retained about client capability: `CChannel::OnVersionAndOSReceived` (`src/channel.cpp:171`) only sets `bUseSequenceNumber` internally; its signal is **not** connected to any server slot (`connectChannelSignalsToServerSlots`, `src/server.cpp:328-362`). Needed: stored member + new signal + server connection. +- **Default `false` until confirmed → legacy 18.** Safe failure = "unknown/new client → legacy", never silent loss. A chat before the capability reply is a benign race handled by the legacy default. +- No generalized capability framework — narrowly scoped to structured chat. + +## 5. Protocol message 37 + +### 5.1 ID + +```cpp +#define PROTMESSID_CHAT_TEXT_CHANNEL 37 // chat text with source channel ID, timestamp and sender name +``` + +Next free body slot. **Server→client only:** clients keep sending chat as legacy 18 (they don't reliably know their server channel ID; the server stamps time and name). Server is the only producer. + +### 5.2 Wire format + +```text +| 1 byte channel ID | 4 bytes uint32 timestamp | 2 bytes name len | n bytes name | 2 bytes text len | m bytes text | +``` + +- Little-endian per `PutValOnStream`/`GetValFromStream` (`src/protocol.cpp:2976-3006`; `uint32_t`-based, assert `iNumOfBytes <= 4` at `:2976`, `:2826` — a uint64 would need new helpers). uint32 epoch-seconds fits directly; unambiguous until 2106. +- Name/text length fields are **UTF-8 byte counts** (2-byte) via existing stream helpers (`:2992`, `:2846`). +- **Sender name snapshotted at fan-out** from `vecChannels[iCurChanID].GetName()` (`src/server.cpp:1356`). RPC/255 → empty name → client placeholder. Decode cap `MAX_LEN_FADER_TAG` (16), consistent with CONN_CLIENTS_LIST names (`src/protocol.cpp:1268`, `global.h:284`). +- Timestamp: `QDateTime::currentSecsSinceEpoch()` (UTC) at fan-out. + +### 5.3 Text length semantics (decision D) + +`MAX_LEN_CHAT_TEXT == 1600` = **QString::size(), UTF-16 code units**, enforced **post-decode** by `GetStringFromStream` (`strOut.size() > iMaxStringLen`, `src/protocol.cpp:2881`); the wire length is the **UTF-8 byte count**. The client input clamp uses the same unit (`src/chatdlg.cpp:111`) — semantics are self-consistent; the new decoder must reuse the same `size()` check. + +- Reject on: inconsistent length, truncation (name/timestamp/text), decoded text over limit, trailing bytes (`iPos != vecData.Size()`, as `src/protocol.cpp:1430-1433`). +- `MAX_LEN_CHAT_TEXT_PLUS_HTML` (1800) no longer needed for message 37. +- Max body ≈ 1+4+2+≤48 (name 16×3)+2+≤4800 (text 1600×3 bytes/UTF-16 unit) ≈ **4857 bytes** — ~9 split parts, within 36-part capacity (`MAX_NUM_MESS_SPLIT_PARTS = MAX_SIZE_BYTES_NETW_BUF/550`, `src/protocol.h:126`, `global.h:169`). +- Non-trivial message 37 always takes the split path (`src/protocol.cpp:584`, `:590`) — already true for legacy chat; all current clients negotiate split. Created via `CreateAndSendMessage`; client parses the reassembled body like legacy chat. + +## 6. Channel IDs and sentinels + +- The 1-byte field carries the **server channel ID** advertised in `PROTMESSID_CONN_CLIENTS_LIST` (`src/protocol.cpp:1213`, `CChannelInfo.iChanID`). The server channel index *is* the ID (`CreateChannelList` passes the index straight into `CChannelInfo`, `src/server.cpp:1319`) — no index→ID mapping exists or is needed. +- **Valid IDs `0..MAX_NUM_CHANNELS-1`** (`MAX_NUM_CHANNELS = 150`). **ID 0 is a real client and is never a sentinel.** +- **`255` (0xFF) is the server/JSON-RPC sentinel** for RPC `broadcastChatMessage`/`privateChatMessage`: outside 0..149, fits the byte. `INVALID_CLIENT_ID = -1` (`src/serverrpc.cpp:51`) doesn't fit a byte and stays internal-only. +- Client reject rule: **"reject > 149 except 255"**, not "reject > MAX_NUM_CHANNELS" (which would wrongly admit 150..254). +- Mapping: JSON-RPC `-1` → internal `INVALID_CLIENT_ID` → wire `255`, converted only at the fan-out boundary. + +## 7. Server fan-out + +Client chat + both RPC paths converge on **one** branchy fan-out. Today: `CreateAndSendChatTextForAllConChannels` (`src/server.cpp:1352-1367`, formats + fans out; client chat only) vs `SendChatTextToAllConChannels` (`src/server.cpp:1369-1381`, raw unescaped; called by RPC, `src/serverrpc.cpp:112`). Merge the latter into the former. + +1. **Clamp** to `MAX_LEN_CHAT_TEXT` up front — today's fan-out never clamps (client UI clamps; welcome at `src/server.cpp:1655`; RPC-private at `src/serverrpc.cpp:125`; RPC-broadcast and client-sourced do not). +2. **Stamp** `QDateTime::currentSecsSinceEpoch()` (UTC). +3. **Determine source ID before indexing `vecChannels`**: use `iCurChanID` directly for client chat; map RPC/`INVALID_CLIENT_ID` to `255`. **Guard: sentinel/-1 must short-circuit before `vecChannels[iCurChanID].GetName()` / `vstrChatColors[iCurChanID % 6]` (`src/server.cpp:1356`, `:1360`) — indexing with 255 or -1 is out of bounds.** Sentinel branch supplies an empty name. +4. **Per client:** `bSupportsStructuredChat` → `CreateChatTextChannelMes ( channelID, timestamp, senderName, strChatText )` (plain, no wire escaping); legacy → existing escaped HTML string via `PROTMESSID_CHAT_TEXT`. + +## 8. Legacy message 18 + +Compatibility window only: names/text escaped, consistent length enforcement, RPC uses the same safe formatting path, no new functionality depends on it. The structured path must **not** reuse the legacy formatted string internally. + +## 9. Welcome message (administrative rich-content message) + +`CServer::OnClientConnect` (`src/server.cpp:448-459`) keeps sending the raw-HTML welcome via `PROTMESSID_CHAT_TEXT` — administrators depend on HTML/CSS styling. Classified as an **administrative rich-content message**, not normal chat; **normal user chat never enters this path**. Prefix detection (`src/clientdlg.cpp:887`) remains temporarily; the HTML mechanism is not generalized; long-term migration out of scope. + +## 10. Client data model + +Protocol handlers must not build widgets. Semantic layer between protocol and presentation: + +```cpp +struct ChatMessage +{ + uint8_t channelId; + uint32_t timestamp; + QString senderName; // wire snapshot (decision B) + QString text; +}; +``` + +```text +Protocol → ChatMessage → model/presentation layer → desktop UI + accessibility + filtering/muting + JSON-RPC +``` + +Not `Protocol → QListWidgetItem`. With the name on the wire, no live lookup is needed for historical identity; channel lookup only serves filtering/muting. + +## 11. Client rendering + +Plain-data rendering; `hello`, ``, ``, `` appear as ordinary text. + +- New `OnChatTextChannelReceived` builds `ChatMessage` and appends to the chat model. +- `AddChatText` (`src/chatdlg.cpp:134-161`) retained for the legacy path only; remove the `href\s*=|src\s*=` heuristic (`:142`). +- Linkify: apply the HTTP(S)-wrap regex (`:155-156`) to escaped/plain text **after** escaping, never before; confirm-before-open stays. + +## 12. Chat UI choice (decision C) + +Evaluate, don't assume: + +1. `QListWidget` — per-item `Qt::PlainText`, `Qt::UserRole` channel ID; per-item a11y via `QAccessibleItemView`. +2. `QListView` + model + delegate — best fit for `ChatMessage`; links/copy/select/keyboard via delegate + editor flags; same per-item a11y; scales to high volume. +3. Retained `QTextBrowser` with fully client-controlled escaped generation — **rank lowest**: the source of both the a11y ceiling (one text node to screen readers) and the HTML-interpretation surface. + +Criteria: a11y, keyboard navigation, select/copy, URL interaction, visual formatting, high-volume performance, ease, maintainability, Qt compatibility. **Requirement is structured data + safe presentation, not a specific widget.** If `QListWidget` needs a heavy custom delegate/hit-test/a11y layer to reproduce clickable URLs, prefer model/view. + +Evaluation outcome (Slice 3, 2026-08-09): **retained `QTextBrowser`** with fully client-controlled escaped generation. Rationale: (1) clickable-URL-with-confirm requires an anchor hit-test delegate in any list view — the "heavy layer" the design flags; (2) the legacy old-server path must keep rendering server HTML, so two rendering modes would be needed in a list view; (3) the core requirement — structured data + safe presentation — is fully met by client-side escaping (`EscapeAndLinkifyText`) on the retained widget. A11y improvement delivered via `QAccessibleAnnouncementEvent` live announcements; per-message a11y nodes (list-view benefit) remain a follow-up candidate. The URL linkify regex was tightened to terminate at `&` (HTML-entity boundary in escaped text) so URLs adjacent to escaped markup do not over-match. + +## 13. Accessibility + +Improve, not merely preserve: accessible name (`src/chatdlg.cpp:58`); per-message exposure (list-based view gives per-item nodes); live announcements — today `QAccessibleValueChangeEvent` (`:137`), prefer `QAccessibleAnnouncementEvent` (API since Qt 6.5; the project's existing guarded use at `src/connectdlg.cpp:1169` is **Qt ≥ 6.8** — match that guard); keyboard nav; selectable/copyable text; meaningful sender/timestamp/text semantics. + +## 14. Name/channel lookup + +Name is on the wire, so the channel ID is for filtering/muting, not identity. Client channel structures store no name (`CClientChannel`, `src/client.h:131`; CONN_CLIENTS_LIST names remapped in `OnConClientListMesReceived`, `src/client.cpp:353`, `:377`). For lookups: unknown IDs must not crash (`FindClientChannel` → `INVALID_INDEX` for IDs ≥ `MAX_NUM_CHANNELS`, `src/client.cpp:1740`, covering 255); `255`/unknown → "Server"/placeholder; pre-list messages (connect race / UDP loss) → placeholder, repaired on refresh. + +## 15. JSON-RPC + +### 15.1 Server + +`broadcastChatMessage` (`src/serverrpc.cpp:98-114`) and `privateChatMessage` (`:116-138`) route through the merged fan-out (§7) — no HTML construction; wire ID `255`; clamping added to broadcast (`private` already clamps at `:125`). Public API keeps `-1` for RPC messages (`chatMessageReceived`, `src/serverrpc.cpp:86-96`); `-1 → 255` conversion only at the fan-out boundary; 255 never surfaces in the public API. + +### 15.2 Client + +`jamulusclient/chatTextReceived` (`src/clientrpc.cpp:54-62`) stops exposing server HTML. **Structured:** + +```json +{ "channelId": 12, "timestamp": 1786298460, "text": "hello" } +``` + +plus `senderName` (decision B). Breaking — changelog-flagged. + +## 16. Testing + +Part of the feature, not deferred. **Dependency:** repo currently has only the commented-out `CTestbench` (`src/protocol.h:161-183`) — land the test-infrastructure PR first or add a small standalone target for 37 round-trips. + +- **Serialization:** ASCII, UTF-8, empty, max-length, over-limit, channel 0, channel 149, channel 255, invalid 150–254, malformed length, truncated packet/timestamp, trailing data. +- **Compatibility:** all four old/new server × old/new client combos. Expected: new+old→18; new+new capable→37; old+new→18 (new client keeps the legacy parser). +- **Capability:** absent, supported, malformed/unknown, message before negotiation completes, reconnect, state reset on disconnect. Safe default always legacy. +- **Security:** `hello`, ``, ``, ``, HTML+URL combinations. Invariant: user text can never become executable/unintended HTML. + +Status (2026-08-09): the standalone `tests/chatprotocol` target covers serialization, reject rules, split reassembly, capability, the `ChatMessage` data model and the security cases above (via `EscapeAndLinkifyText`/`LinkifyURLs`); 48 assertions passing. +- **Identity (name on wire):** rename after send → old row keeps original name; disconnect + ID reuse → old row keeps original sender. + +## 17. Documentation + +- `docs/JAMULUS_PROTOCOL.md`: message 37, field order, byte order, timestamp semantics, channel-ID semantics, 255 sentinel, server→client-only, capability negotiation (38/39), relation to legacy 18. State: *message 37 carries semantic chat data, not HTML/presentation markup.* Clarify the legacy note (`:145-147`). +- `docs/JSON-RPC.md`: regenerate via `tools/generate_json_rpc_docs.py`; document HTML removal from client notifications, structured schema, `-1` semantics, breaking implications. + +## 18. Implementation plan + +**Slice 1 — protocol + capability + server.** No client UI change; existing clients keep parsing legacy HTML. + +1. `protocol.h`: add `PROTMESSID_CHAT_TEXT_CHANNEL 37`, `REQ_CHAT_TEXT_SUPPORT 38`, `CHAT_TEXT_SUPPORTED 39`; declare create/evaluate. +2. `protocol.cpp`: 37 create/evaluate (decode caps: name `MAX_LEN_FADER_TAG`, text `MAX_LEN_CHAT_TEXT` post-decode, trailing guard); dispatch cases in `EvaluateMessageBody` (`src/protocol.cpp:806`); 38/39 handshake. +3. `CChannel`: store `bSupportsStructuredChat`; new signal + connection (`connectChannelSignalsToServerSlots`, `src/server.cpp:328-362`); server sends REQ in the connect bootstrap (near `src/server.cpp:417`). +4. Merge fan-out (§7): clamp, stamp, sentinel guard, name snapshot, per-client 37/18 branch; route RPC through it (`src/serverrpc.cpp:112`), keep `-1`. +5. Tests (§16) + protocol/RPC docs (§17). + +**Slice 2 — client data/model.** `ChatMessage`; `OnChatTextChannelReceived`; identity semantics; safe text representation; structured RPC notification (§15.2); tests. + +Status (2026-08-09): **implemented.** `ChatMessage` struct in `src/chatmessage.h` (plain data: channel ID, epoch timestamp, sender-name wire snapshot, text — never HTML). `CChannel` relays the new protocol signal; `CClient::OnChatTextChannelReceived` builds the `ChatMessage` and emits `CClient::ChatTextChannelReceived`. `jamulusclient/chatTextReceived` now emits structured `{channelId, timestamp, senderName, text}` and no longer exposes server HTML. Note: the structured notification fires only for message 37; a new client against a legacy server (message 18) does not re-expose the legacy HTML string through this notification. Tests extended in `tests/chatprotocol` for the `ChatMessage` data model; full client build verified. Slice 3 (UI) still owns placeholder/identity rendering of unknown/255 channel IDs and the model/view. + +**Slice 3 — client UI.** Chat model/view (§12); timestamp formatting; sender rendering; URL interaction; a11y + live announcements (§13); copy/select; legacy fallback. Only the old-server path retains HTML rendering. + +Status (2026-08-09): **implemented.** `CChatDlg::AddChatMessage` renders structured messages entirely client-side: local/locale-aware timestamp, stable per-channel colour, escaped sender name and text via the new testable `EscapeAndLinkifyText`/`LinkifyURLs` helpers (`src/util.h`), preserving bare-http(s)-URL linkification after escaping and the confirm-before-open dialog. Legacy `AddChatText` keeps the server-HTML path (linkify only, `href\s*=|src\s*=` heuristic removed per §11). A11y live announcements moved from `QAccessibleValueChangeEvent` to the Qt ≥ 6.8-guarded `QAccessibleAnnouncementEvent` (§13). `CClientDlg::OnChatTextChannelReceived` wires the structured signal into the chat dialog (audio alert, `ShowChatWindow(false)`, welcome detection remains on the legacy path). + +## 19. Non-goals + +Per-channel mute, rich user formatting, Markdown, arbitrary HTML chat, editing, reactions, threading, persistence, generalized capability framework. Mute/filtering becomes easier (channel identity on messages) but remains follow-up. + +## 20. Open items + +- Exact names of capability messages 38/39 (per existing conventions). +- Exact JSON-RPC schema field names — settle before Slice 2. +- Test-infrastructure PR vs standalone target sequencing. + +Resolved during Slice 2: JSON-RPC field names are `channelId`, `timestamp`, `senderName`, `text` for both `jamulusclient/chatTextReceived` and `jamulusserver/chatMessageReceived`; 38 = `REQ_CHAT_TEXT_SUPPORT`, 39 = `CHAT_TEXT_SUPPORTED`. diff --git a/src/channel.cpp b/src/channel.cpp index 7755b7ec92..d11c03c747 100644 --- a/src/channel.cpp +++ b/src/channel.cpp @@ -53,6 +53,7 @@ CChannel::CChannel ( const bool bNIsServer ) : iCurSockBufNumFrames ( INVALID_INDEX ), bDoAutoSockBufSize ( true ), bUseSequenceNumber ( false ), // this is important since in the client we reset on Channel.SetEnable ( false ) + bSupportsStructuredChat ( false ), iSendSequenceNumber ( 0 ), iFadeInCnt ( 0 ), iFadeInCntMax ( FADE_IN_NUM_FRAMES_DBLE_FRAMESIZE ), @@ -113,6 +114,8 @@ CChannel::CChannel ( const bool bNIsServer ) : QObject::connect ( &Protocol, &CProtocol::ChatTextReceived, this, &CChannel::ChatTextReceived ); + QObject::connect ( &Protocol, &CProtocol::ChatTextChannelReceived, this, &CChannel::ChatTextChannelReceived ); + QObject::connect ( &Protocol, &CProtocol::NetTranspPropsReceived, this, &CChannel::OnNetTranspPropsReceived ); QObject::connect ( &Protocol, &CProtocol::ReqNetTranspProps, this, &CChannel::OnReqNetTranspProps ); @@ -121,6 +124,10 @@ CChannel::CChannel ( const bool bNIsServer ) : QObject::connect ( &Protocol, &CProtocol::SplitMessSupported, this, &CChannel::OnSplitMessSupported ); + QObject::connect ( &Protocol, &CProtocol::ReqChatTextSupport, this, &CChannel::OnReqChatTextSupport ); + + QObject::connect ( &Protocol, &CProtocol::ChatTextSupported, this, &CChannel::OnChatTextSupported ); + QObject::connect ( &Protocol, &CProtocol::LicenceRequired, this, &CChannel::LicenceRequired ); QObject::connect ( &Protocol, &CProtocol::VersionAndOSReceived, this, &CChannel::OnVersionAndOSReceived ); diff --git a/src/channel.h b/src/channel.h index 946eae007a..0decb52699 100644 --- a/src/channel.h +++ b/src/channel.h @@ -111,8 +111,9 @@ class CChannel : public QObject void ResetInfo() { - bIsIdentified = false; - ChannelInfo = CChannelCoreInfo(); + bIsIdentified = false; + bSupportsStructuredChat = false; + ChannelInfo = CChannelCoreInfo(); } // reset does not emit a message QString GetName(); void SetChanInfo ( const CChannelCoreInfo& NChanInf ); @@ -178,6 +179,10 @@ class CChannel : public QObject void CreateReqJitBufMes() { Protocol.CreateReqJitBufMes(); } void CreateReqConnClientsList() { Protocol.CreateReqConnClientsList(); } void CreateChatTextMes ( const QString& strChatText ) { Protocol.CreateChatTextMes ( strChatText ); } + void CreateChatTextChannelMes ( const uint8_t iChannelID, const uint32_t iTimestamp, const QString strSenderName, const QString strChatText ) { Protocol.CreateChatTextChannelMes ( iChannelID, iTimestamp, strSenderName, strChatText ); } + void CreateReqChatTextSupportMes() { Protocol.CreateReqChatTextSupportMes(); } + void CreateChatTextSupportedMes() { Protocol.CreateChatTextSupportedMes(); } + bool SupportsStructuredChat() const { return bSupportsStructuredChat; } void CreateLicReqMes ( const ELicenceType eLicenceType ) { Protocol.CreateLicenceRequiredMes ( eLicenceType ); } //### TODO: BEGIN ###// @@ -224,6 +229,7 @@ class CChannel : public QObject int iCurSockBufNumFrames; bool bDoAutoSockBufSize; bool bUseSequenceNumber; + bool bSupportsStructuredChat; uint8_t iSendSequenceNumber; // network output conversion buffer @@ -265,6 +271,8 @@ public slots: void OnReqNetTranspProps(); void OnReqSplitMessSupport(); void OnSplitMessSupported() { Protocol.SetSplitMessageSupported ( true ); } + void OnReqChatTextSupport() { Protocol.CreateChatTextSupportedMes(); } + void OnChatTextSupported() { bSupportsStructuredChat = true; } void OnVersionAndOSReceived ( COSUtil::EOpSystemType eOSType, QString strVersion ); @@ -301,6 +309,7 @@ public slots: void MuteStateHasChangedReceived ( int iChanID, bool bIsMuted ); void ReqChanInfo(); void ChatTextReceived ( QString strChatText ); + void ChatTextChannelReceived ( uint8_t iChannelID, uint32_t iTimestamp, QString strSenderName, QString strChatText ); void ReqNetTranspProps(); void LicenceRequired ( ELicenceType eLicenceType ); void VersionAndOSReceived ( COSUtil::EOpSystemType eOSType, QString strVersion ); diff --git a/src/chatdlg.cpp b/src/chatdlg.cpp index d49a716fb9..7f68328709 100644 --- a/src/chatdlg.cpp +++ b/src/chatdlg.cpp @@ -46,7 +46,20 @@ #include "chatdlg.h" +#include +#include +#if QT_VERSION >= QT_VERSION_CHECK( 6, 8, 0 ) +# include +#endif + /* Implementation *************************************************************/ +namespace +{ +// client-controlled sender colours, stable per channel ID (presentation is a +// client concern; the server only sends data) +const char* const astrChatColors[6] = { "mediumblue", "red", "darkorchid", "green", "maroon", "coral" }; +} + CChatDlg::CChatDlg ( QWidget* parent ) : CBaseDlg ( parent, Qt::Window ) // use Qt::Window to get min/max window buttons { setupUi ( this ); @@ -133,33 +146,68 @@ void CChatDlg::OnClearChatHistory() void CChatDlg::AddChatText ( QString strChatText ) { - // notify accessibility plugin that text has changed - QAccessible::updateAccessibility ( new QAccessibleValueChangeEvent ( txvChatWindow, strChatText ) ); + // legacy (message 18) path: the server sent already-escaped HTML; we only + // linkify bare http(s):// URLs, the text itself is never re-interpreted + LinkifyURLs ( strChatText ); - // analyze strChatText to check if hyperlink (limit ourselves to http(s)://) but do not - // replace the hyperlinks if any HTML code for a hyperlink was found (the user has done the HTML - // coding hisself and we should not mess with that) - if ( !strChatText.contains ( QRegularExpression ( "href\\s*=|src\\s*=" ) ) ) - { - // searches for all occurrences of http(s) and cuts until a space (\S matches any non-white-space - // character and the + means that matches the previous element one or more times.) - // This regex now contains three parts: - // - https?://\\S+ matches as much non-whitespace as possible after the http:// or https://, - // subject to the next two parts, which exclude terminating punctuation - // - (??\\[\\]{}]) is a negative look-behind assertion that disallows the match - // from ending with one of the characters !"'()+,.:;<=>?[]{} - // - (??\\[\\]{}]) is a negative look-behind assertion that disallows the match - // from ending with a ? followed by one of the characters !"'()+,.:;<=>?[]{} - // These last two parts must be separate, as a look-behind assertion must be fixed length. -#define PUNCT_NOEND_URL "[!\"'()+,.:;<=>?\\[\\]{}]" - strChatText.replace ( QRegularExpression ( "(https?://\\S+(?\\1" ); - } + AnnounceNewChatMessage ( strChatText ); // add new text in chat window txvChatWindow->append ( strChatText ); } +void CChatDlg::AddChatMessage ( const ChatMessage& message ) +{ + // announce the plain content (sender and text) to screen readers + QString strAnnouncement; + if ( !message.senderName.isEmpty() ) + { + strAnnouncement = message.senderName + ": " + message.text; + } + else + { + strAnnouncement = message.text; + } + AnnounceNewChatMessage ( strAnnouncement ); + + // add new structured message in chat window + txvChatWindow->append ( FormatChatMessage ( message ) ); +} + +void CChatDlg::AnnounceNewChatMessage ( const QString& strAnnouncement ) +{ +#if QT_VERSION >= QT_VERSION_CHECK( 6, 8, 0 ) + // prefer a proper live region announcement over the value-change event + QAccessible::updateAccessibility ( new QAccessibleAnnouncementEvent ( txvChatWindow, strAnnouncement ) ); +#else + QAccessible::updateAccessibility ( new QAccessibleValueChangeEvent ( txvChatWindow, strAnnouncement ) ); +#endif +} + +QString CChatDlg::FormatChatMessage ( const ChatMessage& message ) const +{ + // the client supplies all presentation: local, locale-aware time, a stable + // per-channel sender colour and escaped plain text; user data is escaped so + // that it is never interpreted as HTML + const QString strTime = QLocale().toString ( QDateTime::fromSecsSinceEpoch ( message.timestamp ).toLocalTime().time(), + QLocale::ShortFormat ); + + QString strSenderName = message.senderName; + if ( strSenderName.isEmpty() ) + { + // server/RPC-originated messages carry the wire sentinel channel ID and + // no sender name; unknown channels get a neutral placeholder + strSenderName = ( message.channelId == SERVER_CHAT_CHANNEL_ID ) ? tr ( "Server" ) : tr ( "Unknown" ); + } + + const QString sCurColor = astrChatColors[message.channelId % 6]; + + const QString strHeader = + "(" + strTime + ") " + strSenderName.toHtmlEscaped() + " "; + + return strHeader + EscapeAndLinkifyText ( message.text ); +} + void CChatDlg::OnAnchorClicked ( const QUrl& Url ) { // only allow http(s) URLs to be opened in an external browser diff --git a/src/chatdlg.h b/src/chatdlg.h index eaad2c1cbe..db8c41456c 100644 --- a/src/chatdlg.h +++ b/src/chatdlg.h @@ -59,6 +59,7 @@ #include #include "global.h" #include "util.h" +#include "chatmessage.h" #include "ui_chatdlgbase.h" /* Classes ********************************************************************/ @@ -70,6 +71,7 @@ class CChatDlg : public CBaseDlg, private Ui_CChatDlgBase CChatDlg ( QWidget* parent = nullptr ); void AddChatText ( QString strChatText ); + void AddChatMessage ( const ChatMessage& message ); public slots: void OnSendText(); @@ -82,4 +84,8 @@ public slots: signals: void NewLocalInputText ( QString strNewText ); + +private: + void AnnounceNewChatMessage ( const QString& strAnnouncement ); + QString FormatChatMessage ( const ChatMessage& message ) const; }; diff --git a/src/chatmessage.h b/src/chatmessage.h new file mode 100644 index 0000000000..9b2d3ede9b --- /dev/null +++ b/src/chatmessage.h @@ -0,0 +1,43 @@ +/******************************************************************************\ + * Copyright (c) 2026 + * + * This file is part of Jamulus. + * + * Author(s): + * Jamulus contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + \******************************************************************************/ + +#pragma once + +#include +#include + +#include + +// Semantic chat message data as carried by protocol message 37. Contains +// facts, not presentation markup: the sender name is the wire snapshot taken +// at server fan-out, and the channel ID serves filtering/muting rather than +// identity. SERVER_CHAT_CHANNEL_ID (255) marks server/RPC-originated messages. +struct ChatMessage +{ + uint8_t channelId; // server channel ID, or SERVER_CHAT_CHANNEL_ID (255) for server/RPC messages + uint32_t timestamp; // epoch seconds (UTC), stamped at the server + QString senderName; // sender name snapshotted on the wire (empty for server/RPC messages) + QString text; // plain UTF-8 chat text, never HTML +}; + +Q_DECLARE_METATYPE ( ChatMessage ) diff --git a/src/client.cpp b/src/client.cpp index 6ee048a863..18ec89af0d 100644 --- a/src/client.cpp +++ b/src/client.cpp @@ -48,6 +48,8 @@ #include "settings.h" #include "util.h" +#include + /* Implementation *************************************************************/ CClient::CClient ( const quint16 iPortNumber, const quint16 iQosNumber, @@ -151,6 +153,8 @@ CClient::CClient ( const quint16 iPortNumber, QObject::connect ( &Channel, &CChannel::ChatTextReceived, this, &CClient::ChatTextReceived ); + QObject::connect ( &Channel, &CChannel::ChatTextChannelReceived, this, &CClient::OnChatTextChannelReceived ); + QObject::connect ( &Channel, &CChannel::ClientIDReceived, this, &CClient::OnClientIDReceived ); QObject::connect ( &Channel, &CChannel::RawAudioSupported, this, &CClient::OnRawAudioSupported ); @@ -420,6 +424,13 @@ void CClient::OnConClientListMesReceived ( CVector vecChanInfo ) emit ConClientListMesReceived ( vecChanInfo ); } +void CClient::OnChatTextChannelReceived ( uint8_t iChannelID, uint32_t iTimestamp, QString strSenderName, QString strChatText ) +{ + // build the semantic chat message and pass it on to the model/presentation + // layer; senderName is the wire snapshot, no live lookup is needed + emit ChatTextChannelReceived ( ChatMessage{ iChannelID, iTimestamp, std::move ( strSenderName ), std::move ( strChatText ) } ); +} + void CClient::CreateServerJitterBufferMessage() { // per definition in the client: if auto jitter buffer is enabled, both, diff --git a/src/client.h b/src/client.h index b56e5c42d1..37f9610a4f 100644 --- a/src/client.h +++ b/src/client.h @@ -61,6 +61,7 @@ #include "socket.h" #include "channel.h" #include "util.h" +#include "chatmessage.h" #include "plugins/audioreverb.h" #include "buffer.h" #include "signalhandler.h" @@ -485,10 +486,12 @@ protected slots: void OnMuteStateHasChangedReceived ( int iServerChanID, bool bIsMuted ); void OnCLChannelLevelListReceived ( CHostAddress InetAddr, CVector vecLevelList ); void OnConClientListMesReceived ( CVector vecChanInfo ); + void OnChatTextChannelReceived ( uint8_t iChannelID, uint32_t iTimestamp, QString strSenderName, QString strChatText ); signals: void ConClientListMesReceived ( CVector vecChanInfo ); void ChatTextReceived ( QString strChatText ); + void ChatTextChannelReceived ( ChatMessage chatMessage ); void ClientIDReceived ( int iChanID ); void MuteStateHasChangedReceived ( int iChanID, bool bIsMuted ); void LicenceRequired ( ELicenceType eLicenceType ); diff --git a/src/clientdlg.cpp b/src/clientdlg.cpp index d939b54aa2..8cbeac3b2b 100644 --- a/src/clientdlg.cpp +++ b/src/clientdlg.cpp @@ -511,6 +511,8 @@ CClientDlg::CClientDlg ( CClient* pNCliP, QObject::connect ( pClient, &CClient::ChatTextReceived, this, &CClientDlg::OnChatTextReceived ); + QObject::connect ( pClient, &CClient::ChatTextChannelReceived, this, &CClientDlg::OnChatTextChannelReceived ); + QObject::connect ( pClient, &CClient::ClientIDReceived, this, &CClientDlg::OnClientIDReceived ); QObject::connect ( pClient, &CClient::MuteStateHasChangedReceived, this, &CClientDlg::OnMuteStateHasChangedReceived ); @@ -890,6 +892,21 @@ void CClientDlg::OnChatTextReceived ( QString strChatText ) UpdateDisplay(); } +void CClientDlg::OnChatTextChannelReceived ( ChatMessage chatMessage ) +{ + if ( pSettings->bEnableAudioAlerts ) + { + PlayAudioAlert ( QUrl::fromLocalFile ( ":sounds/res/sounds/new_message.wav" ) ); + } + ChatDlg.AddChatMessage ( chatMessage ); + + // structured messages never carry the server welcome message (that stays + // on the legacy path), so never force the dialog to be upfront + ShowChatWindow ( false ); + + UpdateDisplay(); +} + void CClientDlg::OnLicenceRequired ( ELicenceType eLicenceType ) { // right now only the creative common licence is supported diff --git a/src/clientdlg.h b/src/clientdlg.h index 6a34cc66f0..1e1131c0a1 100644 --- a/src/clientdlg.h +++ b/src/clientdlg.h @@ -212,6 +212,7 @@ public slots: void OnConClientListMesReceived ( CVector vecChanInfo ); void OnChatTextReceived ( QString strChatText ); + void OnChatTextChannelReceived ( ChatMessage chatMessage ); void OnLicenceRequired ( ELicenceType eLicenceType ); void OnSoundDeviceChanged ( QString strError ); diff --git a/src/clientrpc.cpp b/src/clientrpc.cpp index 4cef578b17..c0a1e08702 100644 --- a/src/clientrpc.cpp +++ b/src/clientrpc.cpp @@ -52,12 +52,18 @@ CClientRpc::CClientRpc ( CClient* pClient, CClientSettings* pSettings, CRpcServe m_pSettings ( pSettings ) { /// @rpc_notification jamulusclient/chatTextReceived - /// @brief Emitted when a chat text is received. - /// @param {string} params.chatText - The chat text. - connect ( pClient, &CClient::ChatTextReceived, [=] ( QString strChatText ) { + /// @brief Emitted when a structured chat message (message 37) is received. Carries semantic data, never presentation markup. + /// @param {number} params.channelId - Channel ID of the sending client, or 255 for server/RPC-originated messages. + /// @param {number} params.timestamp - Unix timestamp (seconds) stamped at the server. + /// @param {string} params.senderName - Name of the sending client (empty for server/RPC-originated messages). + /// @param {string} params.text - Chat message text. + connect ( pClient, &CClient::ChatTextChannelReceived, [=] ( ChatMessage chatMessage ) { pRpcServer->BroadcastNotification ( "jamulusclient/chatTextReceived", QJsonObject{ - { "chatText", strChatText }, + { "channelId", static_cast ( chatMessage.channelId ) }, + { "timestamp", static_cast ( chatMessage.timestamp ) }, + { "senderName", chatMessage.senderName }, + { "text", chatMessage.text }, } ); } ); diff --git a/src/global.h b/src/global.h index 5414a58c41..a9d13e3824 100644 --- a/src/global.h +++ b/src/global.h @@ -232,6 +232,13 @@ LED bar: lbr // maximum number of connected clients at the server (must not be larger than 256) #define MAX_NUM_CHANNELS 150 // max number channels for server +// channel ID sentinel for server/RPC-originated chat messages on the wire (message 37) +#define SERVER_CHAT_CHANNEL_ID 255 + +// internal marker for RPC-originated chat messages (no sender channel); maps to +// SERVER_CHAT_CHANNEL_ID at the fan-out boundary and never surfaces in the public API +#define INVALID_CLIENT_ID -1 + // actual number of used channels in the server // this parameter can safely be changed from 1 to MAX_NUM_CHANNELS // without any other changes in the code diff --git a/src/protocol.cpp b/src/protocol.cpp index 3030c2dca1..8d4ed28d20 100644 --- a/src/protocol.cpp +++ b/src/protocol.cpp @@ -849,6 +849,10 @@ void CProtocol::ParseMessageBody ( const CVector& vecbyMesBodyData, con EvaluateChatTextMes ( vecbyMesBodyDataRef ); break; + case PROTMESSID_CHAT_TEXT_CHANNEL: + EvaluateChatTextChannelMes ( vecbyMesBodyDataRef ); + break; + case PROTMESSID_NETW_TRANSPORT_PROPS: EvaluateNetwTranspPropsMes ( vecbyMesBodyDataRef ); break; @@ -865,6 +869,14 @@ void CProtocol::ParseMessageBody ( const CVector& vecbyMesBodyData, con EvaluateSplitMessSupportedMes(); break; + case PROTMESSID_REQ_CHAT_TEXT_SUPPORT: + EvaluateReqChatTextSupportMes(); + break; + + case PROTMESSID_CHAT_TEXT_SUPPORTED: + EvaluateChatTextSupportedMes(); + break; + case PROTMESSID_RAWAUDIO_SUPPORTED: EvaluateRawAudioSupportedMes(); break; @@ -1438,6 +1450,80 @@ bool CProtocol::EvaluateChatTextMes ( const CVector& vecData ) return false; // no error } +void CProtocol::CreateChatTextChannelMes ( const uint8_t iChannelID, const uint32_t iTimestamp, const QString strSenderName, const QString strChatText ) +{ + int iPos = 0; // init position pointer + + // convert strings to utf-8 + const QByteArray strUTF8SenderName = strSenderName.toUtf8(); + const QByteArray strUTF8ChatText = strChatText.toUtf8(); + + // size of message body + const int iEntrLen = 1 + // channel ID + 4 + // timestamp + 2 + strUTF8SenderName.size() + // sender name + 2 + strUTF8ChatText.size(); // chat text + + // build data vector + CVector vecData ( iEntrLen ); + + // channel ID + PutValOnStream ( vecData, iPos, iChannelID, 1 ); + + // timestamp (epoch seconds, UTC) + PutValOnStream ( vecData, iPos, iTimestamp, 4 ); + + // sender name + PutStringUTF8OnStream ( vecData, iPos, strUTF8SenderName ); + + // chat text + PutStringUTF8OnStream ( vecData, iPos, strUTF8ChatText ); + + CreateAndSendMessage ( PROTMESSID_CHAT_TEXT_CHANNEL, vecData ); +} + +bool CProtocol::EvaluateChatTextChannelMes ( const CVector& vecData ) +{ + int iPos = 0; // init position pointer + + // check size: at minimum the channel ID, timestamp and both string length fields + if ( vecData.Size() < 1 + 4 + 2 + 2 ) + { + return true; // return error code + } + + // channel ID + const uint8_t iChannelID = static_cast ( GetValFromStream ( vecData, iPos, 1 ) ); + + // timestamp + const uint32_t iTimestamp = static_cast ( GetValFromStream ( vecData, iPos, 4 ) ); + + // sender name + QString strSenderName; + if ( GetStringFromStream ( vecData, iPos, MAX_LEN_FADER_TAG, strSenderName ) ) + { + return true; // return error code + } + + // chat text + QString strChatText; + if ( GetStringFromStream ( vecData, iPos, MAX_LEN_CHAT_TEXT, strChatText ) ) + { + return true; // return error code + } + + // check size: all data is read, the position must now be at the end + if ( iPos != vecData.Size() ) + { + return true; // return error code + } + + // invoke message action + emit ChatTextChannelReceived ( iChannelID, iTimestamp, strSenderName, strChatText ); + + return false; // no error +} + void CProtocol::CreateNetwTranspPropsMes ( const CNetworkTransportProps& NetTrProps ) { int iPos = 0; // init position pointer @@ -1584,6 +1670,26 @@ bool CProtocol::EvaluateSplitMessSupportedMes() return false; // no error } +void CProtocol::CreateReqChatTextSupportMes() { CreateAndSendMessage ( PROTMESSID_REQ_CHAT_TEXT_SUPPORT, CVector ( 0 ) ); } + +bool CProtocol::EvaluateReqChatTextSupportMes() +{ + // invoke message action + emit ReqChatTextSupport(); + + return false; // no error +} + +void CProtocol::CreateChatTextSupportedMes() { CreateAndSendMessage ( PROTMESSID_CHAT_TEXT_SUPPORTED, CVector ( 0 ) ); } + +bool CProtocol::EvaluateChatTextSupportedMes() +{ + // invoke message action + emit ChatTextSupported(); + + return false; // no error +} + void CProtocol::CreateRawAudioSupportedMes() { CreateAndSendMessage ( PROTMESSID_RAWAUDIO_SUPPORTED, CVector ( 0 ) ); } bool CProtocol::EvaluateRawAudioSupportedMes() diff --git a/src/protocol.h b/src/protocol.h index 8d4125a9ab..b47d6c5c74 100644 --- a/src/protocol.h +++ b/src/protocol.h @@ -85,6 +85,9 @@ #define PROTMESSID_REQ_SPLIT_MESS_SUPPORT 34 // request support for split messages #define PROTMESSID_SPLIT_MESS_SUPPORTED 35 // split messages are supported #define PROTMESSID_RAWAUDIO_SUPPORTED 36 // raw (uncompressed) audio is supported +#define PROTMESSID_CHAT_TEXT_CHANNEL 37 // chat text with source channel ID, timestamp and sender name +#define PROTMESSID_REQ_CHAT_TEXT_SUPPORT 38 // request support for structured chat text +#define PROTMESSID_CHAT_TEXT_SUPPORTED 39 // structured chat text is supported // message IDs of connection less messages (CLM) // DEFINITION -> start at 1000, end at 1999, see IsConnectionLessMessageID @@ -147,10 +150,13 @@ class CProtocol : public QObject void CreateChanInfoMes ( const CChannelCoreInfo ChanInfo ); void CreateReqChanInfoMes(); void CreateChatTextMes ( const QString strChatText ); + void CreateChatTextChannelMes ( const uint8_t iChannelID, const uint32_t iTimestamp, const QString strSenderName, const QString strChatText ); void CreateNetwTranspPropsMes ( const CNetworkTransportProps& NetTrProps ); void CreateReqNetwTranspPropsMes(); void CreateReqSplitMessSupportMes(); void CreateSplitMessSupportedMes(); + void CreateReqChatTextSupportMes(); + void CreateChatTextSupportedMes(); void CreateRawAudioSupportedMes(); void CreateLicenceRequiredMes ( const ELicenceType eLicenceType ); void CreateOpusSupportedMes(); @@ -284,10 +290,13 @@ class CProtocol : public QObject bool EvaluateChanInfoMes ( const CVector& vecData ); bool EvaluateReqChanInfoMes(); bool EvaluateChatTextMes ( const CVector& vecData ); + bool EvaluateChatTextChannelMes ( const CVector& vecData ); bool EvaluateNetwTranspPropsMes ( const CVector& vecData ); bool EvaluateReqNetwTranspPropsMes(); bool EvaluateReqSplitMessSupportMes(); bool EvaluateSplitMessSupportedMes(); + bool EvaluateReqChatTextSupportMes(); + bool EvaluateChatTextSupportedMes(); bool EvaluateRawAudioSupportedMes(); bool EvaluateLicenceRequiredMes ( const CVector& vecData ); bool EvaluateVersionAndOSMes ( const CVector& vecData ); @@ -350,10 +359,13 @@ public slots: void ChangeChanInfo ( CChannelCoreInfo ChanInfo ); void ReqChanInfo(); void ChatTextReceived ( QString strChatText ); + void ChatTextChannelReceived ( uint8_t iChannelID, uint32_t iTimestamp, QString strSenderName, QString strChatText ); void NetTranspPropsReceived ( CNetworkTransportProps NetworkTransportProps ); void ReqNetTranspProps(); void ReqSplitMessSupport(); void SplitMessSupported(); + void ReqChatTextSupport(); + void ChatTextSupported(); void RawAudioSupported(); void LicenceRequired ( ELicenceType eLicenceType ); void VersionAndOSReceived ( COSUtil::EOpSystemType eOSType, QString strVersion ); diff --git a/src/server.cpp b/src/server.cpp index a49eab776c..f214b47222 100644 --- a/src/server.cpp +++ b/src/server.cpp @@ -416,6 +416,9 @@ void CServer::OnNewConnection ( int iChID, int iTotChans, CHostAddress RecHostAd // query support for split messages in the client vecChannels[iChID].CreateReqSplitMessSupportMes(); + // query support for structured chat text in the client + vecChannels[iChID].CreateReqChatTextSupportMes(); + // on a new connection we query the network transport properties for the // audio packets (to use the correct network block size and audio // compression properties, etc.) @@ -1349,35 +1352,58 @@ void CServer::CreateAndSendChanListForThisChan ( const int iCurChanID ) vecChannels[iCurChanID].CreateConClientListMes ( vecChanInfo ); } -void CServer::CreateAndSendChatTextForAllConChannels ( const int iCurChanID, const QString& strChatText ) +void CServer::CreateAndSendChatTextForAllConChannels ( const int iSendingChanID, const QString& strChatText ) { - // Create message which is sent to all connected clients ------------------- - // get client name - QString ChanName = vecChannels[iCurChanID].GetName(); - - // add time and name of the client at the beginning of the message text and - // use different colors - QString sCurColor = vstrChatColors[iCurChanID % vstrChatColors.Size()]; - - const QString strActualMessageText = "(" + QTime::currentTime().toString ( "hh:mm:ss AP" ) + ") " + - ChanName.toHtmlEscaped() + " " + strChatText.toHtmlEscaped(); - - // Send chat text to all connected clients --------------------------------- - SendChatTextToAllConChannels ( iCurChanID, strActualMessageText ); -} + // clamp to the maximum chat text length up front + const QString strClampedChatText = strChatText.left ( MAX_LEN_CHAT_TEXT ); + + // timestamp stamped at fan-out (epoch seconds, UTC) + const uint32_t iTimestamp = static_cast ( QDateTime::currentSecsSinceEpoch() ); + + // determine source channel ID and sender name before indexing vecChannels; + // RPC messages (no sender channel) map to the wire sentinel ID 255 and get + // an empty sender name + int iSourceChanID = iSendingChanID; + QString strSenderName; + if ( iSendingChanID == INVALID_CLIENT_ID ) + { + iSourceChanID = SERVER_CHAT_CHANNEL_ID; + } + else + { + strSenderName = vecChannels[iSendingChanID].GetName(); + } -void CServer::SendChatTextToAllConChannels ( const int iSendingChanID, const QString& strChatText ) -{ // Send chat text to all connected clients --------------------------------- for ( int i = 0; i < iMaxNumChannels; i++ ) { if ( vecChannels[i].IsConnected() ) { - vecChannels[i].CreateChatTextMes ( strChatText ); + SendChatTextToConChannelWithSource ( i, iSourceChanID, iTimestamp, strSenderName, strClampedChatText ); } } // forward the message to the RPC server - emit sentChatMessage ( iSendingChanID, strChatText ); + emit sentChatMessage ( iSendingChanID, iTimestamp, strSenderName, strClampedChatText ); +} + +void CServer::SendChatTextToConChannelWithSource ( const int iCurChanID, const int iSourceChanID, const uint32_t iTimestamp, const QString& strSenderName, const QString& strChatText ) +{ + if ( vecChannels[iCurChanID].SupportsStructuredChat() ) + { + // structured chat text (message 37): plain data, no presentation markup + vecChannels[iCurChanID].CreateChatTextChannelMes ( iSourceChanID, iTimestamp, strSenderName, strChatText ); + } + else + { + // legacy chat text (message 18): server-generated HTML with escaped + // name and text and a per-source color + const QString sCurColor = vstrChatColors[iSourceChanID % vstrChatColors.Size()]; + + const QString strActualMessageText = "(" + QTime::currentTime().toString ( "hh:mm:ss AP" ) + ") " + + strSenderName.toHtmlEscaped() + " " + strChatText.toHtmlEscaped(); + + vecChannels[iCurChanID].CreateChatTextMes ( strActualMessageText ); + } } bool CServer::SendChatTextToConChannel ( const int iCurChanID, const QString& strChatText ) @@ -1387,8 +1413,13 @@ bool CServer::SendChatTextToConChannel ( const int iCurChanID, const QString& st { return false; } - // send message - vecChannels[iCurChanID].CreateChatTextMes ( strChatText ); + + // route the private message through the same fan-out logic as broadcast + // chat (RPC-originated: wire sentinel ID 255, no sender name) + const QString strClampedChatText = strChatText.left ( MAX_LEN_CHAT_TEXT ); + const uint32_t iTimestamp = static_cast ( QDateTime::currentSecsSinceEpoch() ); + + SendChatTextToConChannelWithSource ( iCurChanID, SERVER_CHAT_CHANNEL_ID, iTimestamp, QString(), strClampedChatText ); return true; } diff --git a/src/server.h b/src/server.h index c54c3083cd..1027d3b3e9 100644 --- a/src/server.h +++ b/src/server.h @@ -193,7 +193,7 @@ class CServer : public QObject, public CServerSlots void SetEnableDelayPanning ( bool bDelayPanningOn ) { bDelayPan = bDelayPanningOn; } bool IsDelayPanningEnabled() { return bDelayPan; } - void SendChatTextToAllConChannels ( const int iSendingChanID, const QString& strChatText ); + void CreateAndSendChatTextForAllConChannels ( const int iSendingChanID, const QString& strChatText ); bool SendChatTextToConChannel ( const int iCurChanID, const QString& strChatText ); protected: @@ -209,7 +209,7 @@ class CServer : public QObject, public CServerSlots virtual void CreateAndSendChanListForAllConChannels(); virtual void CreateAndSendChanListForThisChan ( const int iCurChanID ); - virtual void CreateAndSendChatTextForAllConChannels ( const int iCurChanID, const QString& strChatText ); + void SendChatTextToConChannelWithSource ( const int iCurChanID, const int iSourceChanID, const uint32_t iTimestamp, const QString& strSenderName, const QString& strChatText ); virtual void CreateOtherMuteStateChanged ( const int iCurChanID, const int iOtherChanID, const bool bIsMuted ); @@ -331,7 +331,7 @@ class CServer : public QObject, public CServerSlots void Stopped(); void ClientDisconnected ( const int iChID ); void ClientConnected ( const int iChID, const QHostAddress RecHostAddr, const int iTotChans ); - void sentChatMessage ( const int iSendingChanID, const QString& strChatText ); + void sentChatMessage ( const int iSendingChanID, const uint32_t iTimestamp, const QString& strSenderName, const QString& strChatText ); void SvrRegStatusChanged(); void AudioFrame ( const int iChID, const QString stChName, diff --git a/src/serverrpc.cpp b/src/serverrpc.cpp index 05bb54be62..2adc36bb39 100644 --- a/src/serverrpc.cpp +++ b/src/serverrpc.cpp @@ -47,9 +47,6 @@ #include "serverrpc.h" -/* Definitions ****************************************************************/ -#define INVALID_CLIENT_ID -1 - CServerRpc::CServerRpc ( CServer* pServer, CRpcServer* pRpcServer, QObject* parent ) : QObject ( parent ) { // API doc already part of CClientRpc @@ -85,13 +82,17 @@ CServerRpc::CServerRpc ( CServer* pServer, CRpcServer* pRpcServer, QObject* pare /// @rpc_notification jamulusserver/chatMessageReceived /// @brief Emitted when a chat message is received from either a Jamulus or RPC client and to be broadcast to all connected clients. - /// @param {number} params.id - Channel ID of sending client or -1 for RPC sent messages. - /// @param {string} params.chatMessage - Chat message text. - connect ( pServer, &CServer::sentChatMessage, [=] ( const int iSendingChanID, const QString& strChatText ) { + /// @param {number} params.channelId - Channel ID of sending client or -1 for RPC sent messages. + /// @param {number} params.timestamp - Unix timestamp (seconds) stamped at the server. + /// @param {string} params.senderName - Name of the sending client (empty for RPC sent messages). + /// @param {string} params.text - Chat message text. + connect ( pServer, &CServer::sentChatMessage, [=] ( const int iSendingChanID, const uint32_t iTimestamp, const QString& strSenderName, const QString& strChatText ) { pRpcServer->BroadcastNotification ( "jamulusserver/chatMessageReceived", QJsonObject{ - { "id", iSendingChanID }, - { "chatMessage", strChatText }, + { "channelId", iSendingChanID }, + { "timestamp", static_cast ( iTimestamp ) }, + { "senderName", strSenderName }, + { "text", strChatText }, } ); } ); @@ -109,7 +110,7 @@ CServerRpc::CServerRpc ( CServer* pServer, CRpcServer* pRpcServer, QObject* pare } // set invalid channel ID to make clear this message was not sent by a Jamulus client - pServer->SendChatTextToAllConChannels ( INVALID_CLIENT_ID, jsonChatMessage.toString() ); + pServer->CreateAndSendChatTextForAllConChannels ( INVALID_CLIENT_ID, jsonChatMessage.toString() ); response["result"] = "ok"; } ); diff --git a/src/util.cpp b/src/util.cpp index 39efa10a16..ec8a273d8c 100644 --- a/src/util.cpp +++ b/src/util.cpp @@ -46,6 +46,8 @@ #include "util.h" +#include + namespace { // Capture layout: @@ -1780,3 +1782,33 @@ QString TruncateString ( QString str, int position ) } return str.left ( position ); } + +QString EscapeAndLinkifyText ( const QString strText ) +{ + // escape first, then linkify, so that user text can never become markup + QString strEscapedText = strText.toHtmlEscaped(); + LinkifyURLs ( strEscapedText ); + return strEscapedText; +} + +void LinkifyURLs ( QString& strText ) +{ + // searches for all occurrences of http(s) and wraps them in anchor tags; + // must only be applied AFTER HTML-escaping (see EscapeAndLinkifyText) + // The regex contains three parts: + // - https?://(?:[^\s&]|&(?:amp|#\d+|#[xX][0-9a-fA-F]+);)+ matches as much as + // possible after the http:// or https://, stopping at whitespace and at a + // bare "&" that does not start an allowed HTML entity. Only & (and + // numeric entities) may appear inside a URL, so query strings such as + // ?a=1&b=2 survive escaping as ?a=1&b=2 and are kept whole, while + // escaped markup boundaries (< > ") always terminate the match. + // The last two parts exclude terminating punctuation. + // - (??\\[\\]{}]) is a negative look-behind assertion that disallows the match + // from ending with one of the characters !"'()+,.:;<=>?[]{} + // - (??\\[\\]{}]) is a negative look-behind assertion that disallows the match + // from ending with a ? followed by one of the characters !"'()+,.:;<=>?[]{} + // These last two parts must be separate, as a look-behind assertion must be fixed length. +#define PUNCT_NOEND_URL "[!\"'()+,.:;<=>?\\[\\]{}]" + strText.replace ( QRegularExpression ( "(https?://(?:[^\\s&]|&(?:amp|#\\d+|#[xX][0-9a-fA-F]+);)+(?\\1" ); +} diff --git a/src/util.h b/src/util.h index 08e45166cb..e0f1706394 100644 --- a/src/util.h +++ b/src/util.h @@ -142,6 +142,12 @@ bool IsMappedReleaseVersion ( const QString& mappedVersion ); QString MapVersionStrForCompare ( const QString& versionStr ); QString TruncateString ( QString str, int position ); +// escape plain text as HTML and then wrap bare http(s):// URLs in anchor tags; +// used for chat rendering so that user text is never interpreted as markup +QString EscapeAndLinkifyText ( const QString strText ); +// wrap bare http(s):// URLs in anchor tags in already-escaped HTML text +void LinkifyURLs ( QString& strText ); + /******************************************************************************\ * CVector Base Class * \******************************************************************************/ diff --git a/tests/chatprotocol/chatprotocol.pro b/tests/chatprotocol/chatprotocol.pro new file mode 100644 index 0000000000..08b3e95d4d --- /dev/null +++ b/tests/chatprotocol/chatprotocol.pro @@ -0,0 +1,25 @@ +QT += core network +QT -= gui +CONFIG += console +CONFIG -= app_bundle +TEMPLATE = app +TARGET = chatprotocoltest + +DEFINES += HEADLESS +DEFINES += APP_VERSION=\\\"3.12.3dev\\\" + +INCLUDEPATH += ../../src + +win32 { + DEFINES += NOMINMAX +} + +SOURCES += \ + tst_chatprotocol.cpp \ + ../../src/protocol.cpp \ + ../../src/util.cpp + +HEADERS += \ + ../../src/protocol.h \ + ../../src/util.h \ + ../../src/chatmessage.h diff --git a/tests/chatprotocol/tst_chatprotocol.cpp b/tests/chatprotocol/tst_chatprotocol.cpp new file mode 100644 index 0000000000..d22eff79c3 --- /dev/null +++ b/tests/chatprotocol/tst_chatprotocol.cpp @@ -0,0 +1,490 @@ +/******************************************************************************\ + * Standalone protocol tests for the redesigned chat message (Slice 1). + * + * Covers message 37 (structured chat text) create/evaluate round-trips and + * decode reject rules, plus the 38/39 capability handshake. + * + * Build (from the tests/chatprotocol directory): + * qmake && nmake release (or mingw32-make) + * Run: + * release/chatprotocoltest +\******************************************************************************/ + +#include +#include + +#include +#include +#include +#include + +#include "protocol.h" +#include "util.h" +#include "chatmessage.h" + +/* -------------------------------------------------------------------------- */ +// expose the protected stream helpers so that malformed bodies can be crafted +class CProtocolExposed : public CProtocol +{ +public: + using CProtocol::GetValFromStream; + using CProtocol::PutStringUTF8OnStream; + using CProtocol::PutValOnStream; +}; + +/* -------------------------------------------------------------------------- */ +// simple test framework +static int iNumTests = 0; +static int iNumFailures = 0; + +static void Check ( const bool bCondition, const char* strTestName ) +{ + iNumTests++; + if ( bCondition ) + { + std::printf ( "ok: %s\n", strTestName ); + } + else + { + iNumFailures++; + std::printf ( "FAIL: %s\n", strTestName ); + } +} + +/* -------------------------------------------------------------------------- */ +// protocol harness: captures sent frames and received signals +struct SChatReceived +{ + uint8_t iChannelID; + uint32_t iTimestamp; + QString strSenderName; + QString strChatText; +}; + +class CProtocolHarness +{ +public: + CProtocolExposed Prot; + QVector> vecSentFrames; + QVector vecChatReceived; + int iReqChatTextSupport; + int iChatTextSupported; + + CProtocolHarness() + { + iReqChatTextSupport = 0; + iChatTextSupported = 0; + + QObject::connect ( &Prot, &CProtocol::MessReadyForSending, + [this] ( CVector vecMessage ) { vecSentFrames.append ( vecMessage ); } ); + + QObject::connect ( &Prot, &CProtocol::ChatTextChannelReceived, + [this] ( uint8_t iChannelID, uint32_t iTimestamp, QString strSenderName, QString strChatText ) { + vecChatReceived.append ( SChatReceived{ iChannelID, iTimestamp, strSenderName, strChatText } ); + } ); + + QObject::connect ( &Prot, &CProtocol::ReqChatTextSupport, [this]() { iReqChatTextSupport++; } ); + + QObject::connect ( &Prot, &CProtocol::ChatTextSupported, [this]() { iChatTextSupported++; } ); + } + + void Reset() + { + vecSentFrames.clear(); + vecChatReceived.clear(); + iReqChatTextSupport = 0; + iChatTextSupported = 0; + Prot.Reset(); + } +}; + +// Deliver every frame Tx emitted (immediately or after the simulated ACK +// handshake, which is required to drain split messages) to Rx, so that Rx +// fully reassembles split messages and evaluates them. +static void DeliverAll ( CProtocolHarness& Tx, CProtocolHarness& Rx ) +{ + int iTxFrameIdx = 0; + int iRxFrameIdx = 0; + + while ( true ) + { + bool bProgress = false; + + // deliver any new frames from Tx to Rx + for ( ; iTxFrameIdx < Tx.vecSentFrames.size(); iTxFrameIdx++ ) + { + CVector vecBody; + int iCnt = 0; + int iID = 0; + + if ( CProtocol::ParseMessageFrame ( Tx.vecSentFrames[iTxFrameIdx], Tx.vecSentFrames[iTxFrameIdx].Size(), vecBody, iCnt, iID ) ) + { + continue; + } + Rx.Prot.ParseMessageBody ( vecBody, iCnt, iID ); + bProgress = true; + } + + // feed ACKs Rx emitted back to Tx (each ACK drains one queued message) + for ( ; iRxFrameIdx < Rx.vecSentFrames.size(); iRxFrameIdx++ ) + { + CVector vecBody; + int iCnt = 0; + int iID = 0; + + if ( CProtocol::ParseMessageFrame ( Rx.vecSentFrames[iRxFrameIdx], Rx.vecSentFrames[iRxFrameIdx].Size(), vecBody, iCnt, iID ) ) + { + continue; + } + if ( iID == PROTMESSID_ACKN ) + { + Tx.Prot.ParseMessageBody ( vecBody, iCnt, iID ); + bProgress = true; + } + } + + if ( !bProgress ) + { + break; + } + } +} + +/* -------------------------------------------------------------------------- */ +int main ( int argc, char* argv[] ) +{ + QCoreApplication app ( argc, argv ); + + CProtocolHarness Tx; + CProtocolHarness Rx; + + // --- message 37 round-trips (non-split) -------------------------------- + { + const uint8_t iChannelID = 7; + const uint32_t iTimestamp = 1786298460U; + const QString strName = "Alice"; + const QString strText = "hello"; + + Tx.Prot.CreateChatTextChannelMes ( iChannelID, iTimestamp, strName, strText ); + DeliverAll ( Tx, Rx ); + + Check ( Rx.vecChatReceived.size() == 1, "37 round-trip: exactly one message received" ); + if ( Rx.vecChatReceived.size() == 1 ) + { + Check ( Rx.vecChatReceived[0].iChannelID == iChannelID, "37 round-trip: channel ID" ); + Check ( Rx.vecChatReceived[0].iTimestamp == iTimestamp, "37 round-trip: timestamp" ); + Check ( Rx.vecChatReceived[0].strSenderName == strName, "37 round-trip: sender name" ); + Check ( Rx.vecChatReceived[0].strChatText == strText, "37 round-trip: text" ); + } + Tx.Reset(); + Rx.Reset(); + } + + // UTF-8 text and name + { + const uint8_t iChannelID = 3; + const uint32_t iTimestamp = 1700000000U; + const QString strName = QString::fromUtf8 ( "\xc3\x9c""n\xc3\xaf""code \xe2\x98\xba" ); // "Ünïcode ☺" + const QString strText = QString::fromUtf8 ( "h\xc3\xa9""llo w\xc3\xb6""rld \xe6\x97\xa5\xe6\x9c\xac\xe8\xaa\x9e" ); // "héllo wörld 日本語" + + Tx.Prot.CreateChatTextChannelMes ( iChannelID, iTimestamp, strName, strText ); + DeliverAll ( Tx, Rx ); + + Check ( Rx.vecChatReceived.size() == 1, "37 UTF-8: message received" ); + if ( Rx.vecChatReceived.size() == 1 ) + { + Check ( Rx.vecChatReceived[0].iChannelID == iChannelID, "37 UTF-8: channel ID" ); + Check ( Rx.vecChatReceived[0].iTimestamp == iTimestamp, "37 UTF-8: timestamp" ); + Check ( Rx.vecChatReceived[0].strSenderName == strName, "37 UTF-8: sender name" ); + Check ( Rx.vecChatReceived[0].strChatText == strText, "37 UTF-8: text" ); + } + Tx.Reset(); + Rx.Reset(); + } + + // empty name and empty text + { + Tx.Prot.CreateChatTextChannelMes ( 0, 0U, QString(), QString() ); + DeliverAll ( Tx, Rx ); + + Check ( Rx.vecChatReceived.size() == 1, "37 empty: message received" ); + if ( Rx.vecChatReceived.size() == 1 ) + { + Check ( Rx.vecChatReceived[0].iChannelID == 0, "37 empty: channel ID" ); + Check ( Rx.vecChatReceived[0].iTimestamp == 0U, "37 empty: timestamp" ); + Check ( Rx.vecChatReceived[0].strSenderName.isEmpty(), "37 empty: sender name" ); + Check ( Rx.vecChatReceived[0].strChatText.isEmpty(), "37 empty: text" ); + } + Tx.Reset(); + Rx.Reset(); + } + + // boundary channel IDs (0, 149 and the 255 server/RPC sentinel) + { + const uint8_t aiChannelIDs[3] = { 0, MAX_NUM_CHANNELS - 1, SERVER_CHAT_CHANNEL_ID }; + const char* astrChannelNames[3] = { "channel 0", "channel 149", "channel 255" }; + + for ( int i = 0; i < 3; i++ ) + { + Tx.Prot.CreateChatTextChannelMes ( aiChannelIDs[i], 100U, "n", "t" ); + DeliverAll ( Tx, Rx ); + + Check ( ( Rx.vecChatReceived.size() == 1 ) && ( Rx.vecChatReceived[0].iChannelID == aiChannelIDs[i] ), + astrChannelNames[i] ); + Tx.Reset(); + Rx.Reset(); + } + } + + // maximum length text (MAX_LEN_CHAT_TEXT == 1600), sent unsplit + { + const QString strText ( MAX_LEN_CHAT_TEXT, QChar ( 'a' ) ); + Tx.Prot.CreateChatTextChannelMes ( 1, 100U, "n", strText ); + DeliverAll ( Tx, Rx ); + + Check ( ( Rx.vecChatReceived.size() == 1 ) && ( Rx.vecChatReceived[0].strChatText == strText ), + "37 max length text round-trip" ); + Tx.Reset(); + Rx.Reset(); + } + + // large text which takes the split message path (body > MESS_SPLIT_PART_SIZE_BYTES) + { + Tx.Prot.SetSplitMessageSupported ( true ); + const QString strText ( MAX_LEN_CHAT_TEXT, QChar ( 'x' ) ); + Tx.Prot.CreateChatTextChannelMes ( 5, 200U, "split-test", strText ); + DeliverAll ( Tx, Rx ); + + Check ( Rx.vecChatReceived.size() == 1, "37 split: message reassembled" ); + if ( Rx.vecChatReceived.size() == 1 ) + { + Check ( Rx.vecChatReceived[0].iChannelID == 5, "37 split: channel ID" ); + Check ( Rx.vecChatReceived[0].iTimestamp == 200U, "37 split: timestamp" ); + Check ( Rx.vecChatReceived[0].strSenderName == "split-test", "37 split: sender name" ); + Check ( Rx.vecChatReceived[0].strChatText == strText, "37 split: text" ); + } + Tx.Reset(); + Rx.Reset(); + } + + // --- ChatMessage data model (Slice 2) ----------------------------------- + { + // a ChatMessage carries the wire facts verbatim: channel ID, epoch + // timestamp, the sender name snapshot and plain text (never HTML) + ChatMessage msg{ 12, 1786298460U, "Alice", "hello & " }; + + Check ( msg.channelId == 12, "ChatMessage: channel ID" ); + Check ( msg.timestamp == 1786298460U, "ChatMessage: timestamp" ); + Check ( msg.senderName == "Alice", "ChatMessage: sender name" ); + Check ( msg.text == "hello & ", "ChatMessage: text is plain data, not markup" ); + + // text never silently changes representation between wire and model + const QString strText = QString::fromUtf8 ( "h\xc3\xa9""llo w\xc3\xb6""rld \xe6\x97\xa5\xe6\x9c\xac\xe8\xaa\x9e" ); // "héllo wörld 日本語" + msg.text = strText; + Check ( msg.text == strText, "ChatMessage: UTF-8 text preserved" ); + + // the wire fields of a message 37 round-trip map 1:1 into a ChatMessage + { + const uint8_t iChannelID = 9; + const uint32_t iTimestamp = 1786300000U; + const QString strName = "Bob"; + const QString strText = "round trip"; + + Tx.Prot.CreateChatTextChannelMes ( iChannelID, iTimestamp, strName, strText ); + DeliverAll ( Tx, Rx ); + + bool bMapped = ( Rx.vecChatReceived.size() == 1 ); + if ( bMapped ) + { + const ChatMessage msgRoundTrip{ Rx.vecChatReceived[0].iChannelID, + Rx.vecChatReceived[0].iTimestamp, + Rx.vecChatReceived[0].strSenderName, + Rx.vecChatReceived[0].strChatText }; + bMapped = ( msgRoundTrip.channelId == iChannelID ) && ( msgRoundTrip.timestamp == iTimestamp ) && + ( msgRoundTrip.senderName == strName ) && ( msgRoundTrip.text == strText ); + } + Check ( bMapped, "ChatMessage: wire fields map 1:1 into the data model" ); + Tx.Reset(); + Rx.Reset(); + } + + // server/RPC-originated messages carry the wire sentinel channel ID + { + const ChatMessage msgServer{ SERVER_CHAT_CHANNEL_ID, 100U, QString(), "server note" }; + Check ( msgServer.channelId == 255, "ChatMessage: server/RPC sentinel channel ID" ); + Check ( msgServer.senderName.isEmpty(), "ChatMessage: server/RPC messages have no sender name" ); + } + } + + // --- safe chat text rendering (Slice 3) --------------------------------- + // invariant: user text is escaped before linkification and can never become + // executable or unintended HTML + { + // markup is escaped, never interpreted + const QString strEscapedBold = EscapeAndLinkifyText ( "hello" ); + Check ( !strEscapedBold.contains ( "" ) && strEscapedBold.contains ( "<b>hello</b>" ), + "safe render: is escaped" ); + + const QString strEscapedImg = EscapeAndLinkifyText ( "" ); + Check ( !strEscapedImg.contains ( " is escaped" ); + + const QString strEscapedScript = EscapeAndLinkifyText ( "" ); + Check ( !strEscapedScript.contains ( " is escaped" ); + + // bare http(s) URLs are wrapped after escaping + const QString strLinked = EscapeAndLinkifyText ( "see https://example.com now" ); + Check ( strLinked.contains ( "https://example.com" ), + "safe render: bare URL is linkified" ); + + // terminating punctuation is excluded from the link + const QString strPunct = EscapeAndLinkifyText ( "https://example.com." ); + Check ( strPunct.contains ( "https://example.com." ), + "safe render: trailing punctuation excluded from link" ); + + // query-string URLs survive HTML-escaping and stay whole (the & becomes + // &, which must be kept inside the link so the href is not truncated) + const QString strQuery = EscapeAndLinkifyText ( "watch https://example.com/watch?v=X&t=5 now" ); + Check ( strQuery.contains ( "https://example.com/watch?v=X&t=5" ), + "safe render: query-string URL kept whole" ); + + // a bare & in text is escaped and does not swallow surrounding words + const QString strAmp = EscapeAndLinkifyText ( "a & b then https://x.com/?y=1&z=2" ); + Check ( strAmp.contains ( "a & b" ) && strAmp.contains ( "https://x.com/?y=1&z=2" ), + "safe render: entity boundary respected in plain text" ); + + // user-authored HTML around a URL is escaped, then the URL is linkified + const QString strMixed = EscapeAndLinkifyText ( "x" ); + Check ( strMixed.contains ( "<a href="" ) && strMixed.contains ( "https://example.com" ), + "safe render: HTML+URL combination stays escaped" ); + + // legacy path: LinkifyURLs leaves already-generated HTML tags intact + QString strLegacy = "(time) name https://example.com"; + LinkifyURLs ( strLegacy ); + Check ( strLegacy.contains ( "" ) && strLegacy.contains ( "name" ) && + strLegacy.contains ( "https://example.com" ), + "safe render: legacy HTML is preserved and URLs linkified" ); + } + + // --- message 37 decode reject rules ------------------------------------ + // text over MAX_LEN_CHAT_TEXT + { + CProtocolExposed Tmp; + CVector vecBody ( 1 + 4 + 2 + 4 + 2 + ( MAX_LEN_CHAT_TEXT + 1 ) ); + int iPos = 0; + Tmp.PutValOnStream ( vecBody, iPos, 1, 1 ); + Tmp.PutValOnStream ( vecBody, iPos, 100U, 4 ); + Tmp.PutStringUTF8OnStream ( vecBody, iPos, QByteArray ( "name" ) ); + Tmp.PutStringUTF8OnStream ( vecBody, iPos, QByteArray ( MAX_LEN_CHAT_TEXT + 1, 'a' ) ); + + Rx.Prot.ParseMessageBody ( vecBody, 0, PROTMESSID_CHAT_TEXT_CHANNEL ); + Check ( Rx.vecChatReceived.size() == 0, "37 reject: text over MAX_LEN_CHAT_TEXT" ); + Rx.Reset(); + } + + // name over MAX_LEN_FADER_TAG + { + CProtocolExposed Tmp; + CVector vecBody ( 1 + 4 + 2 + ( MAX_LEN_FADER_TAG + 1 ) + 2 + 3 ); + int iPos = 0; + Tmp.PutValOnStream ( vecBody, iPos, 1, 1 ); + Tmp.PutValOnStream ( vecBody, iPos, 100U, 4 ); + Tmp.PutStringUTF8OnStream ( vecBody, iPos, QByteArray ( MAX_LEN_FADER_TAG + 1, 'n' ) ); + Tmp.PutStringUTF8OnStream ( vecBody, iPos, QByteArray ( "abc" ) ); + + Rx.Prot.ParseMessageBody ( vecBody, 0, PROTMESSID_CHAT_TEXT_CHANNEL ); + Check ( Rx.vecChatReceived.size() == 0, "37 reject: name over MAX_LEN_FADER_TAG" ); + Rx.Reset(); + } + + // truncated body (channel ID and timestamp present but too short overall) + { + CProtocolExposed Tmp; + CVector vecBody ( 1 + 3 ); + int iPos = 0; + Tmp.PutValOnStream ( vecBody, iPos, 1, 1 ); + Tmp.PutValOnStream ( vecBody, iPos, 0x1234U, 3 ); // truncated timestamp + + Rx.Prot.ParseMessageBody ( vecBody, 0, PROTMESSID_CHAT_TEXT_CHANNEL ); + Check ( Rx.vecChatReceived.size() == 0, "37 reject: truncated body" ); + Rx.Reset(); + } + + // malformed text length (claims more bytes than available) + { + CProtocolExposed Tmp; + CVector vecBody ( 1 + 4 + 2 + 4 + 2 + 10 ); + int iPos = 0; + Tmp.PutValOnStream ( vecBody, iPos, 1, 1 ); + Tmp.PutValOnStream ( vecBody, iPos, 100U, 4 ); + Tmp.PutStringUTF8OnStream ( vecBody, iPos, QByteArray ( "name" ) ); + Tmp.PutValOnStream ( vecBody, iPos, 100, 2 ); // text length claims 100 bytes + for ( int i = 0; i < 10; i++ ) + { + Tmp.PutValOnStream ( vecBody, iPos, 'x', 1 ); // only 10 bytes present + } + + Rx.Prot.ParseMessageBody ( vecBody, 0, PROTMESSID_CHAT_TEXT_CHANNEL ); + Check ( Rx.vecChatReceived.size() == 0, "37 reject: malformed text length" ); + Rx.Reset(); + } + + // trailing data after the chat text + { + CProtocolExposed Tmp; + CVector vecBody ( 1 + 4 + 2 + 4 + 2 + 3 + 1 ); + int iPos = 0; + Tmp.PutValOnStream ( vecBody, iPos, 1, 1 ); + Tmp.PutValOnStream ( vecBody, iPos, 100U, 4 ); + Tmp.PutStringUTF8OnStream ( vecBody, iPos, QByteArray ( "name" ) ); + Tmp.PutStringUTF8OnStream ( vecBody, iPos, QByteArray ( "abc" ) ); + Tmp.PutValOnStream ( vecBody, iPos, 0, 1 ); // trailing byte + + Rx.Prot.ParseMessageBody ( vecBody, 0, PROTMESSID_CHAT_TEXT_CHANNEL ); + Check ( Rx.vecChatReceived.size() == 0, "37 reject: trailing data" ); + Rx.Reset(); + } + + // --- capability handshake 38/39 ---------------------------------------- + { + Tx.Prot.CreateReqChatTextSupportMes(); + DeliverAll ( Tx, Rx ); + Check ( Rx.iReqChatTextSupport == 1, "38: ReqChatTextSupport received" ); + Tx.Reset(); + Rx.Reset(); + } + { + Tx.Prot.CreateChatTextSupportedMes(); + DeliverAll ( Tx, Rx ); + Check ( Rx.iChatTextSupported == 1, "39: ChatTextSupported received" ); + Tx.Reset(); + Rx.Reset(); + } + + // unknown message IDs are acknowledged but not evaluated (no silent loss) + { + Rx.Prot.ParseMessageBody ( CVector ( 0 ), 0, 40 ); + Check ( Rx.vecChatReceived.size() == 0, "unknown message: not evaluated" ); + + bool bAckSent = false; + for ( const CVector& vecFrame : Rx.vecSentFrames ) + { + CVector vecBody; + int iCnt = 0; + int iID = 0; + if ( !CProtocol::ParseMessageFrame ( vecFrame, vecFrame.Size(), vecBody, iCnt, iID ) && ( iID == PROTMESSID_ACKN ) ) + { + bAckSent = true; + break; + } + } + Check ( bAckSent, "unknown message: acknowledged unconditionally" ); + Rx.Reset(); + } + + // --- summary ----------------------------------------------------------- + std::printf ( "\n%d tests, %d failure(s)\n", iNumTests, iNumFailures ); + + return ( iNumFailures == 0 ) ? 0 : 1; +} diff --git a/tools/generate_json_rpc_docs.py b/tools/generate_json_rpc_docs.py index 0495c4b1af..752a373095 100755 --- a/tools/generate_json_rpc_docs.py +++ b/tools/generate_json_rpc_docs.py @@ -303,7 +303,7 @@ def to_markdown(self): Jamulus will also send **notifications** to the consumer: ```json -{"jsonrpc":"2.0","method":"jamulusclient/chatTextReceived","params":{"text":"(01:23:45 AM) user test"}} +{"jsonrpc":"2.0","method":"jamulusclient/chatTextReceived","params":{"channelId":12,"timestamp":1786298460,"senderName":"user","text":"test"}} ``` """