Merge pull request #910 from the-draupnir-project/gnuxie/media-extraction

Use new `EventMixin` extraction API from MPS in protections.

This allows protections to retrieve extensible events style mixins from any event.

We also add a protection to automatically redact any event with an erroneous mixin that is likely to cause issues for other clients.

MPS Describes all `m.room.message` `msgtype`s as mixins too.
This commit is contained in:
Gnuxie
2025-06-24 11:52:29 +01:00
committed by GitHub
12 changed files with 460 additions and 146 deletions

View File

@@ -63,7 +63,7 @@
"jsdom": "^24.0.0",
"matrix-appservice-bridge": "^10.3.1",
"matrix-bot-sdk": "npm:@vector-im/matrix-bot-sdk@^0.7.1-element.6",
"matrix-protection-suite": "npm:@gnuxie/matrix-protection-suite@3.6.2",
"matrix-protection-suite": "npm:@gnuxie/matrix-protection-suite@3.7.1",
"matrix-protection-suite-for-matrix-bot-sdk": "npm:@gnuxie/matrix-protection-suite-for-matrix-bot-sdk@3.6.6",
"pg": "^8.8.0",
"yaml": "^2.3.2"

View File

@@ -382,6 +382,7 @@ export class MjolnirAppService {
await this.bridge.close();
await this.dataStore.close();
await this.api.close();
this.draupnirManager.unregisterListeners();
}
/**

View File

@@ -83,6 +83,10 @@ export class AppServiceDraupnirManager {
return `@${mjolnirRecord.local_part}:${this.serverName}` as StringUserID;
}
public unregisterListeners(): void {
this.baseManager.unregisterListeners();
}
/**
* Create the draupnir manager from the datastore and the access control.
* @param dataStore The data store interface that has the details for provisioned draupnirs.

View File

@@ -11,6 +11,7 @@
import {
ActionResult,
ClientPlatform,
DefaultMixinExtractor,
LoggableConfigTracker,
Logger,
MJOLNIR_PROTECTED_ROOMS_EVENT_TYPE,
@@ -219,6 +220,7 @@ export async function makeProtectedRoomsSet(
protectedRoomsManager.ok,
protectionsConfig.ok,
userID,
DefaultMixinExtractor,
makeHandleMissingProtectionPermissions(
clientPlatform.toRoomMessageSender(),
managementRoom.toRoomIDOrAlias()

View File

@@ -42,6 +42,15 @@ export class StandardDraupnirManager {
// nothing to do.
}
public unregisterListeners(): void {
for (const [, draupnir] of this.draupnir) {
draupnir.stop();
}
for (const [, safeModeDraupnir] of this.safeModeDraupnir) {
safeModeDraupnir.stop();
}
}
public makeSafeModeToggle(
clientUserID: StringUserID,
managementRoom: MatrixRoomID,
@@ -52,6 +61,7 @@ export class StandardDraupnirManager {
const draupnirManager = this;
const toggle: SafeModeToggle = Object.freeze({
async switchToSafeMode(cause: SafeModeCause) {
draupnirManager.stopDraupnir(clientUserID);
return draupnirManager.makeSafeModeDraupnir(
clientUserID,
managementRoom,
@@ -60,6 +70,7 @@ export class StandardDraupnirManager {
);
},
async switchToDraupnir() {
draupnirManager.stopDraupnir(clientUserID);
return draupnirManager.makeDraupnir(
clientUserID,
managementRoom,
@@ -216,12 +227,15 @@ export class StandardDraupnirManager {
public stopDraupnir(clientUserID: StringUserID): void {
const draupnir = this.draupnir.get(clientUserID);
if (draupnir === undefined) {
return;
} else {
if (draupnir !== undefined) {
draupnir.stop();
this.draupnir.delete(clientUserID);
}
const safeModeDraupnir = this.safeModeDraupnir.get(clientUserID);
if (safeModeDraupnir) {
safeModeDraupnir.stop();
this.safeModeDraupnir.delete(clientUserID);
}
}
}

View File

@@ -18,6 +18,7 @@ import { RedactionSynchronisationProtection } from "./RedactionSynchronisation";
import { PolicyChangeNotification } from "./PolicyChangeNotification";
import { JoinRoomsOnInviteProtection } from "./invitation/JoinRoomsOnInviteProtection";
import { RoomsSetBehaviour } from "./ProtectedRooms/RoomsSetBehaviourProtection";
import { InvalidEventProtection } from "./InvalidEventProtection";
export const DefaultEnabledProtectionsMigration =
new SchemedDataManager<MjolnirEnabledProtectionsEvent>([
@@ -165,4 +166,25 @@ export const DefaultEnabledProtectionsMigration =
[DRAUPNIR_SCHEMA_VERSION_KEY]: toVersion,
});
},
async function enableInvalidEventProtection(input, toVersion) {
if (!Value.Check(MjolnirEnabledProtectionsEvent, input)) {
return ActionError.Result(
`The data for ${MjolnirEnabledProtectionsEventType} is corrupted.`
);
}
const enabledProtections = new Set(input.enabled);
const protection = findProtection(InvalidEventProtection.name);
if (protection === undefined) {
const message = `Cannot find the ${RoomsSetBehaviour.name} protection`;
return ActionException.Result(message, {
exception: new TypeError(message),
exceptionKind: ActionExceptionKind.Unknown,
});
}
enabledProtections.add(protection.name);
return Ok({
enabled: [...enabledProtections],
[DRAUPNIR_SCHEMA_VERSION_KEY]: toVersion,
});
},
]);

View File

@@ -16,6 +16,7 @@ import "./BanPropagation";
import "./BasicFlooding";
import "./FirstMessageIsImage";
import "./HomeserverUserPolicyApplication/HomeserverUserPolicyProtection";
import "./InvalidEventProtection";
import "./JoinWaveShortCircuit";
import "./RedactionSynchronisation";
import "./MembershipChangeProtection";

View File

@@ -0,0 +1,214 @@
// Copyright 2025 Gnuxie <Gnuxie@protonmail.com>
//
// SPDX-License-Identifier: AFL-3.0
import { Type } from "@sinclair/typebox";
import {
AbstractProtection,
describeProtection,
EDStatic,
EventConsequences,
EventWithMixins,
isError,
Logger,
Ok,
ProtectedRoomsSet,
Protection,
ProtectionDescription,
RoomMessageSender,
Task,
UserConsequences,
} from "matrix-protection-suite";
import { Draupnir } from "../Draupnir";
import {
MatrixRoomID,
StringRoomID,
StringUserID,
} from "@the-draupnir-project/matrix-basic-types";
import { LazyLeakyBucket } from "../queues/LeakyBucket";
import { DeadDocumentJSX } from "@the-draupnir-project/interface-manager";
import {
renderMentionPill,
sendMatrixEventsFromDeadDocument,
} from "@the-draupnir-project/mps-interface-adaptor";
const log = new Logger("InvalidEventProtection");
const InvalidEventProtectionSettings = Type.Object(
{
warningText: Type.String({
description:
"The reason to use to notify the user after redacting their infringing message.",
default:
"You have sent an invalid event that could cause problems in some Matrix clients, so we have had to redact it.",
}),
},
{
title: "InvalidEventProtectionSettings",
}
);
type InvalidEventProtectionSettings = EDStatic<
typeof InvalidEventProtectionSettings
>;
export type InvalidEventProtectionDescription = ProtectionDescription<
unknown,
typeof InvalidEventProtectionSettings,
InvalidEventProtectionCapabilities
>;
export type InvalidEventProtectionCapabilities = {
eventConsequences: EventConsequences;
userConsequences: UserConsequences;
};
export class InvalidEventProtection
extends AbstractProtection<InvalidEventProtectionDescription>
implements Protection<InvalidEventProtectionDescription>
{
private readonly eventConsequences: EventConsequences;
private readonly userConsequences: UserConsequences;
private readonly consequenceBucket = new LazyLeakyBucket<StringUserID>(
1,
30 * 60_000 // half an hour will do
);
public constructor(
description: InvalidEventProtectionDescription,
capabilities: InvalidEventProtectionCapabilities,
protectedRoomsSet: ProtectedRoomsSet,
private readonly warningText: string,
private readonly roomMessageSender: RoomMessageSender,
private readonly managentRoomID: StringRoomID
) {
super(description, capabilities, protectedRoomsSet, {});
this.eventConsequences = capabilities.eventConsequences;
this.userConsequences = capabilities.userConsequences;
}
private async redactEventWithMixin(event: EventWithMixins): Promise<void> {
const redactResult = await this.eventConsequences.consequenceForEvent(
event.sourceEvent.room_id,
event.sourceEvent.event_id,
"invalid event mixin"
);
if (isError(redactResult)) {
log.error(
`Failed to redact and event sent by ${event.sourceEvent.sender} in ${event.sourceEvent.room_id}`,
redactResult.error
);
}
const managementRoomSendResult = await sendMatrixEventsFromDeadDocument(
this.roomMessageSender,
this.managentRoomID,
<root>
<details>
<summary>
Copy of invalid event content from {event.sourceEvent.sender}
</summary>
<pre>{JSON.stringify(event.sourceEvent.content)}</pre>
</details>
</root>,
{}
);
if (isError(managementRoomSendResult)) {
log.error(
"Failed to send redacted event details to the management room",
managementRoomSendResult.error
);
}
}
private async sendWarning(event: EventWithMixins): Promise<void> {
const result = await sendMatrixEventsFromDeadDocument(
this.roomMessageSender,
event.sourceEvent.room_id,
<root>
{renderMentionPill(event.sourceEvent.sender, event.sourceEvent.sender)}{" "}
{this.warningText}
</root>,
{ replyToEvent: event.sourceEvent }
);
if (isError(result)) {
log.error(
"Unable to warn the user",
event.sourceEvent.sender,
result.error
);
}
}
private async banUser(event: EventWithMixins): Promise<void> {
const banResult = await this.userConsequences.consequenceForUserInRoom(
event.sourceEvent.room_id,
event.sourceEvent.sender,
"Sending invalid events"
);
if (isError(banResult)) {
log.error(
"Unable to ban the sender of invalid events",
event.sourceEvent.sender,
event.sourceEvent.room_id,
banResult.error
);
}
}
public handleProtectionDisable(): void {
this.consequenceBucket.stop();
}
public handleTimelineEventMixins(
_room: MatrixRoomID,
event: EventWithMixins
): void {
if (!event.mixins.some((mixin) => mixin.isErroneous)) {
return;
}
const infractions = this.consequenceBucket.getTokenCount(
event.sourceEvent.sender
);
if (infractions > 0) {
void Task(this.banUser(event), { log });
} else {
void Task(this.sendWarning(event), { log });
}
this.consequenceBucket.addToken(event.sourceEvent.sender);
void Task(this.redactEventWithMixin(event), { log });
}
}
describeProtection<
InvalidEventProtectionCapabilities,
Draupnir,
typeof InvalidEventProtectionSettings
>({
name: "InvalidEventProtection",
description: `Protect the room against malicious events or evasion of other protections.`,
capabilityInterfaces: {
eventConsequences: "EventConsequences",
userConsequences: "UserConsequences",
},
defaultCapabilities: {
eventConsequences: "StandardEventConsequences",
userConsequences: "StandardUserConsequences",
},
configSchema: InvalidEventProtectionSettings,
factory: async (
decription,
protectedRoomsSet,
draupnir,
capabilitySet,
settings
) =>
Ok(
new InvalidEventProtection(
decription,
capabilitySet,
protectedRoomsSet,
settings.warningText,
draupnir.clientPlatform.toRoomMessageSender(),
draupnir.managementRoomID
)
),
});

View File

@@ -5,19 +5,22 @@
import {
AbstractProtection,
ActionResult,
ContentMixins,
EDStatic,
EventConsequences,
EventWithMixins,
Logger,
MentionsMixin,
MentionsMixinDescription,
NewContentMixinDescription,
Ok,
ProtectedRoomsSet,
Protection,
ProtectionDescription,
RoomEvent,
RoomMessageBodyMixinDescription,
RoomMessageSender,
Task,
UserConsequences,
Value,
describeProtection,
isError,
} from "matrix-protection-suite";
@@ -33,52 +36,61 @@ import {
renderMentionPill,
sendMatrixEventsFromDeadDocument,
} from "@the-draupnir-project/mps-interface-adaptor";
import { Result } from "@gnuxie/typescript-result";
const log = new Logger("MentionLimitProtection");
const MentionsContentSchema = Type.Object({
"m.mentions": Type.Object({
user_ids: Type.Array(Type.String()),
}),
});
function isMentionsMixinOverLimit(
mentionsMixin: MentionsMixin,
maxMentions: number
): boolean {
return mentionsMixin.user_ids.length > maxMentions;
}
const NewContentMentionsSchema = Type.Object({
"m.new_content": MentionsContentSchema,
});
const WeakTextContentSchema = Type.Object({
body: Type.Optional(Type.String()),
formatted_body: Type.Optional(Type.String()),
});
export function isContainingMentionsOverLimit(
event: RoomEvent,
function isContentContaningMentionsOverLimit(
content: ContentMixins,
maxMentions: number,
checkBody: boolean
): boolean {
const isOverLimit = (user_ids: string[]): boolean =>
user_ids.length > maxMentions;
const mentionMixin = content.findMixin(MentionsMixinDescription);
if (
Value.Check(NewContentMentionsSchema, event.content) &&
isOverLimit(event.content["m.new_content"]["m.mentions"].user_ids)
mentionMixin?.isErroneous === false &&
isMentionsMixinOverLimit(mentionMixin, maxMentions)
) {
return true;
}
if (
Value.Check(MentionsContentSchema, event.content) &&
isOverLimit(event.content["m.mentions"].user_ids)
) {
if (!checkBody) {
return false;
}
const bodyMixin = content.findMixin(RoomMessageBodyMixinDescription);
if (bodyMixin === undefined || bodyMixin.isErroneous) {
return false;
}
return bodyMixin.body.split("@").length - 1 > maxMentions;
}
export function isContainingMentionsOverLimit(
event: EventWithMixins,
maxMentions: number,
checkBody: boolean
): boolean {
const isTopContentOverLimit = isContentContaningMentionsOverLimit(
event,
maxMentions,
checkBody
);
if (isTopContentOverLimit) {
return true;
}
if (checkBody && Value.Check(WeakTextContentSchema, event.content)) {
if (
event.content.body !== undefined &&
event.content.body.split("@").length - 1 > maxMentions
) {
return true;
}
const newContentMixin = event.findMixin(NewContentMixinDescription);
if (newContentMixin === undefined || newContentMixin.isErroneous) {
return false;
}
return false;
return isContentContaningMentionsOverLimit(
newContentMixin,
maxMentions,
checkBody
);
}
const MentionLimitProtectionSettings = Type.Object(
@@ -141,12 +153,12 @@ export class MentionLimitProtection
this.warningText = settings.warningText;
this.includeLegacymentions = settings.includeLegacyMentions;
}
public async handleTimelineEvent(
public handleTimelineEventMixins(
_room: MatrixRoomID,
event: RoomEvent
): Promise<ActionResult<void>> {
if (event.sender === this.protectedRoomsSet.userID) {
return Ok(undefined);
event: EventWithMixins
): void {
if (event.sourceEvent.sender === this.protectedRoomsSet.userID) {
return;
}
if (
isContainingMentionsOverLimit(
@@ -155,42 +167,60 @@ export class MentionLimitProtection
this.includeLegacymentions
)
) {
const infractions = this.consequenceBucket.getTokenCount(event.sender);
if (infractions > 0) {
const userResult = await this.userConsequences.consequenceForUserInRoom(
event.room_id,
event.sender,
this.warningText
);
if (isError(userResult)) {
log.error("Failed to ban the user", event.sender, userResult.error);
}
// fall through to the event consequence on purpose so we redact the event too.
} else {
// if they're not being banned we need to tell them why their message got redacted.
void Task(
sendMatrixEventsFromDeadDocument(
this.roomMessageSender,
event.room_id,
<root>
{renderMentionPill(event.sender, event.sender)} {this.warningText}
</root>,
{ replyToEvent: event }
),
{
log,
}
);
}
this.consequenceBucket.addToken(event.sender);
return await this.eventConsequences.consequenceForEvent(
event.room_id,
event.event_id,
void Task(this.handleEventOverLimit(event), {
log,
});
}
}
public async handleEventOverLimit(
event: EventWithMixins
): Promise<Result<void>> {
const sourceEvent = event.sourceEvent;
const infractions = this.consequenceBucket.getTokenCount(
sourceEvent.sender
);
if (infractions > 0) {
const userResult = await this.userConsequences.consequenceForUserInRoom(
sourceEvent.room_id,
sourceEvent.sender,
this.warningText
);
if (isError(userResult)) {
log.error(
"Failed to ban the user",
sourceEvent.sender,
userResult.error
);
}
// fall through to the event consequence on purpose so we redact the event too.
} else {
return Ok(undefined);
// if they're not being banned we need to tell them why their message got redacted.
void Task(
sendMatrixEventsFromDeadDocument(
this.roomMessageSender,
sourceEvent.room_id,
<root>
{renderMentionPill(sourceEvent.sender, sourceEvent.sender)}{" "}
{this.warningText}
</root>,
{ replyToEvent: sourceEvent }
),
{
log,
}
);
}
this.consequenceBucket.addToken(sourceEvent.sender);
return await this.eventConsequences.consequenceForEvent(
sourceEvent.room_id,
sourceEvent.event_id,
this.warningText
);
}
handleProtectionDisable(): void {
this.consequenceBucket.stop();
}
}

View File

@@ -12,6 +12,8 @@ import {
AbstractProtection,
ActionResult,
EventConsequences,
EventWithMixins,
ExtensibleTextMixinDescription,
Logger,
MembershipChange,
MembershipChangeType,
@@ -19,12 +21,12 @@ import {
ProtectedRoomsSet,
Protection,
ProtectionDescription,
RoomEvent,
RoomMembershipRevision,
RoomMessage,
RoomMessageBodyMixinDescription,
RoomMessageFormattedBodyMixinDescription,
Task,
UnknownConfig,
UserConsequences,
Value,
describeProtection,
isError,
} from "matrix-protection-suite";
@@ -55,7 +57,8 @@ describeProtection<WordListCapabilities, Draupnir>({
name: "WordListProtection",
description:
"If a user posts a monitored word a set amount of time after joining, they\
will be banned from that room. This will not publish the ban to a ban list.",
will be banned from that room. This will not publish the ban to a ban list.\
This protection only targets recently joined users.",
capabilityInterfaces: {
userConsequences: "UserConsequences",
eventConsequences: "EventConsequences",
@@ -90,7 +93,7 @@ export class WordListProtection
implements Protection<WordListDescription>
{
private justJoined: JustJoinedByRoom = new Map();
private badWords?: RegExp;
private badWords: RegExp;
private readonly userConsequences: UserConsequences;
private readonly eventConsequences: EventConsequences;
@@ -103,6 +106,15 @@ export class WordListProtection
super(description, capabilities, protectedRoomsSet, {});
this.userConsequences = capabilities.userConsequences;
this.eventConsequences = capabilities.eventConsequences;
// See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#escaping
const escapeRegExp = (string: string) => {
return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
};
// Create a mega-regex from all the tiny words.
const words = this.draupnir.config.protections.wordlist.words
.filter((word) => word.length !== 0)
.map(escapeRegExp);
this.badWords = new RegExp(words.join("|"), "i");
}
public async handleMembershipChange(
revision: RoomMembershipRevision,
@@ -136,90 +148,93 @@ export class WordListProtection
return Ok(undefined);
}
public async handleTimelineEvent(
public handleTimelineEventMixins(
room: MatrixRoomID,
event: RoomEvent
): Promise<ActionResult<void>> {
event: EventWithMixins
): void {
// If the sender is draupnir, ignore the message
if (event["sender"] === this.draupnir.clientUserID) {
log.debug(`Ignoring message from self: ${event.event_id}`);
return Ok(undefined);
if (event.sourceEvent["sender"] === this.draupnir.clientUserID) {
log.debug(`Ignoring message from self: ${event.sourceEvent.event_id}`);
return;
}
const bodies = [
((mixin) => (mixin?.isErroneous === true ? undefined : mixin?.body))(
event.findMixin(RoomMessageBodyMixinDescription)
),
((mixin) => (mixin?.isErroneous ? undefined : mixin?.formatted_body))(
event.findMixin(RoomMessageFormattedBodyMixinDescription)
),
...(((mixin) =>
mixin?.isErroneous
? undefined
: mixin?.representations.map(
(representation) => representation.body
))(event.findMixin(ExtensibleTextMixinDescription)) ?? []),
].filter((mixin) => mixin !== undefined);
if (bodies.length === 0) {
return;
}
const minsBeforeTrusting =
this.draupnir.config.protections.wordlist.minutesBeforeTrusting;
if (Value.Check(RoomMessage, event)) {
if (!("msgtype" in event.content)) {
return Ok(undefined);
}
const message =
("formatted_body" in event.content &&
event.content["formatted_body"]) ||
event.content["body"];
const roomID = room.toRoomIDOrAlias();
const roomID = room.toRoomIDOrAlias();
// Check conditions first
if (minsBeforeTrusting > 0) {
const roomEntry = this.justJoined.get(roomID);
const joinTime = roomEntry?.get(event.sourceEvent["sender"]);
if (joinTime !== undefined) {
// Disregard if the user isn't recently joined
// Check conditions first
if (minsBeforeTrusting > 0) {
const roomEntry = this.justJoined.get(roomID);
const joinTime = roomEntry?.get(event["sender"]);
if (joinTime !== undefined) {
// Disregard if the user isn't recently joined
// Check if they did join recently, was it within the timeframe
const now = new Date();
if (
now.valueOf() - joinTime.valueOf() >
minsBeforeTrusting * 60 * 1000
) {
roomEntry?.delete(event["sender"]); // Remove the user
log.info(`${event["sender"]} is no longer considered suspect`);
return Ok(undefined);
}
} else {
// The user isn't in the recently joined users list, no need to keep
// looking
return Ok(undefined);
// Check if they did join recently, was it within the timeframe
const now = new Date();
if (
now.valueOf() - joinTime.valueOf() >
minsBeforeTrusting * 60 * 1000
) {
roomEntry?.delete(event.sourceEvent["sender"]); // Remove the user
log.info(
`${event.sourceEvent["sender"]} is no longer considered suspect`
);
return;
}
} else {
// The user isn't in the recently joined users list, no need to keep
// looking
return;
}
if (!this.badWords) {
// See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#escaping
const escapeRegExp = (string: string) => {
return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
};
// Create a mega-regex from all the tiny words.
const words = this.draupnir.config.protections.wordlist.words
.filter((word) => word.length !== 0)
.map(escapeRegExp);
this.badWords = new RegExp(words.join("|"), "i");
}
const match = this.badWords.exec(message);
if (match) {
const reason = `Said a bad word. Moderators, consult the management room for more information.`;
}
const match = bodies.find((body) => this.badWords.exec(body));
if (!match) {
return;
}
const reason = `Said a bad word. Moderators, consult the management room for more information.`;
void Task(
(async () => {
await this.userConsequences.consequenceForUserInRoom(
roomID,
event.sender,
event.sourceEvent.sender,
reason
);
const messageResult = await this.draupnir.client
.sendMessage(this.draupnir.managementRoomID, {
msgtype: "m.notice",
body: `Banned ${event.sender} in ${roomID} for saying '${match[0]}'.`,
body: `Banned ${event.sourceEvent.sender} in ${roomID} for saying '${match[0]}'.`,
})
.then((_) => Ok(undefined), resultifyBotSDKRequestError);
if (isError(messageResult)) {
log.error(
`Failed to send a message to the management room after banning ${event.sender} in ${roomID} for saying '${match[0]}'.`,
`Failed to send a message to the management room after banning ${event.sourceEvent.sender} in ${roomID} for saying '${match[0]}'.`,
messageResult.error
);
}
await this.eventConsequences.consequenceForEvent(
roomID,
event.event_id,
event.sourceEvent.event_id,
reason
);
})(),
{
log,
}
}
return Ok(undefined);
);
}
}

View File

@@ -3,7 +3,13 @@
//
// SPDX-License-Identifier: Apache-2.0
import { RoomEvent } from "matrix-protection-suite";
import {
DefaultMixinExtractor,
EventWithMixins,
randomRoomID,
randomUserID,
RoomEvent,
} from "matrix-protection-suite";
import { isContainingMentionsOverLimit } from "../../../src/protections/MentionLimitProtection";
import expect from "expect";
@@ -11,8 +17,13 @@ function messageEvent(content: {
body?: string;
formatted_body?: string;
"m.mentions"?: { user_ids: string[] };
}): RoomEvent {
return { content } as RoomEvent;
}): EventWithMixins {
return DefaultMixinExtractor.parseEvent({
content,
type: "m.room.message",
sender: randomUserID(),
room_id: randomRoomID([]).toRoomIDOrAlias(),
} as RoomEvent);
}
describe("MentionLimitProtection test", function () {

View File

@@ -2606,10 +2606,10 @@ matrix-appservice@^2.0.0:
"@gnuxie/typescript-result" "^1.0.0"
await-lock "^2.2.2"
"matrix-protection-suite@npm:@gnuxie/matrix-protection-suite@3.6.2":
version "3.6.2"
resolved "https://registry.yarnpkg.com/@gnuxie/matrix-protection-suite/-/matrix-protection-suite-3.6.2.tgz#7d62433d859156ab1bd288c2813c94d4e04c2de7"
integrity sha512-isXsyZn5EH4V2tyhYx1Mf+L5GfQC7/AdBxoZc7fk5hCsEjWaKj6VxpNoSmhx0Ev3F7zr9I52vRN7hNutumwP6g==
"matrix-protection-suite@npm:@gnuxie/matrix-protection-suite@3.7.1":
version "3.7.1"
resolved "https://registry.yarnpkg.com/@gnuxie/matrix-protection-suite/-/matrix-protection-suite-3.7.1.tgz#0f5e128bb05a087b0f144f5466e648c89d88a50d"
integrity sha512-awSsgCqcWtPfNKOHM76Sb5w8UFpB1JDSy5vI5GXWOL2tFl7kXdZ9QXc0ohPC31+jXsKZgXOBaGMY3z830p+DVw==
dependencies:
"@gnuxie/typescript-result" "^1.0.0"
await-lock "^2.2.2"