Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
76 changes: 76 additions & 0 deletions lib/builders/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,82 @@ 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<typeof JSONCoT> = {
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
}
}
},
}
}
}

// 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 = {};

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;
Expand Down
17 changes: 16 additions & 1 deletion lib/cot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -327,11 +327,26 @@ 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();
}

/**
* 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);
}
Comment thread
ingalls marked this conversation as resolved.

/**
Expand Down
5 changes: 5 additions & 0 deletions lib/parser/to_geojson.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions lib/types/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
150 changes: 149 additions & 1 deletion test/chat.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -178,3 +178,151 @@ test('DirectChat - Basic', () => {
});
}
});

test('DirectChatReceipt - Parse ATAK style __chatreceipt', async () => {
const cot = CoTParser.from_xml(`
<event version="2.0" uid="f77dae64-3573-4dae-8447-c0c7a0219d7a.d" type="b-t-f-d" how="h-g-i-g-o" time="2026-07-02T16:19:11Z" start="2026-07-02T16:19:15Z" stale="2026-07-03T16:19:15Z">
<point lat="49.2695677890608" lon="-123.112677849291" hae="-11.93099513512" ce="9999999" le="9999999"/>
<detail>
<__chatreceipt parent="RootContactGroup" groupOwner="false" messageId="f77dae64-3573-4dae-8447-c0c7a0219d7a" chatroom="DFPC Ingalls" id="ANDROID-CloudTAK-admin" senderCallsign="TR-SSC">
<chatgrp uid0="ANDROID-121345" uid1="ANDROID-CloudTAK-admin" id="ANDROID-CloudTAK-admin"/>
</__chatreceipt>
<link uid="ANDROID-121345" type="a-f-G-U-C-I" relation="p-p"/>
</detail>
</event>
`);

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(`
<event version="2.0" uid="f77dae64-3573-4dae-8447-c0c7a0219d7a" type="b-t-f-r" how="h-g-i-g-o" time="2026-07-02T16:19:28Z" start="2026-07-02T16:19:31Z" stale="2026-07-03T16:19:31Z" access="Undefined">
<point lat="49.2695677890608" lon="-123.112677849291" hae="-11.93099513512" ce="9999999" le="9999999"/>
<detail>
<__chat id="ANDROID-CloudTAK-admin" chatroom="DFPC Ingalls" senderCallsign="TR-SSC" groupOwner="false" messageId="f77dae64-3573-4dae-8447-c0c7a0219d7a">
<chatgrp id="ANDROID-CloudTAK-admin" uid0="ANDROID-121345" uid1="ANDROID-CloudTAK-admin"/>
</__chat>
<link uid="ANDROID-121345" type="a-f-G-U-C-I" relation="p-p"/>
</detail>
</event>
`);

assert.equal(cot.is_chat(), false);
assert.equal(cot.is_chat_receipt(), true);

Comment thread
ingalls marked this conversation as resolved.
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',
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'
}
}
}
}
});
});
Loading