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
119 changes: 71 additions & 48 deletions packages/core/src/events/m.room.message.ts
Original file line number Diff line number Diff line change
@@ -1,35 +1,72 @@
import { type EventBase, createEventBase } from './eventBase';
import { createEventWithId } from './utils/createSignedEvent';

type MessageType =
| 'm.text'
| 'm.emote'
| 'm.notice'
| 'm.image'
| 'm.file'
| 'm.audio'
| 'm.video'
| 'm.location';
export type TextMessageType = 'm.text' | 'm.emote' | 'm.notice';
export type FileMessageType = 'm.image' | 'm.file' | 'm.audio' | 'm.video';
export type LocationMessageType = 'm.location';
export type MessageType =
| TextMessageType
| FileMessageType
| LocationMessageType;

// Base message content
type BaseMessageContent = {
body: string;
'm.mentions'?: Record<string, any>;
format?: string;
formatted_body?: string;
'm.relates_to'?: MessageRelation;
};

// Text message content
export type TextMessageContent = BaseMessageContent & {
msgtype: TextMessageType;
};

// File message content
export type FileMessageContent = BaseMessageContent & {
msgtype: FileMessageType;
url: string;
info?: {
size?: number;
mimetype?: string;
w?: number;
h?: number;
duration?: number;
thumbnail_url?: string;
thumbnail_info?: {
w?: number;
h?: number;
mimetype?: string;
size?: number;
};
};
};

// Location message content
export type LocationMessageContent = BaseMessageContent & {
msgtype: LocationMessageType;
geo_uri: string;
};

// New content for edits
type NewContent =
| Pick<TextMessageContent, 'body' | 'msgtype' | 'format' | 'formatted_body'>
| Pick<FileMessageContent, 'body' | 'msgtype' | 'url' | 'info'>
| Pick<LocationMessageContent, 'body' | 'msgtype' | 'geo_uri'>;

declare module './eventBase' {
interface Events {
'm.room.message': {
unsigned: {
age_ts: number;
};
content: {
body: string;
msgtype: MessageType;
'm.mentions'?: Record<string, any>;
format?: string;
formatted_body?: string;
'm.relates_to'?: MessageRelation;
'm.new_content'?: {
body: string;
msgtype: MessageType;
format?: string;
formatted_body?: string;
};
content: (
| TextMessageContent
| FileMessageContent
| LocationMessageContent
) & {
'm.new_content'?: NewContent;
};
};
}
Expand Down Expand Up @@ -67,19 +104,12 @@ export const isRoomMessageEvent = (

export interface RoomMessageEvent extends EventBase {
type: 'm.room.message';
content: {
body: string;
msgtype: MessageType;
'm.mentions'?: Record<string, any>;
format?: string;
formatted_body?: string;
'm.relates_to'?: MessageRelation;
'm.new_content'?: {
body: string;
msgtype: MessageType;
format?: string;
formatted_body?: string;
};
content: (
| TextMessageContent
| FileMessageContent
| LocationMessageContent
) & {
'm.new_content'?: NewContent;
};
unsigned: {
age: number;
Expand Down Expand Up @@ -110,19 +140,12 @@ export const roomMessageEvent = ({
prev_events: string[];
depth: number;
unsigned?: RoomMessageEvent['unsigned'];
content: {
body: string;
msgtype: MessageType;
'm.mentions'?: Record<string, any>;
format?: string;
formatted_body?: string;
'm.relates_to'?: MessageRelation;
'm.new_content'?: {
body: string;
msgtype: MessageType;
format?: string;
formatted_body?: string;
};
content: (
| TextMessageContent
| FileMessageContent
| LocationMessageContent
) & {
'm.new_content'?: NewContent;
};
origin?: string;
ts?: number;
Expand Down
41 changes: 22 additions & 19 deletions packages/federation-sdk/src/services/message.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,27 @@ import { FederationService } from './federation.service';
import { RoomService } from './room.service';
import { StateService } from './state.service';

// File message content type
export type FileMessageContent = {
body: string;
msgtype: 'm.image' | 'm.file' | 'm.video' | 'm.audio';
url: string;
info?: {
size?: number;
mimetype?: string;
w?: number;
h?: number;
duration?: number;
thumbnail_url?: string;
thumbnail_info?: {
w?: number;
h?: number;
mimetype?: string;
size?: number;
};
};
};

@singleton()
export class MessageService {
private readonly logger = createLogger('MessageService');
Expand Down Expand Up @@ -121,25 +142,7 @@ export class MessageService {

async sendFileMessage(
roomId: string,
content: {
body: string;
msgtype: 'm.image' | 'm.file' | 'm.video' | 'm.audio';
url: string;
info?: {
size?: number;
mimetype?: string;
w?: number;
h?: number;
duration?: number;
thumbnail_url?: string;
thumbnail_info?: {
w?: number;
h?: number;
mimetype?: string;
size?: number;
};
};
},
content: FileMessageContent,
senderUserId: string,
): Promise<PersistentEventBase> {
const roomVersion = await this.stateService.getRoomVersion(roomId);
Expand Down
130 changes: 110 additions & 20 deletions packages/room/src/types/v3-11.ts
Original file line number Diff line number Diff line change
Expand Up @@ -338,10 +338,21 @@ export type PduRoomNameEventContent = z.infer<
typeof PduRoomNameEventContentSchema
>;

export const PduMessageEventContentSchema = z.object({
// Base message content schema
const BaseMessageContentSchema = z.object({
body: z.string().describe('The body of the message.'),
// TODO: add more types
msgtype: z.enum(['m.text', 'm.image']).describe('The type of the message.'),
msgtype: z
.enum([
'm.text',
'm.image',
'm.file',
'm.audio',
'm.video',
'm.emote',
'm.notice',
'm.location',
])
.describe('The type of the message.'),
// Optional fields for message edits and relations aka threads
'm.relates_to': z
.object({
Expand All @@ -368,23 +379,6 @@ export const PduMessageEventContentSchema = z.object({
})
.optional()
.describe('Relation information for edits, replies, reactions, etc.'),
'm.new_content': z
.object({
body: z.string().describe('The new body of the message for edits.'),
msgtype: z
.enum(['m.text', 'm.image'])
.describe('The type of the new message content.'),
format: z
.enum(['org.matrix.custom.html'])
.describe('The format of the message content.')
.optional(),
formatted_body: z
.string()
.describe('The formatted body of the message.')
.optional(),
})
.optional()
.describe('The new content for message edits.'),
format: z
.enum(['org.matrix.custom.html'])
.describe('The format of the message content.')
Expand All @@ -395,6 +389,102 @@ export const PduMessageEventContentSchema = z.object({
.optional(),
});

// File info schema
const FileInfoSchema = z.object({
size: z.number().describe('The size of the file in bytes.').optional(),
mimetype: z.string().describe('The MIME type of the file.').optional(),
w: z.number().describe('The width of the image/video in pixels.').optional(),
h: z.number().describe('The height of the image/video in pixels.').optional(),
duration: z
.number()
.describe('The duration of the audio/video in milliseconds.')
.optional(),
thumbnail_url: z
.string()
.describe('The URL of the thumbnail image.')
.optional(),
thumbnail_info: z
.object({
w: z
.number()
.describe('The width of the thumbnail in pixels.')
.optional(),
h: z
.number()
.describe('The height of the thumbnail in pixels.')
.optional(),
mimetype: z
.string()
.describe('The MIME type of the thumbnail.')
.optional(),
size: z
.number()
.describe('The size of the thumbnail in bytes.')
.optional(),
})
.describe('Information about the thumbnail.')
.optional(),
});

// Text message content (m.text, m.emote, m.notice)
const TextMessageContentSchema = BaseMessageContentSchema.extend({
msgtype: z.enum(['m.text', 'm.emote', 'm.notice']),
});

// File message content (m.image, m.file, m.audio, m.video)
const FileMessageContentSchema = BaseMessageContentSchema.extend({
msgtype: z.enum(['m.image', 'm.file', 'm.audio', 'm.video']),
url: z.string().describe('The URL of the file.'),
info: FileInfoSchema.describe('Information about the file.').optional(),
});

// Location message content (m.location)
const LocationMessageContentSchema = BaseMessageContentSchema.extend({
msgtype: z.literal('m.location'),
geo_uri: z.string().describe('The geo URI of the location.'),
// Additional location fields can be added here
});

Comment on lines +441 to +447
Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Validate geo_uri scheme.

Guard against arbitrary strings for locations.

 const LocationMessageContentSchema = BaseMessageContentSchema.extend({
   msgtype: z.literal('m.location'),
-  geo_uri: z.string().describe('The geo URI of the location.'),
+  geo_uri: z.string().regex(/^geo:/, 'geo_uri must start with geo:').describe('The geo URI of the location.'),
   // Additional location fields can be added here
 });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Location message content (m.location)
const LocationMessageContentSchema = BaseMessageContentSchema.extend({
msgtype: z.literal('m.location'),
geo_uri: z.string().describe('The geo URI of the location.'),
// Additional location fields can be added here
});
// Location message content (m.location)
const LocationMessageContentSchema = BaseMessageContentSchema.extend({
msgtype: z.literal('m.location'),
geo_uri: z.string().regex(/^geo:/, 'geo_uri must start with geo:').describe('The geo URI of the location.'),
// Additional location fields can be added here
});
🤖 Prompt for AI Agents
In packages/room/src/types/v3-11.ts around lines 441-447, the geo_uri field
currently accepts any string; change it to validate the URI scheme to prevent
arbitrary values by replacing z.string() with a validation that ensures the
value is a valid URI with an allowed scheme (e.g. starts with "geo:" or matches
a strict URI regex for allowed schemes). Implement using Zod's refine (or regex)
to parse/validate the scheme, provide a clear error message like "geo_uri must
be a valid URI with an allowed scheme (e.g. geo:)", and keep the description;
ensure tests/consumers will reject arbitrary strings and accept valid geo URIs.

// New content schema for edits
const NewContentSchema = z.discriminatedUnion('msgtype', [
TextMessageContentSchema.pick({
body: true,
msgtype: true,
format: true,
formatted_body: true,
}),
FileMessageContentSchema.pick({
body: true,
msgtype: true,
url: true,
info: true,
}),
LocationMessageContentSchema.pick({
body: true,
msgtype: true,
geo_uri: true,
}),
Comment on lines +448 to +466
Copy link

Copilot AI Sep 15, 2025

Choose a reason for hiding this comment

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

The NewContentSchema uses .pick() to select specific fields from each message content schema. This creates a dependency on the structure of the parent schemas and could break if those schemas change. Consider defining explicit schemas for new content to improve maintainability and make the validation logic more explicit.

Suggested change
// New content schema for edits
const NewContentSchema = z.discriminatedUnion('msgtype', [
TextMessageContentSchema.pick({
body: true,
msgtype: true,
format: true,
formatted_body: true,
}),
FileMessageContentSchema.pick({
body: true,
msgtype: true,
url: true,
info: true,
}),
LocationMessageContentSchema.pick({
body: true,
msgtype: true,
geo_uri: true,
}),
// Explicit schemas for new content edits
const NewTextMessageContentEditSchema = z.object({
body: z.string().describe('The textual content of the message.'),
msgtype: z.literal('m.text').or(z.literal('m.notice')).or(z.literal('m.emote')),
format: z.string().optional().describe('The format of the message.'),
formatted_body: z.string().optional().describe('The formatted body of the message.'),
});
const NewFileMessageContentEditSchema = z.object({
body: z.string().describe('The textual description of the file.'),
msgtype: z.enum(['m.image', 'm.file', 'm.audio', 'm.video']),
url: z.string().describe('The URL of the file.'),
info: FileInfoSchema.describe('Information about the file.').optional(),
});
const NewLocationMessageContentEditSchema = z.object({
body: z.string().describe('The textual description of the location.'),
msgtype: z.literal('m.location'),
geo_uri: z.string().describe('The geo URI of the location.'),
});
// New content schema for edits
const NewContentSchema = z.discriminatedUnion('msgtype', [
NewTextMessageContentEditSchema,
NewFileMessageContentEditSchema,
NewLocationMessageContentEditSchema,

Copilot uses AI. Check for mistakes.
]);

// Main message content schema using discriminated union
export const PduMessageEventContentSchema = z.union([
TextMessageContentSchema.extend({
'm.new_content': NewContentSchema.optional().describe(
'The new content for message edits.',
),
}),
FileMessageContentSchema.extend({
'm.new_content': NewContentSchema.optional().describe(
'The new content for message edits.',
),
}),
LocationMessageContentSchema.extend({
'm.new_content': NewContentSchema.optional().describe(
'The new content for message edits.',
),
}),
]);
Comment on lines +469 to +486
Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Use a real discriminatedUnion on msgtype (current comment says discriminated, code is plain union).

Improves inference and validation performance.

-export const PduMessageEventContentSchema = z.union([
+export const PduMessageEventContentSchema = z.discriminatedUnion('msgtype', [
   TextMessageContentSchema.extend({
     'm.new_content': NewContentSchema.optional().describe(
       'The new content for message edits.',
     ),
   }),
   FileMessageContentSchema.extend({
     'm.new_content': NewContentSchema.optional().describe(
       'The new content for message edits.',
     ),
   }),
   LocationMessageContentSchema.extend({
     'm.new_content': NewContentSchema.optional().describe(
       'The new content for message edits.',
     ),
   }),
 ]);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Main message content schema using discriminated union
export const PduMessageEventContentSchema = z.union([
TextMessageContentSchema.extend({
'm.new_content': NewContentSchema.optional().describe(
'The new content for message edits.',
),
}),
FileMessageContentSchema.extend({
'm.new_content': NewContentSchema.optional().describe(
'The new content for message edits.',
),
}),
LocationMessageContentSchema.extend({
'm.new_content': NewContentSchema.optional().describe(
'The new content for message edits.',
),
}),
]);
// Main message content schema using discriminated union
export const PduMessageEventContentSchema = z.discriminatedUnion('msgtype', [
TextMessageContentSchema.extend({
'm.new_content': NewContentSchema.optional().describe(
'The new content for message edits.',
),
}),
FileMessageContentSchema.extend({
'm.new_content': NewContentSchema.optional().describe(
'The new content for message edits.',
),
}),
LocationMessageContentSchema.extend({
'm.new_content': NewContentSchema.optional().describe(
'The new content for message edits.',
),
}),
]);
🤖 Prompt for AI Agents
In packages/room/src/types/v3-11.ts around lines 469 to 486, replace the plain
z.union with z.discriminatedUnion on the msgtype field to get proper type
narrowing and faster validation; update each branch to include an explicit
msgtype literal (for example extend TextMessageContentSchema with { msgtype:
z.literal('m.text') }, FileMessageContentSchema with { msgtype:
z.literal('m.file') }, LocationMessageContentSchema with { msgtype:
z.literal('m.location') } or the actual msgtype strings those schemas represent)
and keep the 'm.new_content' optional extension as-is so the discriminated union
matches on msgtype and preserves the edit field.


export type PduMessageEventContent = z.infer<
typeof PduMessageEventContentSchema
>;
Expand Down