Add Explicit support for allocating multiple Bots per user to AS mode.

This commit is contained in:
Catalan Lover
2026-03-26 12:59:02 +01:00
parent c7d52ed044
commit 665f1f8c52
8 changed files with 165 additions and 3 deletions

View File

@@ -1,5 +1,6 @@
// Copyright 2022 Gnuxie <Gnuxie@protonmail.com>
// Copyright 2022 The Matrix.org Foundation C.I.C.
// SPDX-FileCopyrightText: 2026 Catalan Lover <catalanlover@protonmail.com>
//
// SPDX-License-Identifier: AFL-3.0 AND Apache-2.0
//
@@ -185,6 +186,7 @@ export class MjolnirAppService {
const serverName = config.homeserver.domain;
const mjolnirManager = await AppServiceDraupnirManager.makeDraupnirManager(
serverName,
config.maxDraupnirsPerUser ?? 1,
dataStore,
bridge,
accessControl,

View File

@@ -1,5 +1,6 @@
// Copyright 2022 - 2024 Gnuxie <Gnuxie@protonmail.com>
// Copyright 2022 The Matrix.org Foundation C.I.C.
// SPDX-FileCopyrightText: 2026 Catalan Lover <catalanlover@protonmail.com>
//
// SPDX-License-Identifier: AFL-3.0 AND Apache-2.0
//
@@ -66,6 +67,7 @@ export class AppServiceDraupnirManager {
private constructor(
private readonly serverName: string,
private readonly maxDraupnirsPerUser: number,
private readonly dataStore: DataStore,
private readonly bridge: Bridge,
private readonly accessControl: AccessControl,
@@ -103,6 +105,7 @@ export class AppServiceDraupnirManager {
*/
public static async makeDraupnirManager(
serverName: string,
maxDraupnirsPerUser: number,
dataStore: DataStore,
bridge: Bridge,
accessControl: AccessControl,
@@ -115,6 +118,7 @@ export class AppServiceDraupnirManager {
): Promise<AppServiceDraupnirManager> {
const draupnirManager = new AppServiceDraupnirManager(
serverName,
maxDraupnirsPerUser,
dataStore,
bridge,
accessControl,
@@ -199,6 +203,19 @@ export class AppServiceDraupnirManager {
*/
public async provisionNewDraupnir(
requestingUserID: StringUserID
): Promise<ActionResult<MjolnirRecord>> {
return await this.provisionNewDraupnirInternal(requestingUserID, false);
}
public async provisionNewDraupnirBypassingUserLimit(
requestingUserID: StringUserID
): Promise<ActionResult<MjolnirRecord>> {
return await this.provisionNewDraupnirInternal(requestingUserID, true);
}
private async provisionNewDraupnirInternal(
requestingUserID: StringUserID,
bypassUserLimit: boolean
): Promise<ActionResult<MjolnirRecord>> {
const access = this.accessControl.getUserAccess(requestingUserID);
if (access.outcome !== Access.Allowed) {
@@ -208,7 +225,10 @@ export class AppServiceDraupnirManager {
}
const provisionedMjolnirs =
await this.dataStore.lookupByOwner(requestingUserID);
if (provisionedMjolnirs.length === 0) {
if (
bypassUserLimit ||
provisionedMjolnirs.length < this.maxDraupnirsPerUser
) {
const mjolnirLocalPart = `draupnir_${randomUUID()}`;
const mjIntent = await this.makeMatrixIntent(mjolnirLocalPart);
const draupnirUserID = StringUserID(mjIntent.userId);
@@ -263,7 +283,7 @@ export class AppServiceDraupnirManager {
return Ok(record);
} else {
return ActionError.Result(
`User: ${requestingUserID} has already provisioned ${provisionedMjolnirs.length} draupnirs.`
`User: ${requestingUserID} has already provisioned ${provisionedMjolnirs.length} draupnirs, which meets the configured limit of ${this.maxDraupnirsPerUser}.`
);
}
}

View File

@@ -1,4 +1,5 @@
// SPDX-FileCopyrightText: 2024 Gnuxie <Gnuxie@protonmail.com>
// SPDX-FileCopyrightText: 2026 Catalan Lover <catalanlover@protonmail.com>
//
// SPDX-License-Identifier: Apache-2.0
//
@@ -17,12 +18,14 @@ import {
AppserviceListUnstartedCommand,
AppserviceRestartDraupnirCommand,
} from "./ListCommand";
import { AppserviceProvisionForUserCommand } from "./ProvisionCommand";
import { AppserviceVersionCommand } from "./VersionCommand";
AppserviceBotCommands.internCommand(AppserviceBotHelpCommand, ["admin", "help"])
.internCommand(AppserviceAllowCommand, ["admin", "allow"])
.internCommand(AppserviceRemoveCommand, ["admin", "remove"])
.internCommand(AppserviceVersionCommand, ["admin", "version"])
.internCommand(AppserviceProvisionForUserCommand, ["admin", "provision"])
.internCommand(AppserviceRestartDraupnirCommand, ["admin", "restart"])
.internCommand(AppserviceListUnstartedCommand, [
"admin",

View File

@@ -0,0 +1,42 @@
// Copyright 2026 Catalan Lover <catalanlover@protonmail.com>
//
// SPDX-License-Identifier: AFL-3.0
import { AppserviceAdaptorContext } from "./AppserviceBotPrerequisite";
import { ActionResult, isError, Ok } from "matrix-protection-suite";
import {
MatrixUserIDPresentationType,
describeCommand,
tuple,
} from "@the-draupnir-project/interface-manager";
import { AppserviceBotInterfaceAdaptor } from "./AppserviceBotInterfaceAdaptor";
export const AppserviceProvisionForUserCommand = describeCommand({
parameters: tuple({
name: "user",
acceptor: MatrixUserIDPresentationType,
description: "The user to provision a bot for, bypassing user allocation limit",
}),
summary:
"Provision a new Draupnir for a user while bypassing the per-user allocation limit.",
async executor(
context: AppserviceAdaptorContext,
_info,
_keywords,
_rest,
user
): Promise<ActionResult<void>> {
const result =
await context.appservice.draupnirManager.provisionNewDraupnirBypassingUserLimit(
user.toString()
);
if (isError(result)) {
return result;
}
return Ok(undefined);
},
});
AppserviceBotInterfaceAdaptor.describeRenderer(AppserviceProvisionForUserCommand, {
isAlwaysSupposedToUseDefaultRenderer: true,
});

View File

@@ -1,3 +1,5 @@
# SPDX-FileCopyrightText: 2026 Catalan Lover <catalanlover@protonmail.com>
homeserver:
# The Matrix server name, this will be the name of the server in your matrix id.
domain: "localhost:9999"
@@ -16,5 +18,9 @@ adminRoom: "#draupnir-admin:localhost:9999"
# The directory the bot should store various bits of information in
dataPath: "/data/storage"
# Maximum number of bots each user can provision.
# Defaults to 1 when omitted.
maxDraupnirsPerUser: 1
roomStateBackingStore:
enabled: false

View File

@@ -1,5 +1,6 @@
// Copyright 2022 Gnuxie <Gnuxie@protonmail.com>
// Copyright 2022 The Matrix.org Foundation C.I.C.
// SPDX-FileCopyrightText: 2026 Catalan Lover <catalanlover@protonmail.com>
//
// SPDX-License-Identifier: AFL-3.0 AND Apache-2.0
//
@@ -100,5 +101,13 @@ export const AppserviceConfig = Type.Object({
"A directory where the appservice can storestore persistent data.",
default: "/data/storage",
}),
maxDraupnirsPerUser: Type.Optional(
Type.Integer({
description:
"Maximum number of Draupnir instances a single user may provision.",
minimum: 1,
default: 1,
})
),
logging: Type.Optional(LoggingOptsSchema),
});

View File

@@ -1,5 +1,6 @@
// Copyright 2022 Gnuxie <Gnuxie@protonmail.com>
// Copyright 2022 The Matrix.org Foundation C.I.C.
// SPDX-FileCopyrightText: 2026 Catalan Lover <catalanlover@protonmail.com>
//
// SPDX-License-Identifier: AFL-3.0 AND Apache-2.0
//
@@ -8,7 +9,11 @@
// https://github.com/matrix-org/mjolnir
// </text>
import { readTestConfig, setupHarness } from "../utils/harness";
import {
readTestConfig,
setupHarness,
setupHarnessWithConfig,
} from "../utils/harness";
import { newTestUser } from "../../integration/clientHelper";
import { getFirstReply } from "../../integration/commands/commandUtils";
import { MatrixClient } from "@vector-im/matrix-bot-sdk";
@@ -118,4 +123,72 @@ describe("Test that the app service can provision a draupnir on invite of the ap
allowedUser.stop();
blockedUser.stop();
});
it("Allows provisioning multiple bots per user up to maxDraupnirsPerUser", async function (this: Context) {
const config = readTestConfig();
config.maxDraupnirsPerUser = 2;
this.appservice = await setupHarnessWithConfig(config);
const appservice = this.appservice;
const allowedUser = await newTestUser(config.homeserver.url, {
name: { contains: "multi-allowed" },
});
const allowedUserID = (await allowedUser.getUserId()) as StringUserID;
const allowResult = await appservice.accessControl.allow(allowedUserID);
if (isError(allowResult)) {
throw allowResult.error;
}
const firstProvisionResult =
await appservice.draupnirManager.provisionNewDraupnir(allowedUserID);
if (isError(firstProvisionResult)) {
throw firstProvisionResult.error;
}
const secondProvisionResult =
await appservice.draupnirManager.provisionNewDraupnir(allowedUserID);
if (isError(secondProvisionResult)) {
throw secondProvisionResult.error;
}
const thirdProvisionResult =
await appservice.draupnirManager.provisionNewDraupnir(allowedUserID);
if (!isError(thirdProvisionResult)) {
throw new TypeError(
`Expected provisioning to fail after reaching limit for user ${allowedUserID}`
);
}
allowedUser.stop();
});
it("Admin provisioning path can bypass per-user allocation limit", async function (this: Context) {
const config = readTestConfig();
config.maxDraupnirsPerUser = 1;
this.appservice = await setupHarnessWithConfig(config);
const appservice = this.appservice;
const allowedUser = await newTestUser(config.homeserver.url, {
name: { contains: "admin-bypass-allowed" },
});
const allowedUserID = (await allowedUser.getUserId()) as StringUserID;
const allowResult = await appservice.accessControl.allow(allowedUserID);
if (isError(allowResult)) {
throw allowResult.error;
}
const firstProvisionResult =
await appservice.draupnirManager.provisionNewDraupnir(allowedUserID);
if (isError(firstProvisionResult)) {
throw firstProvisionResult.error;
}
const secondProvisionResult =
await appservice.draupnirManager.provisionNewDraupnirBypassingUserLimit(
allowedUserID
);
if (isError(secondProvisionResult)) {
throw secondProvisionResult.error;
}
allowedUser.stop();
});
});

View File

@@ -1,5 +1,6 @@
// Copyright 2022 Gnuxie <Gnuxie@protonmail.com>
// Copyright 2022 The Matrix.org Foundation C.I.C.
// SPDX-FileCopyrightText: 2026 Catalan Lover <catalanlover@protonmail.com>
//
// SPDX-License-Identifier: AFL-3.0 AND Apache-2.0
//
@@ -28,6 +29,12 @@ export function readTestConfig(): AppserviceConfig {
export async function setupHarness(): Promise<MjolnirAppService> {
const config = readTestConfig();
return await setupHarnessWithConfig(config);
}
export async function setupHarnessWithConfig(
config: AppserviceConfig
): Promise<MjolnirAppService> {
const utilityUser = await newTestUser(config.homeserver.url, {
name: { contains: "utility" },
});