mirror of
https://github.com/the-draupnir-project/Draupnir.git
synced 2026-03-29 10:29:57 +00:00
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:
@@ -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"
|
||||
|
||||
@@ -382,6 +382,7 @@ export class MjolnirAppService {
|
||||
await this.bridge.close();
|
||||
await this.dataStore.close();
|
||||
await this.api.close();
|
||||
this.draupnirManager.unregisterListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -16,6 +16,7 @@ import "./BanPropagation";
|
||||
import "./BasicFlooding";
|
||||
import "./FirstMessageIsImage";
|
||||
import "./HomeserverUserPolicyApplication/HomeserverUserPolicyProtection";
|
||||
import "./InvalidEventProtection";
|
||||
import "./JoinWaveShortCircuit";
|
||||
import "./RedactionSynchronisation";
|
||||
import "./MembershipChangeProtection";
|
||||
|
||||
214
src/protections/InvalidEventProtection.tsx
Normal file
214
src/protections/InvalidEventProtection.tsx
Normal 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
|
||||
)
|
||||
),
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user