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
1 change: 1 addition & 0 deletions packages/core/src/events/edu/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,6 @@ export * from './base';

export * from './m.typing';
export * from './m.presence';
export * from './m.receipt';

export type MatrixEDUTypes = TypingEDU | PresenceEDU | BaseEDU;
31 changes: 31 additions & 0 deletions packages/core/src/events/edu/m.receipt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type { RoomID, UserID } from '@rocket.chat/federation-room';

import type { BaseEDU } from './base';

/**
* Typing receipt EDU as defined in the Matrix specification
*
* @see https://spec.matrix.org/latest/server-server-api/#receipts
*/
export interface ReceiptEDU extends BaseEDU {
edu_type: 'm.receipt';
content: Record<
RoomID,
{
'm.read': Record<
UserID,
{
data: {
thread_id?: string;
ts: number;
};
event_ids: string[];
}
>;
}
>;
}

export const isReceiptEDU = (edu: BaseEDU): edu is ReceiptEDU => {
return edu.edu_type === 'm.receipt';
};
7 changes: 7 additions & 0 deletions packages/federation-sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,13 @@ export type HomeserverEventSignatures = {
last_active_ago?: number;
origin?: string;
};
'homeserver.matrix.receipt': {
room_id: string;
user_id: string;
event_ids: string[];
ts: number;
thread_id?: string;
};
'homeserver.matrix.encryption': {
event_id: EventID;
event: PduForType<'m.room.encryption'>;
Expand Down
4 changes: 4 additions & 0 deletions packages/federation-sdk/src/sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,4 +266,8 @@ export class FederationSDK {
sendPresenceUpdateToRooms(...args: Parameters<typeof this.eduService.sendPresenceUpdateToRooms>) {
return this.eduService.sendPresenceUpdateToRooms(...args);
}

sendReadReceipt(...args: Parameters<typeof this.eduService.sendReadReceipt>) {
return this.eduService.sendReadReceipt(...args);
}
}
2 changes: 2 additions & 0 deletions packages/federation-sdk/src/services/config.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export interface AppConfig {
edu: {
processTyping: boolean;
processPresence: boolean;
processReceipt?: boolean;
};
userCheckTimeoutMs?: number;
networkCheckTimeoutMs?: number;
Expand Down Expand Up @@ -59,6 +60,7 @@ export const AppConfigSchema = z.object({
edu: z.object({
processTyping: z.boolean(),
processPresence: z.boolean(),
processReceipt: z.boolean().optional(),
}),
networkCheckTimeoutMs: z.number().int().min(1000, 'Network check timeout must be at least 1000ms').default(5000).optional(),
userCheckTimeoutMs: z.number().int().min(1000, 'User check timeout must be at least 1000ms').default(10000).optional(),
Expand Down
51 changes: 50 additions & 1 deletion packages/federation-sdk/src/services/edu.service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { PresenceUpdate } from '@rocket.chat/federation-core';
import type { PresenceUpdate, ReceiptEDU } from '@rocket.chat/federation-core';
import { createPresenceEDU, createTypingEDU, createLogger } from '@rocket.chat/federation-core';
import { RoomID } from '@rocket.chat/federation-room';
import { singleton } from 'tsyringe';
Expand Down Expand Up @@ -67,4 +67,53 @@ export class EduService {
throw error;
}
}

async sendReadReceipt({
roomId,
userId,
eventIds,
threadId,
}: {
roomId: RoomID;
userId: string;
eventIds: string[];
threadId?: string;
}): Promise<void> {
try {
const origin = this.configService.serverName;
const receiptEDU: ReceiptEDU = {
edu_type: 'm.receipt',
content: {
[roomId]: {
'm.read': {
[userId]: {
data: {
ts: Date.now(),
thread_id: threadId || 'main',
},
event_ids: eventIds,
},
},
},
},
};
Comment on lines +84 to +99
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🌐 Web query:

Matrix m.receipt EDU thread_id field specification

💡 Result:

In Matrix, the thread_id field is part of the read receipt metadata (and related receipt objects) to indicate which thread a receipt applies to.

Server-server (m.receipt EDU)

In the federation m.receipt EDU, thread_id appears under Read Receipt Metadata (data) and is defined as: a string which is the root thread event’s ID, or the literal "main", identifying which thread the receipt is intended to be under. [1]

Client-server (m.receipt ephemeral event + /receipt API)

In the client-server m.receipt ephemeral event content, each user’s receipt object can include: [2]

  • thread_id (string): the root thread event’s ID or "main".
  • If not specified, the receipt is unthreaded (default).
  • This threaded receipt support was added in Matrix spec v1.4. [2]

The POST /_matrix/client/v3/rooms/{roomId}/receipt/{receiptType}/{eventId} endpoint also accepts an optional thread_id in the request body with the same meaning (root event ID or "main"; omitted = unthreaded). [2]

Sources: Matrix Spec server-server receipts (m.receipt EDU) [1], Matrix Spec client-server receipts (m.receipt) and /receipt endpoint [2].


Fix thread_id field to be conditionally included, not always present.

The code sets thread_id: threadId || 'main' (line 92), which always includes the field. However, according to the Matrix spec, thread_id should be omitted when the receipt is unthreaded. Omitting the field results in an unthreaded receipt (default), while setting thread_id: 'main' explicitly targets the main thread—these are distinct states. When threadId is not provided, the field should be excluded entirely rather than defaulting to 'main'.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/federation-sdk/src/services/edu.service.ts` around lines 84 - 99,
The ReceiptEDU construction always sets thread_id to 'main' which incorrectly
marks receipts as threaded; change the build in services/edu.service.ts (the
receiptEDU object created in the function that uses roomId, userId, eventIds,
threadId) so that the data object only includes a thread_id property when
threadId is provided—i.e., construct the inner data payload (used inside
ReceiptEDU.content[roomId]['m.read'][userId].data) and add data.thread_id =
threadId only if threadId is truthy, otherwise omit the thread_id field
entirely.


this.logger.debug(
`Sending read receipt for user ${userId} in room ${roomId} for events ${eventIds.join(', ')} to all servers in room`,
);

const servers = await this.stateService.getServersInRoom(roomId);
const uniqueServers = servers.filter((server) => server !== origin);

await this.federationService.sendEDUToServers([receiptEDU], uniqueServers);

this.logger.debug(`Sent read receipt to ${uniqueServers.length} unique servers for room ${roomId}`);
} catch (error) {
this.logger.error({
msg: 'Failed to send read receipt',
err: error,
});
throw error;
}
}
}
60 changes: 59 additions & 1 deletion packages/federation-sdk/src/services/event.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,20 @@ import type {
EventStagingStore,
PresenceEDU,
RoomPowerLevelsEvent,
ReceiptEDU,
TypingEDU,
RedactionEvent,
EventStore,
} from '@rocket.chat/federation-core';
import { isPresenceEDU, isTypingEDU, generateId, pruneEventDict, checkSignAndHashes, createLogger } from '@rocket.chat/federation-core';
import {
isPresenceEDU,
isReceiptEDU,
isTypingEDU,
generateId,
pruneEventDict,
checkSignAndHashes,
createLogger,
} from '@rocket.chat/federation-core';
import {
type EventID,
type Pdu,
Expand Down Expand Up @@ -279,6 +288,9 @@ export class EventService {
if (isPresenceEDU(edu)) {
await this.processPresenceEDU(edu, origin);
}
if (isReceiptEDU(edu)) {
await this.processReceiptEDU(edu);
}
}

private async processTypingEDU(typingEDU: TypingEDU, origin?: string): Promise<void> {
Expand Down Expand Up @@ -338,6 +350,52 @@ export class EventService {
}
}

private async processReceiptEDU(receiptEDU: ReceiptEDU): Promise<void> {
const config = this.configService.getConfig('edu');

// turned on by default, so if undefined we should process receipts
if (config.processReceipt === false) {
return;
}

const { content } = receiptEDU;

if (!content || typeof content !== 'object') {
this.logger.warn('Invalid receipt EDU content, missing or invalid content');
return;
}

// Loop through each room in the receipt EDU
for await (const [roomId, roomReceipts] of Object.entries(content)) {
if (!roomReceipts?.['m.read']) {
this.logger.warn(`Invalid receipt EDU for room ${roomId}, missing m.read`);
continue;
}

const readReceipts = roomReceipts['m.read'];

// Loop through each user's read receipt in this room
for await (const [userId, receiptData] of Object.entries(readReceipts)) {
if (!receiptData?.event_ids || !Array.isArray(receiptData.event_ids)) {
this.logger.warn(`Invalid receipt data for user ${userId} in room ${roomId}, missing event_ids`);
continue;
}

const { event_ids, data } = receiptData;

this.logger.debug('Processing read receipt', { roomId, userId, event_ids, thread_id: data.thread_id });

await this.eventEmitterService.emit('homeserver.matrix.receipt', {
room_id: roomId,
user_id: userId,
event_ids,
ts: data.ts,
thread_id: data.thread_id,
});
}
}
}

private validateEventByType(event: Pdu): string[] {
const errors: string[] = [];

Expand Down
1 change: 1 addition & 0 deletions packages/homeserver/src/homeserver.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ export async function setup() {
edu: {
processTyping: process.env.EDU_PROCESS_TYPING !== 'false',
processPresence: process.env.EDU_PROCESS_PRESENCE === 'true',
processReceipt: process.env.EDU_PROCESS_RECEIPT === 'true',
},
});

Expand Down