From 39cf0f282711e7a795449afffc284896e5361cec Mon Sep 17 00:00:00 2001 From: ingalls Date: Fri, 3 Jul 2026 22:34:23 -0600 Subject: [PATCH 1/3] Read Receipts --- CHANGELOG.md | 5 ++ lib/builders/chat.ts | 74 +++++++++++++++++++ lib/cot.ts | 12 ++++ lib/parser/to_geojson.ts | 5 ++ lib/types/types.ts | 1 + test/chat.test.ts | 149 ++++++++++++++++++++++++++++++++++++++- 6 files changed, 245 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9106741..0fbf188 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,11 @@ ### Pending Fixed +- :tada: Add support for parsing Chat Receipt CoTs (`b-t-f-d`, `b-t-f-r`, `b-t-f-p`, `b-t-f-s`) - the ATAK style `__chatreceipt` detail is now included in the `Detail` schema and populates `properties.chat` in `to_geojson` +- :tada: Add `DirectChatReceipt` builder for generating Chat Receipt CoTs +- :tada: Add `CoT.is_chat_receipt()` helper +- :white_check_mark: Add tests for ATAK (`__chatreceipt`) & WinTAK (`__chat`) style Chat Receipts + ### v14.43.2 - 2025-07-03 - :arrow_up: Update protobuf diff --git a/lib/builders/chat.ts b/lib/builders/chat.ts index 97d1297..2fbd659 100644 --- a/lib/builders/chat.ts +++ b/lib/builders/chat.ts @@ -89,6 +89,80 @@ export type DirectChatMember = { callsign: string; } +export const DirectChatReceiptStatus = { + delivered: 'b-t-f-d', + read: 'b-t-f-r', + pending: 'b-t-f-p', + failed: 'b-t-f-s' +} as const; + +export type DirectChatReceiptInput = { + to: DirectChatMember; + from: DirectChatMember; + + status: keyof typeof DirectChatReceiptStatus; + messageId: string; + + parent?: string; + chatroom?: string; + groupOwner?: boolean; +} + +/** + * Delivery/Read Receipt for a DirectChat message + * The `to` member is the sender of the original message that the receipt + * refers to and `messageId` is the original message's messageId + */ +export class DirectChatReceipt extends CoT { + constructor(receipt: DirectChatReceiptInput) { + const type = DirectChatReceiptStatus[receipt.status]; + + const cot: Static = { + event: { + _attributes: Util.cot_event_attr(type, 'h-g-i-g-o'), + point: Util.cot_point(), + detail: { + __chatreceipt: { + _attributes: { + parent: receipt.parent || 'RootContactGroup', + groupOwner: receipt.groupOwner ? 'true' : 'false', + messageId: receipt.messageId, + chatroom: receipt.chatroom || receipt.to.callsign, + id: receipt.to.uid, + senderCallsign: receipt.from.callsign + }, + chatgrp: { + _attributes: { + uid0: receipt.from.uid, + uid1: receipt.to.uid, + id: receipt.to.uid + } + } + }, + } + } + } + + cot.event._attributes.uid = `${receipt.messageId}.${type.slice(-1)}`; + + if (!cot.event.detail) cot.event.detail = {}; + + cot.event.detail.link = { + _attributes: { + uid: receipt.from.uid, + type: 'a-f-G', + relation: 'p-p' + } + } + + super(cot) + + this.addDest({ + uid: receipt.to.uid + }) + } +} + export type DirectChatInput = { to: DirectChatMember; from: DirectChatMember; diff --git a/lib/cot.ts b/lib/cot.ts index 417305c..c00b0d2 100644 --- a/lib/cot.ts +++ b/lib/cot.ts @@ -334,6 +334,18 @@ export default class CoT { return !!(this.raw.event.detail && this.raw.event.detail.__chat); } + /** + * Determines if the CoT message represents a Chat Receipt + * Note: ATAK sends receipts with a `__chatreceipt` detail element, while + * WinTAK reuses the `__chat` element - the CoT type is authoritative + * + * @return {boolean} + */ + is_chat_receipt(): boolean { + return !!(this.raw.event.detail && this.raw.event.detail.__chatreceipt) + || ['b-t-f-d', 'b-t-f-r', 'b-t-f-p', 'b-t-f-s'].includes(this.raw.event._attributes.type); + } + /** * Determines if the CoT message represents a Friendly Element * diff --git a/lib/parser/to_geojson.ts b/lib/parser/to_geojson.ts index c5028b8..793b35b 100644 --- a/lib/parser/to_geojson.ts +++ b/lib/parser/to_geojson.ts @@ -255,6 +255,11 @@ export async function to_geojson( ...raw.event.detail.__chat._attributes, chatgrp: raw.event.detail.__chat.chatgrp } + } else if (raw.event.detail.__chatreceipt) { + feat.properties.chat = { + ...raw.event.detail.__chatreceipt._attributes, + chatgrp: raw.event.detail.__chatreceipt.chatgrp + } } if (raw.event.detail.track?._attributes) { diff --git a/lib/types/types.ts b/lib/types/types.ts index 62a986a..9fd5c05 100644 --- a/lib/types/types.ts +++ b/lib/types/types.ts @@ -1211,6 +1211,7 @@ export const Detail = Type.Object({ tog: Type.Optional(TogAttributes), '__group': Type.Optional(Group), '__chat': Type.Optional(Chat), + '__chatreceipt': Type.Optional(Chat), '__video': Type.Optional(Video), '__geofence': Type.Optional(Geofence), '__milsym': Type.Optional(Milsym), diff --git a/test/chat.test.ts b/test/chat.test.ts index a164c17..e74a28e 100644 --- a/test/chat.test.ts +++ b/test/chat.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { DirectChat, MissionChat, CoTParser } from '../index.js'; +import { DirectChat, DirectChatReceipt, MissionChat, CoTParser } from '../index.js'; test('MissionChat - GeoJSON output', async () => { const messageId = '6c19bcd5-c632-4c59-95e2-ae8c76c8feab'; @@ -178,3 +178,150 @@ test('DirectChat - Basic', () => { }); } }); + +test('DirectChatReceipt - Parse ATAK style __chatreceipt', async () => { + const cot = CoTParser.from_xml(` + + + + <__chatreceipt parent="RootContactGroup" groupOwner="false" messageId="f77dae64-3573-4dae-8447-c0c7a0219d7a" chatroom="DFPC Ingalls" id="ANDROID-CloudTAK-admin" senderCallsign="TR-SSC"> + + + + + + `); + + assert.equal(cot.is_chat(), false); + assert.equal(cot.is_chat_receipt(), true); + + const feat = await CoTParser.to_geojson(cot); + + assert.equal(feat.properties.type, 'b-t-f-d'); + assert.deepEqual(feat.properties.chat, { + parent: 'RootContactGroup', + groupOwner: 'false', + messageId: 'f77dae64-3573-4dae-8447-c0c7a0219d7a', + chatroom: 'DFPC Ingalls', + id: 'ANDROID-CloudTAK-admin', + senderCallsign: 'TR-SSC', + chatgrp: { + _attributes: { + uid0: 'ANDROID-121345', + uid1: 'ANDROID-CloudTAK-admin', + id: 'ANDROID-CloudTAK-admin' + } + } + }); +}); + +test('DirectChatReceipt - Parse WinTAK style __chat receipt', async () => { + const cot = CoTParser.from_xml(` + + + + <__chat id="ANDROID-CloudTAK-admin" chatroom="DFPC Ingalls" senderCallsign="TR-SSC" groupOwner="false" messageId="f77dae64-3573-4dae-8447-c0c7a0219d7a"> + + + + + + `); + + assert.equal(cot.is_chat_receipt(), true); + + const feat = await CoTParser.to_geojson(cot); + + assert.equal(feat.properties.type, 'b-t-f-r'); + assert.equal(feat.properties.remarks, undefined); + assert.deepEqual(feat.properties.chat, { + groupOwner: 'false', + messageId: 'f77dae64-3573-4dae-8447-c0c7a0219d7a', + chatroom: 'DFPC Ingalls', + id: 'ANDROID-CloudTAK-admin', + senderCallsign: 'TR-SSC', + chatgrp: { + _attributes: { + id: 'ANDROID-CloudTAK-admin', + uid0: 'ANDROID-121345', + uid1: 'ANDROID-CloudTAK-admin' + } + } + }); +}); + +test('DirectChatReceipt - Builder', async () => { + const cot = new DirectChatReceipt({ + to: { + uid: '123456', + callsign: 'Alpha Operator' + }, + from: { + uid: '654321', + callsign: 'Bravo Operator' + }, + status: 'read', + messageId: 'c0dfa5d7-27c2-4f4a-a4d6-a4d0d4d9b3c1' + }); + + assert.equal(cot.is_chat(), false); + assert.equal(cot.is_chat_receipt(), true); + + assert.equal(typeof cot.raw.event._attributes.time, 'string'); + cot.raw.event._attributes.time = '2024-04-01' + assert.equal(typeof cot.raw.event._attributes.start, 'string'); + cot.raw.event._attributes.start = '2024-04-01' + assert.equal(typeof cot.raw.event._attributes.stale, 'string'); + cot.raw.event._attributes.stale = '2024-04-01' + + assert.deepEqual(cot.raw, { + event: { + _attributes: { + uid: 'c0dfa5d7-27c2-4f4a-a4d6-a4d0d4d9b3c1.r', + version: '2.0', + type: 'b-t-f-r', + how: 'h-g-i-g-o', + + time: '2024-04-01', + stale: '2024-04-01', + start: '2024-04-01' + }, + point: { + _attributes: { lat: 0.000000, lon: 0.000000, hae: 0.0, ce: 9999999.0, le: 9999999.0 } + }, + detail: { + __chatreceipt: { + _attributes: { + parent: 'RootContactGroup', + groupOwner: 'false', + messageId: 'c0dfa5d7-27c2-4f4a-a4d6-a4d0d4d9b3c1', + chatroom: 'Alpha Operator', + id: '123456', + senderCallsign: 'Bravo Operator' + }, + chatgrp: { + _attributes: { + uid0: '654321', + uid1: '123456', + id: '123456' + } + } + }, + marti: { + dest: [{ + _attributes: { + uid: '123456', + } + }], + }, + link: { + _attributes: { + uid: '654321', + type: 'a-f-G', + relation: 'p-p' + } + } + } + } + }); +}); From 0614611f1002d0b7bddbed9d901e1dfc1649d175 Mon Sep 17 00:00:00 2001 From: ingalls Date: Fri, 3 Jul 2026 22:42:31 -0600 Subject: [PATCH 2/3] Minor messageId change --- lib/builders/chat.ts | 4 +++- test/chat.test.ts | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/builders/chat.ts b/lib/builders/chat.ts index 2fbd659..564719e 100644 --- a/lib/builders/chat.ts +++ b/lib/builders/chat.ts @@ -143,7 +143,9 @@ export class DirectChatReceipt extends CoT { } } - cot.event._attributes.uid = `${receipt.messageId}.${type.slice(-1)}`; + // ATAK & WinTAK set the receipt Event UID to the original messageId and + // ATAK looks the original message up by the receipt's Event UID on receive + cot.event._attributes.uid = receipt.messageId; if (!cot.event.detail) cot.event.detail = {}; diff --git a/test/chat.test.ts b/test/chat.test.ts index e74a28e..b6eb67d 100644 --- a/test/chat.test.ts +++ b/test/chat.test.ts @@ -277,7 +277,7 @@ test('DirectChatReceipt - Builder', async () => { assert.deepEqual(cot.raw, { event: { _attributes: { - uid: 'c0dfa5d7-27c2-4f4a-a4d6-a4d0d4d9b3c1.r', + uid: 'c0dfa5d7-27c2-4f4a-a4d6-a4d0d4d9b3c1', version: '2.0', type: 'b-t-f-r', how: 'h-g-i-g-o', From 5f57adb704e22daf794a675c20e6f599dbc88aaa Mon Sep 17 00:00:00 2001 From: ingalls Date: Fri, 3 Jul 2026 22:47:06 -0600 Subject: [PATCH 3/3] Exclude Chat Receipts from is_chat() & test/style fixes per review --- lib/cot.ts | 5 ++++- test/chat.test.ts | 7 ++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/lib/cot.ts b/lib/cot.ts index c00b0d2..88a311e 100644 --- a/lib/cot.ts +++ b/lib/cot.ts @@ -327,11 +327,14 @@ export default class CoT { /** * Determines if the CoT message represents a Chat Message + * Note: Chat Receipts are excluded even when they reuse the `__chat` + * detail element (WinTAK style) - see `is_chat_receipt()` * * @return {boolean} */ is_chat(): boolean { - return !!(this.raw.event.detail && this.raw.event.detail.__chat); + return !!(this.raw.event.detail && this.raw.event.detail.__chat) + && !this.is_chat_receipt(); } /** diff --git a/test/chat.test.ts b/test/chat.test.ts index b6eb67d..b56aee5 100644 --- a/test/chat.test.ts +++ b/test/chat.test.ts @@ -228,6 +228,7 @@ test('DirectChatReceipt - Parse WinTAK style __chat receipt', async () => { `); + assert.equal(cot.is_chat(), false); assert.equal(cot.is_chat_receipt(), true); const feat = await CoTParser.to_geojson(cot); @@ -268,11 +269,11 @@ test('DirectChatReceipt - Builder', async () => { assert.equal(cot.is_chat_receipt(), true); assert.equal(typeof cot.raw.event._attributes.time, 'string'); - cot.raw.event._attributes.time = '2024-04-01' + cot.raw.event._attributes.time = '2024-04-01'; assert.equal(typeof cot.raw.event._attributes.start, 'string'); - cot.raw.event._attributes.start = '2024-04-01' + cot.raw.event._attributes.start = '2024-04-01'; assert.equal(typeof cot.raw.event._attributes.stale, 'string'); - cot.raw.event._attributes.stale = '2024-04-01' + cot.raw.event._attributes.stale = '2024-04-01'; assert.deepEqual(cot.raw, { event: {