mirror of
https://github.com/the-draupnir-project/Draupnir.git
synced 2026-09-26 12:37:52 +00:00
Flesh out RoomDiscovery some more.
I really need to move the room details to the audit log, it's there so we can see what the rooms are that have been takendown.
This commit is contained in:
@@ -10,6 +10,7 @@ import {
|
||||
HashedLiteralPolicyRule,
|
||||
LiteralPolicyRule,
|
||||
makeReversedHashedPolicy,
|
||||
RoomBasicDetails,
|
||||
RoomHashRecord,
|
||||
SHA256RoomHashStore,
|
||||
} from "matrix-protection-suite";
|
||||
@@ -29,6 +30,14 @@ const schema = [
|
||||
room_id TEXT PRIMARY KEY NOT NULL,
|
||||
sha256 TEXT NOT NULL
|
||||
) STRICT;`,
|
||||
`CREATE TABLE room_detail (
|
||||
room_id TEXT PRIMARY KEY NOT NULL,
|
||||
creator TEXT,
|
||||
name TEXT,
|
||||
topic TEXT,
|
||||
joined_members INTEGER
|
||||
) STRICT;
|
||||
`,
|
||||
];
|
||||
|
||||
type RoomSha256 = {
|
||||
@@ -197,4 +206,33 @@ export class SqliteHashReversalStore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async storeRoomDetails(
|
||||
roomDetails: RoomBasicDetails
|
||||
): Promise<Result<void>> {
|
||||
try {
|
||||
const statement = this.db.prepare(`
|
||||
REPLACE INTO room_detail SELECT
|
||||
value ->> 'room_id',
|
||||
value ->> 'creator',
|
||||
value ->> 'name',
|
||||
value ->> 'topic',
|
||||
value ->> 'joined_members'
|
||||
FROM json_each(?)`);
|
||||
statement.run(JSON.stringify(roomDetails));
|
||||
return Ok(undefined);
|
||||
} catch (exception) {
|
||||
if (exception instanceof Error) {
|
||||
return ActionException.Result(
|
||||
`Error while trying to store details about a hashed room`,
|
||||
{
|
||||
exception,
|
||||
exceptionKind: ActionExceptionKind.Unknown,
|
||||
}
|
||||
);
|
||||
} else {
|
||||
throw exception;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,23 +4,17 @@
|
||||
|
||||
import { Result } from "@gnuxie/typescript-result";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import {
|
||||
StringRoomID,
|
||||
StringUserID,
|
||||
} from "@the-draupnir-project/matrix-basic-types";
|
||||
import { StringRoomID } from "@the-draupnir-project/matrix-basic-types";
|
||||
import {
|
||||
Capability,
|
||||
CapabilityMethodSchema,
|
||||
describeCapabilityInterface,
|
||||
RoomBasicDetails,
|
||||
} from "matrix-protection-suite";
|
||||
|
||||
export type RoomTakedownDetails = Partial<{
|
||||
creator: StringUserID | undefined;
|
||||
room_id: StringRoomID;
|
||||
name: string | undefined;
|
||||
topic: string | undefined;
|
||||
avatar: string | undefined;
|
||||
}>;
|
||||
export interface RoomDetailsProvider {
|
||||
getRoomDetails(roomID: StringRoomID): Promise<Result<RoomBasicDetails>>;
|
||||
}
|
||||
|
||||
export const RoomTakedownCapability = Type.Intersect([
|
||||
Type.Object({
|
||||
@@ -35,7 +29,7 @@ export const RoomTakedownCapability = Type.Intersect([
|
||||
// to get those details on conduwuit.
|
||||
export type RoomTakedownCapability = {
|
||||
isRoomTakendown(roomID: StringRoomID): Promise<Result<boolean>>;
|
||||
takedownRoom(roomID: StringRoomID): Promise<Result<RoomTakedownDetails>>;
|
||||
takedownRoom(roomID: StringRoomID): Promise<Result<RoomBasicDetails>>;
|
||||
} & Capability;
|
||||
|
||||
describeCapabilityInterface({
|
||||
|
||||
@@ -5,11 +5,9 @@
|
||||
import {
|
||||
describeCapabilityRenderer,
|
||||
DescriptionMeta,
|
||||
RoomBasicDetails,
|
||||
} from "matrix-protection-suite";
|
||||
import {
|
||||
RoomTakedownCapability,
|
||||
RoomTakedownDetails,
|
||||
} from "./RoomTakedownCapability";
|
||||
import { RoomTakedownCapability } from "./RoomTakedownCapability";
|
||||
import { RendererMessageCollector } from "./RendererMessageCollector";
|
||||
import {
|
||||
MatrixRoomReference,
|
||||
@@ -37,7 +35,7 @@ function renderCodeOrDefault(
|
||||
|
||||
function renderTakedown(
|
||||
roomID: StringRoomID,
|
||||
details: RoomTakedownDetails
|
||||
details: RoomBasicDetails
|
||||
): DocumentNode {
|
||||
return (
|
||||
<details>
|
||||
@@ -83,7 +81,7 @@ class StandardRoomTakedownCapabilityRenderer implements RoomTakedownCapability {
|
||||
|
||||
public async takedownRoom(
|
||||
roomID: StringRoomID
|
||||
): Promise<Result<RoomTakedownDetails>> {
|
||||
): Promise<Result<RoomBasicDetails>> {
|
||||
const capabilityResult = await this.capability.takedownRoom(roomID);
|
||||
if (isError(capabilityResult)) {
|
||||
this.messageCollector.addOneliner(
|
||||
|
||||
@@ -3,22 +3,47 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { StringRoomID } from "@the-draupnir-project/matrix-basic-types";
|
||||
import { Logger } from "matrix-protection-suite";
|
||||
import { Logger, RoomBasicDetails } from "matrix-protection-suite";
|
||||
import {
|
||||
RoomDetailsProvider,
|
||||
RoomTakedownCapability,
|
||||
RoomTakedownDetails,
|
||||
} from "../RoomTakedownCapability";
|
||||
import { SynapseAdminClient } from "matrix-protection-suite-for-matrix-bot-sdk";
|
||||
import { isError, Ok, Result } from "@gnuxie/typescript-result";
|
||||
|
||||
const log = new Logger("SynapseAdminRoomTakedownCapability");
|
||||
|
||||
export class SynapseAdminRoomDetailsProvider implements RoomDetailsProvider {
|
||||
public constructor(private readonly adminClient: SynapseAdminClient) {
|
||||
// nothing to do mare.
|
||||
}
|
||||
public async getRoomDetails(
|
||||
roomID: StringRoomID
|
||||
): Promise<Result<RoomBasicDetails>> {
|
||||
const detailsResponse = await this.adminClient.getRoomDetails(roomID);
|
||||
if (isError(detailsResponse)) {
|
||||
return detailsResponse;
|
||||
} else {
|
||||
return Ok({
|
||||
name: detailsResponse.ok?.name ?? undefined,
|
||||
creator: detailsResponse.ok?.creator,
|
||||
avatar: detailsResponse.ok?.avatar ?? undefined,
|
||||
topic: detailsResponse.ok?.topic ?? undefined,
|
||||
room_id: roomID,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class SynapseAdminRoomTakedownCapability
|
||||
implements RoomTakedownCapability
|
||||
{
|
||||
public readonly requiredPermissions = [];
|
||||
public readonly requiredStatePermissions = [];
|
||||
public readonly requiredEventPermissions = [];
|
||||
private readonly roomDetailsProvider = new SynapseAdminRoomDetailsProvider(
|
||||
this.adminClient
|
||||
);
|
||||
public constructor(private readonly adminClient: SynapseAdminClient) {
|
||||
// nothing to do mare.
|
||||
}
|
||||
@@ -33,9 +58,10 @@ export class SynapseAdminRoomTakedownCapability
|
||||
|
||||
public async takedownRoom(
|
||||
roomID: StringRoomID
|
||||
): Promise<Result<RoomTakedownDetails>> {
|
||||
const detailsResponse = await this.adminClient.getRoomDetails(roomID);
|
||||
let details: RoomTakedownDetails;
|
||||
): Promise<Result<RoomBasicDetails>> {
|
||||
const detailsResponse =
|
||||
await this.roomDetailsProvider.getRoomDetails(roomID);
|
||||
let details: RoomBasicDetails;
|
||||
if (isError(detailsResponse)) {
|
||||
log.warn(
|
||||
"Unable to fetch details for a room being requested to shutdown",
|
||||
@@ -43,13 +69,7 @@ export class SynapseAdminRoomTakedownCapability
|
||||
);
|
||||
details = { room_id: roomID };
|
||||
} else {
|
||||
details = {
|
||||
name: detailsResponse.ok?.name ?? undefined,
|
||||
creator: detailsResponse.ok?.creator,
|
||||
avatar: detailsResponse.ok?.avatar ?? undefined,
|
||||
topic: detailsResponse.ok?.topic ?? undefined,
|
||||
room_id: roomID,
|
||||
};
|
||||
details = detailsResponse.ok;
|
||||
}
|
||||
const takedownResult = await this.adminClient.deleteRoom(roomID, {
|
||||
block: true,
|
||||
|
||||
@@ -6,22 +6,34 @@ import {
|
||||
ConstantPeriodItemBatch,
|
||||
isError,
|
||||
Logger,
|
||||
RoomBasicDetails,
|
||||
RoomHashRecord,
|
||||
SHA256RoomHashStore,
|
||||
StandardBatcher,
|
||||
Task,
|
||||
} from "matrix-protection-suite";
|
||||
import { CheckEventForSpamRequestBody } from "../../webapis/SynapseHTTPAntispam/CheckEventForSpamEndpoint";
|
||||
import { SynapseHttpAntispam } from "../../webapis/SynapseHTTPAntispam/SynapseHttpAntispam";
|
||||
import { StringRoomID } from "@the-draupnir-project/matrix-basic-types";
|
||||
import { UserMayInviteRequestBody } from "../../webapis/SynapseHTTPAntispam/UserMayInviteEndpoint";
|
||||
import { UserMayJoinRoomRequestBody } from "../../webapis/SynapseHTTPAntispam/UserMayJoinRoomEndpoint";
|
||||
import { EventEmitter } from "stream";
|
||||
import { RoomDetailsProvider } from "../../capabilities/RoomTakedownCapability";
|
||||
|
||||
const log = new Logger("SynapseHTTPAntispamRoomDiscovery");
|
||||
|
||||
export type RoomDiscoveryListener = (details: RoomBasicDetails) => void;
|
||||
|
||||
export interface RoomDiscovery {
|
||||
unregisterListeners(): void;
|
||||
on(event: "RoomDiscovery", listener: RoomDiscoveryListener): this;
|
||||
off(event: "RoomDiscovery", listener: RoomDiscoveryListener): this;
|
||||
emit(event: "RoomDiscovery", details: RoomBasicDetails): void;
|
||||
}
|
||||
|
||||
export class SynapseHTTPAntispamRoomDiscovery implements RoomDiscovery {
|
||||
export class SynapseHTTPAntispamRoomDiscovery
|
||||
extends EventEmitter
|
||||
implements RoomDiscovery
|
||||
{
|
||||
private readonly discoveredRooms = new Set<StringRoomID>();
|
||||
private readonly batcher = new StandardBatcher(
|
||||
() =>
|
||||
@@ -32,8 +44,10 @@ export class SynapseHTTPAntispamRoomDiscovery implements RoomDiscovery {
|
||||
);
|
||||
constructor(
|
||||
private readonly synapseHTTPAntispam: SynapseHttpAntispam,
|
||||
private readonly hashStore: SHA256RoomHashStore
|
||||
private readonly hashStore: SHA256RoomHashStore,
|
||||
private readonly roomDetailsProvider: RoomDetailsProvider
|
||||
) {
|
||||
super();
|
||||
synapseHTTPAntispam.checkEventForSpamHandles.registerNonBlockingHandle(
|
||||
this.handleCheckEventForSpam
|
||||
);
|
||||
@@ -45,6 +59,36 @@ export class SynapseHTTPAntispamRoomDiscovery implements RoomDiscovery {
|
||||
);
|
||||
}
|
||||
|
||||
private addRoomDetails(discoveredRooms: RoomHashRecord[]): void {
|
||||
void Task(
|
||||
(async () => {
|
||||
for (const { room_id: roomID } of discoveredRooms) {
|
||||
const detailsResult =
|
||||
await this.roomDetailsProvider.getRoomDetails(roomID);
|
||||
if (isError(detailsResult)) {
|
||||
log.error(
|
||||
"Error fetching details for a discovered room",
|
||||
roomID,
|
||||
detailsResult.error
|
||||
);
|
||||
continue;
|
||||
}
|
||||
this.emit("RoomDiscovery", detailsResult.ok);
|
||||
const storeResult = await this.hashStore.storeRoomDetails(
|
||||
detailsResult.ok
|
||||
);
|
||||
if (isError(storeResult)) {
|
||||
log.error(
|
||||
"Error storing room details for a room",
|
||||
roomID,
|
||||
detailsResult.ok
|
||||
);
|
||||
}
|
||||
}
|
||||
})()
|
||||
);
|
||||
}
|
||||
|
||||
private readonly forwardDiscoveredBatch = async function (
|
||||
this: SynapseHTTPAntispamRoomDiscovery,
|
||||
rawEntries: [StringRoomID][]
|
||||
@@ -61,6 +105,7 @@ export class SynapseHTTPAntispamRoomDiscovery implements RoomDiscovery {
|
||||
for (const roomID of entries) {
|
||||
this.discoveredRooms.add(roomID);
|
||||
}
|
||||
this.addRoomDetails(storeResult.ok);
|
||||
}.bind(this);
|
||||
|
||||
private readonly handleCheckEventForSpam = function (
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
// SPDX-FileCopyrightText: 2025 Gnuxie <Gnuxie@protonmail.com>
|
||||
//
|
||||
// SPDX-License-Identifier: AFL-3.0
|
||||
|
||||
import { DocumentNode } from "@the-draupnir-project/interface-manager";
|
||||
import { RoomBasicDetails } from "matrix-protection-suite";
|
||||
import { DeadDocumentJSX } from "@the-draupnir-project/interface-manager";
|
||||
|
||||
export function renderDiscoveredRoom(details: RoomBasicDetails): DocumentNode {
|
||||
return (
|
||||
<fragment>
|
||||
<h3>Room Discovered</h3>
|
||||
<ul>
|
||||
<li>
|
||||
name: <code>{details.name ?? "Unamed room"}</code>
|
||||
</li>
|
||||
<li>
|
||||
member count: <code>{details.joined_members ?? "unknown"}</code>
|
||||
</li>
|
||||
<li>
|
||||
room ID: <code>{details.room_id}</code>
|
||||
</li>
|
||||
<li>
|
||||
creator: <code>{details.creator ?? "unknown"}</code>
|
||||
</li>
|
||||
<li>
|
||||
topic: <pre>{details.topic ?? "unknown"}</pre>
|
||||
</li>
|
||||
</ul>
|
||||
</fragment>
|
||||
);
|
||||
}
|
||||
@@ -22,6 +22,10 @@ const log = new Logger("RoomTakedown");
|
||||
|
||||
// FIXME: How can we segment this so that rooms are takendown on prompt in
|
||||
// the abscence of policy approval?
|
||||
// Probably by using a simulated capability that asks to confirm and then
|
||||
// does the takedown for real?
|
||||
// Probably by using an updated version of the shutdown command...
|
||||
// Ok no that all sucks we'll have to wait for policy approval...
|
||||
|
||||
export type RoomTakedownService = {
|
||||
handleDiscoveredRooms(rooms: StringRoomID[]): Promise<Result<void>>;
|
||||
|
||||
@@ -13,30 +13,68 @@ import {
|
||||
ProtectedRoomsSet,
|
||||
Protection,
|
||||
ProtectionDescription,
|
||||
RoomBasicDetails,
|
||||
RoomMessageSender,
|
||||
SHA256RoomHashStore,
|
||||
StringRoomIDSchema,
|
||||
Task,
|
||||
UnknownConfig,
|
||||
} from "matrix-protection-suite";
|
||||
import { RoomTakedownCapability } from "../../capabilities/RoomTakedownCapability";
|
||||
import { Draupnir } from "../../Draupnir";
|
||||
import { StandardRoomTakedown } from "./RoomTakedown";
|
||||
import { RoomAuditLog } from "./RoomAuditLog";
|
||||
import { SynapseAdminRoomTakedownCapability } from "../../capabilities/SynapseAdminRoomTakedown/SynapseAdminRoomTakedown";
|
||||
import { ResultError } from "@gnuxie/typescript-result";
|
||||
import {
|
||||
SynapseAdminRoomDetailsProvider,
|
||||
SynapseAdminRoomTakedownCapability,
|
||||
} from "../../capabilities/SynapseAdminRoomTakedown/SynapseAdminRoomTakedown";
|
||||
import { isError, ResultError } from "@gnuxie/typescript-result";
|
||||
import {
|
||||
RoomDiscovery,
|
||||
RoomDiscoveryListener,
|
||||
SynapseHTTPAntispamRoomDiscovery,
|
||||
} from "./RoomDiscovery";
|
||||
import { StringRoomID } from "@the-draupnir-project/matrix-basic-types";
|
||||
import { sendMatrixEventsFromDeadDocument } from "../../commands/interface-manager/MPSMatrixInterfaceAdaptor";
|
||||
import { wrapInRoot } from "../../commands/interface-manager/MatrixHelpRenderer";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { EDStatic } from "matrix-protection-suite/dist/Interface/Static";
|
||||
import { renderDiscoveredRoom } from "./RoomDiscoveryRenderer";
|
||||
|
||||
const log = new Logger("RoomTakedownProtection");
|
||||
|
||||
const RoomTakedownProtectionSettings = Type.Object({
|
||||
discoveryNotificationMembershipThreshold: Type.Integer({
|
||||
default: 20,
|
||||
description:
|
||||
"The number of members required in the room for it to appear in the notification. This is to prevent showing direct messages or small rooms that could be too much of an invasion of privacy. We don't have access to enough information to determine this a better way.",
|
||||
}),
|
||||
// There needs to be a transform for room references
|
||||
discoveryNotificationRoom: Type.Union(
|
||||
[StringRoomIDSchema, Type.Undefined()],
|
||||
{
|
||||
default: undefined,
|
||||
description:
|
||||
"The room where notifications should be sent. Currently broken and needs to be edited from a state event while we figure something out",
|
||||
}
|
||||
),
|
||||
discoveryNotificationEnabled: Type.Boolean({
|
||||
default: true,
|
||||
description:
|
||||
"Wether to send notifications for newly discovered rooms from the homerserver.",
|
||||
}),
|
||||
});
|
||||
|
||||
type RoomTakedownProtectionSettings = EDStatic<
|
||||
typeof RoomTakedownProtectionSettings
|
||||
>;
|
||||
|
||||
type RoomTakedownProtectionCapabilities = {
|
||||
roomTakedownCapability: RoomTakedownCapability;
|
||||
};
|
||||
|
||||
type RoomTakedownProtectionDescription = ProtectionDescription<
|
||||
Draupnir,
|
||||
UnknownConfig,
|
||||
typeof RoomTakedownProtectionSettings,
|
||||
RoomTakedownProtectionCapabilities
|
||||
>;
|
||||
|
||||
@@ -51,6 +89,10 @@ export class RoomTakedownProtection
|
||||
protectedRoomsSet: ProtectedRoomsSet,
|
||||
hashStore: SHA256RoomHashStore,
|
||||
auditLog: RoomAuditLog,
|
||||
private readonly roomMessageSender: RoomMessageSender,
|
||||
private readonly discoveryNotificationEnabled: boolean,
|
||||
private readonly discoveryNotificationMembershipThreshold: number,
|
||||
private readonly discoveryNotificationRoom: StringRoomID,
|
||||
private readonly roomDiscovery: RoomDiscovery | undefined
|
||||
) {
|
||||
super(description, capabilities, protectedRoomsSet, {});
|
||||
@@ -64,8 +106,40 @@ export class RoomTakedownProtection
|
||||
this.protectedRoomsSet.watchedPolicyRooms.currentRevision
|
||||
)
|
||||
);
|
||||
if (this.discoveryNotificationEnabled) {
|
||||
this.roomDiscovery?.on("RoomDiscovery", this.roomDiscoveryListener);
|
||||
}
|
||||
}
|
||||
|
||||
private readonly roomDiscoveryListener: RoomDiscoveryListener = function (
|
||||
this: RoomTakedownProtection,
|
||||
details: RoomBasicDetails
|
||||
) {
|
||||
if (
|
||||
(details.joined_members ?? 0) <
|
||||
this.discoveryNotificationMembershipThreshold
|
||||
) {
|
||||
return;
|
||||
}
|
||||
void Task(
|
||||
(async () => {
|
||||
const sendResult = await sendMatrixEventsFromDeadDocument(
|
||||
this.roomMessageSender,
|
||||
this.discoveryNotificationRoom,
|
||||
wrapInRoot(renderDiscoveredRoom(details)),
|
||||
{}
|
||||
);
|
||||
if (isError(sendResult)) {
|
||||
log.error(
|
||||
"Error sending a notification about a discovered room",
|
||||
details.room_id,
|
||||
sendResult.error
|
||||
);
|
||||
}
|
||||
})()
|
||||
);
|
||||
}.bind(this);
|
||||
|
||||
handlePolicyChange(
|
||||
revision: PolicyListRevision,
|
||||
changes: PolicyRuleChange[]
|
||||
@@ -75,10 +149,15 @@ export class RoomTakedownProtection
|
||||
|
||||
handleProtectionDisable(): void {
|
||||
this.roomDiscovery?.unregisterListeners();
|
||||
this.roomDiscovery?.off("RoomDiscovery", this.roomDiscoveryListener);
|
||||
}
|
||||
}
|
||||
|
||||
describeProtection<RoomTakedownProtectionCapabilities, Draupnir>({
|
||||
describeProtection<
|
||||
RoomTakedownProtectionCapabilities,
|
||||
Draupnir,
|
||||
typeof RoomTakedownProtectionSettings
|
||||
>({
|
||||
name: RoomTakedownProtection.name,
|
||||
description: `A protection to shutdown rooms matching policies from watched lists`,
|
||||
capabilityInterfaces: {
|
||||
@@ -87,7 +166,7 @@ describeProtection<RoomTakedownProtectionCapabilities, Draupnir>({
|
||||
defaultCapabilities: {
|
||||
roomTakedownCapability: SynapseAdminRoomTakedownCapability.name,
|
||||
},
|
||||
factory(description, protectedRoomsSet, draupnir, capabilitySet, _settings) {
|
||||
factory(description, protectedRoomsSet, draupnir, capabilitySet, settings) {
|
||||
if (
|
||||
draupnir.stores.hashStore === undefined ||
|
||||
draupnir.stores.roomAuditLog === undefined
|
||||
@@ -97,10 +176,20 @@ describeProtection<RoomTakedownProtectionCapabilities, Draupnir>({
|
||||
);
|
||||
}
|
||||
const roomDiscovery = (() => {
|
||||
const roomDetailsProvider = draupnir.synapseAdminClient
|
||||
? new SynapseAdminRoomDetailsProvider(draupnir.synapseAdminClient)
|
||||
: undefined;
|
||||
if (roomDetailsProvider === undefined) {
|
||||
log.warn(
|
||||
"This protection currently requires synapse admin capability in order to fetch room details"
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
if (draupnir.synapseHTTPAntispam !== undefined) {
|
||||
return new SynapseHTTPAntispamRoomDiscovery(
|
||||
draupnir.synapseHTTPAntispam,
|
||||
draupnir.stores.hashStore
|
||||
draupnir.stores.hashStore,
|
||||
roomDetailsProvider
|
||||
);
|
||||
} else {
|
||||
log.warn(
|
||||
@@ -116,6 +205,10 @@ describeProtection<RoomTakedownProtectionCapabilities, Draupnir>({
|
||||
protectedRoomsSet,
|
||||
draupnir.stores.hashStore,
|
||||
draupnir.stores.roomAuditLog,
|
||||
draupnir.clientPlatform.toRoomMessageSender(),
|
||||
settings.discoveryNotificationEnabled,
|
||||
settings.discoveryNotificationMembershipThreshold,
|
||||
settings.discoveryNotificationRoom ?? draupnir.managementRoomID,
|
||||
roomDiscovery
|
||||
)
|
||||
);
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
// SPDX-FileCopyrightText: 2025 Gnuxie <Gnuxie@protonmail.com>
|
||||
//
|
||||
// SPDX-License-Identifier: AFL-3.0
|
||||
Reference in New Issue
Block a user