Skip to content

Commit 30611aa

Browse files
FSG-Catpre-commit-ci[bot]Gnuxie
authored
Appservice Improvements Fixed (#1061)
* Add Version command to AS mode admin room This replicates the status command output giving version string data in a convenient format to AS Admins. * Add Provision Limit and Force Provision Command * Add SelfServiceProvisioning Config Flag that defaults to False This defaults to false due to the currently known problem where Self Service Provisioning ACLs are broken. So this config exists to allow that codepath to be disabled while we work on fixing it. * Add Copyright Headers * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Split Version Command integration test out into new file. * Fix Self Service Provision Message Co-authored-by: Gnuxie <50846879+Gnuxie@users.noreply.github.com> * FIx AS.TS * Clean up Cat's tests --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Gnuxie <50846879+Gnuxie@users.noreply.github.com> Co-authored-by: gnuxie <Gnuxie@protonmail.com>
1 parent 5e15637 commit 30611aa

13 files changed

Lines changed: 425 additions & 48 deletions

.changeset/icy-doodles-create.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"draupnir": patch
3+
---
4+
5+
Add version command for appservice mode.

.changeset/orange-pots-win.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"draupnir": major
3+
---
4+
5+
Require Self Service Provisioning to be Manually Enabled in config. Breaks
6+
existing workflows.

.changeset/sunny-cooks-jump.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"draupnir": minor
3+
---
4+
5+
Add appservice provision limits and add appservice force provision command.

apps/draupnir/src/appservice/AppService.ts

Lines changed: 86 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
// Copyright 2022 Gnuxie <Gnuxie@protonmail.com>
22
// Copyright 2022 The Matrix.org Foundation C.I.C.
3+
// SPDX-FileCopyrightText: 2026 Catalan Lover <catalanlover@protonmail.com>
34
//
45
// SPDX-License-Identifier: AFL-3.0 AND Apache-2.0
56
//
@@ -185,6 +186,7 @@ export class MjolnirAppService {
185186
const serverName = config.homeserver.domain;
186187
const mjolnirManager = await AppServiceDraupnirManager.makeDraupnirManager(
187188
serverName,
189+
config.maxDraupnirsPerUser ?? 1,
188190
dataStore,
189191
bridge,
190192
accessControl,
@@ -263,60 +265,101 @@ export class MjolnirAppService {
263265
log.info(
264266
`${mxEvent.sender} has sent an invitation to the appservice bot ${this.bridge.botUserId}, attempting to provision them a draupnir`
265267
);
266-
// Join the room and try to send the welcome flow
268+
const client = this.bridge.getBot().getClient();
267269
try {
268-
await this.bridge.getBot().getClient().joinRoom(mxEvent.room_id);
269-
await this.bridge
270-
.getBot()
271-
.getClient()
272-
.sendText(
270+
// Join the room so we can notify the requester and then reject the invite.
271+
try {
272+
await client.joinRoom(mxEvent.room_id);
273+
} catch (e: unknown) {
274+
log.error(
275+
`Failed to join the room by ${mxEvent.sender} to process provisioning invite`,
276+
e
277+
);
278+
return;
279+
}
280+
281+
if (!this.config.allowSelfServiceProvisioning) {
282+
await client
283+
.sendText(
284+
mxEvent.room_id,
285+
"Self-service provisioning is disabled. Please ask an admin to provision a Draupnir for you."
286+
)
287+
.catch((e: unknown) => {
288+
log.error(
289+
`Failed to notify ${mxEvent.sender} that self-service provisioning is disabled`,
290+
e
291+
);
292+
});
293+
return;
294+
}
295+
296+
try {
297+
await client.sendText(
273298
mxEvent.room_id,
274299
"Your Draupnir is currently being provisioned. Please wait while we set up the rooms."
275300
);
276-
} catch (e: unknown) {
277-
log.error(
278-
`Failed to join the room by ${mxEvent.sender} to display the welcome flow`,
279-
e
280-
);
281-
}
282-
try {
283-
const result = await this.draupnirManager.provisionNewDraupnir(
284-
mxEvent.sender as StringUserID
285-
);
286-
if (isError(result)) {
301+
} catch (e: unknown) {
302+
log.error(
303+
`Failed to send provisioning welcome flow to ${mxEvent.sender}; aborting provisioning`,
304+
e
305+
);
306+
// We don´t want to continue with provisioning because we don´t have a working communications channel with the user.
307+
return;
308+
}
309+
// Ideallly we need to rework provisionNewDraupnir because its current state does not catch all expected errors.
310+
let provisioningFailed = false;
311+
try {
312+
const provisionResult = await this.draupnirManager.provisionNewDraupnir(
313+
mxEvent.sender as StringUserID
314+
);
315+
if (isError(provisionResult)) {
316+
log.error(
317+
`Failed to provision a draupnir for ${mxEvent.sender} after they invited ${this.bridge.botUserId}`,
318+
provisionResult.error
319+
);
320+
provisioningFailed = true;
321+
}
322+
} catch (e: unknown) {
287323
log.error(
288324
`Failed to provision a draupnir for ${mxEvent.sender} after they invited ${this.bridge.botUserId}`,
289-
result.error
325+
e
290326
);
327+
provisioningFailed = true;
328+
}
329+
330+
if (provisioningFailed) {
331+
try {
332+
await client.sendText(
333+
mxEvent.room_id,
334+
"Please make sure you are allowed to provision a bot. Otherwise please notify the admin. The provisioning request was rejected."
335+
);
336+
} catch (e: unknown) {
337+
log.error(
338+
`Failed to send provisioning failure flow to ${mxEvent.sender}`,
339+
e
340+
);
341+
}
342+
return;
291343
}
292-
// Send a notice that the invite must be accepted
293-
await this.bridge
294-
.getBot()
295-
.getClient()
296-
.sendText(
344+
// Send a notice that the invite must be accepted.
345+
try {
346+
await client.sendText(
297347
mxEvent.room_id,
298-
"Please accept the inviations to the newly provisioned rooms. These will be the home of your Draupnir Instance. This room will not be used in the future."
348+
"Please accept the invitations to the newly provisioned rooms. These will be the home of your Draupnir Instance. This room will not be used in the future."
299349
);
300-
} catch (e: unknown) {
301-
log.error(
302-
`Failed to provision a draupnir for ${mxEvent.sender} after they invited ${this.bridge.botUserId}:`,
303-
e
304-
);
305-
// continue, we still want to reject this invitation.
306-
// Send a notice that the provisioning failed
307-
await this.bridge
308-
.getBot()
309-
.getClient()
310-
.sendText(
311-
mxEvent.room_id,
312-
"Please make sure you are allowed to provision a bot. Otherwise please notify the admin. The provisioning request was rejected."
350+
} catch (e: unknown) {
351+
log.error(
352+
`Failed to send provisioning success flow to ${mxEvent.sender}`,
353+
e
313354
);
314-
}
315-
try {
316-
// reject the invite to keep the room clean and make sure the invetee doesn't get confused and think this is their draupnir.
317-
await this.bridge.getBot().getClient().leaveRoom(mxEvent.room_id);
318-
} catch (e: unknown) {
319-
log.warn("Unable to reject an invite to a room", e);
355+
}
356+
} finally {
357+
try {
358+
// Reject the invite to keep the room clean and make sure the invitee doesn't get confused and think this is their draupnir.
359+
await client.leaveRoom(mxEvent.room_id);
360+
} catch (e: unknown) {
361+
log.warn("Unable to reject an invite to a room", e);
362+
}
320363
}
321364
}
322365

apps/draupnir/src/appservice/AppServiceDraupnirManager.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
// Copyright 2022 - 2024 Gnuxie <Gnuxie@protonmail.com>
22
// Copyright 2022 The Matrix.org Foundation C.I.C.
3+
// SPDX-FileCopyrightText: 2026 Catalan Lover <catalanlover@protonmail.com>
34
//
45
// SPDX-License-Identifier: AFL-3.0 AND Apache-2.0
56
//
@@ -66,6 +67,7 @@ export class AppServiceDraupnirManager {
6667

6768
private constructor(
6869
private readonly serverName: string,
70+
private readonly maxDraupnirsPerUser: number,
6971
private readonly dataStore: DataStore,
7072
private readonly bridge: Bridge,
7173
private readonly accessControl: AccessControl,
@@ -103,6 +105,7 @@ export class AppServiceDraupnirManager {
103105
*/
104106
public static async makeDraupnirManager(
105107
serverName: string,
108+
maxDraupnirsPerUser: number,
106109
dataStore: DataStore,
107110
bridge: Bridge,
108111
accessControl: AccessControl,
@@ -115,6 +118,7 @@ export class AppServiceDraupnirManager {
115118
): Promise<AppServiceDraupnirManager> {
116119
const draupnirManager = new AppServiceDraupnirManager(
117120
serverName,
121+
maxDraupnirsPerUser,
118122
dataStore,
119123
bridge,
120124
accessControl,
@@ -199,6 +203,19 @@ export class AppServiceDraupnirManager {
199203
*/
200204
public async provisionNewDraupnir(
201205
requestingUserID: StringUserID
206+
): Promise<ActionResult<MjolnirRecord>> {
207+
return await this.provisionNewDraupnirInternal(requestingUserID, false);
208+
}
209+
210+
public async provisionNewDraupnirBypassingUserLimit(
211+
requestingUserID: StringUserID
212+
): Promise<ActionResult<MjolnirRecord>> {
213+
return await this.provisionNewDraupnirInternal(requestingUserID, true);
214+
}
215+
216+
private async provisionNewDraupnirInternal(
217+
requestingUserID: StringUserID,
218+
bypassUserLimit: boolean
202219
): Promise<ActionResult<MjolnirRecord>> {
203220
const access = this.accessControl.getUserAccess(requestingUserID);
204221
if (access.outcome !== Access.Allowed) {
@@ -208,7 +225,10 @@ export class AppServiceDraupnirManager {
208225
}
209226
const provisionedMjolnirs =
210227
await this.dataStore.lookupByOwner(requestingUserID);
211-
if (provisionedMjolnirs.length === 0) {
228+
if (
229+
bypassUserLimit ||
230+
provisionedMjolnirs.length < this.maxDraupnirsPerUser
231+
) {
212232
const mjolnirLocalPart = `draupnir_${randomUUID()}`;
213233
const mjIntent = await this.makeMatrixIntent(mjolnirLocalPart);
214234
const draupnirUserID = StringUserID(mjIntent.userId);
@@ -263,7 +283,7 @@ export class AppServiceDraupnirManager {
263283
return Ok(record);
264284
} else {
265285
return ActionError.Result(
266-
`User: ${requestingUserID} has already provisioned ${provisionedMjolnirs.length} draupnirs.`
286+
`User: ${requestingUserID} has already provisioned ${provisionedMjolnirs.length} draupnirs, which meets the configured limit of ${this.maxDraupnirsPerUser}.`
267287
);
268288
}
269289
}

apps/draupnir/src/appservice/bot/AppserviceBotCommands.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
// SPDX-FileCopyrightText: 2024 Gnuxie <Gnuxie@protonmail.com>
2+
// SPDX-FileCopyrightText: 2026 Catalan Lover <catalanlover@protonmail.com>
23
//
34
// SPDX-License-Identifier: Apache-2.0
45
//
@@ -17,10 +18,14 @@ import {
1718
AppserviceListUnstartedCommand,
1819
AppserviceRestartDraupnirCommand,
1920
} from "./ListCommand";
21+
import { AppserviceProvisionForUserCommand } from "./ProvisionCommand";
22+
import { AppserviceVersionCommand } from "./VersionCommand";
2023

2124
AppserviceBotCommands.internCommand(AppserviceBotHelpCommand, ["admin", "help"])
2225
.internCommand(AppserviceAllowCommand, ["admin", "allow"])
2326
.internCommand(AppserviceRemoveCommand, ["admin", "remove"])
27+
.internCommand(AppserviceProvisionForUserCommand, ["admin", "provision"])
28+
.internCommand(AppserviceVersionCommand, ["admin", "version"])
2429
.internCommand(AppserviceRestartDraupnirCommand, ["admin", "restart"])
2530
.internCommand(AppserviceListUnstartedCommand, [
2631
"admin",
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
// SPDX-FileCopyrightText: 2026 Catalan Lover <catalanlover@protonmail.com>
2+
//
3+
// SPDX-License-Identifier: AFL-3.0
4+
5+
import { AppserviceAdaptorContext } from "./AppserviceBotPrerequisite";
6+
import { ActionResult, isError, Ok } from "matrix-protection-suite";
7+
import {
8+
MatrixUserIDPresentationType,
9+
describeCommand,
10+
tuple,
11+
} from "@the-draupnir-project/interface-manager";
12+
import { AppserviceBotInterfaceAdaptor } from "./AppserviceBotInterfaceAdaptor";
13+
14+
export const AppserviceProvisionForUserCommand = describeCommand({
15+
parameters: tuple({
16+
name: "user",
17+
acceptor: MatrixUserIDPresentationType,
18+
description:
19+
"The user to provision a bot for, bypassing user allocation limit",
20+
}),
21+
summary:
22+
"Provision a new Draupnir for a user while bypassing the per-user allocation limit.",
23+
async executor(
24+
context: AppserviceAdaptorContext,
25+
_info,
26+
_keywords,
27+
_rest,
28+
user
29+
): Promise<ActionResult<void>> {
30+
const result =
31+
await context.appservice.draupnirManager.provisionNewDraupnirBypassingUserLimit(
32+
user.toString()
33+
);
34+
if (isError(result)) {
35+
return result;
36+
}
37+
return Ok(undefined);
38+
},
39+
});
40+
41+
AppserviceBotInterfaceAdaptor.describeRenderer(
42+
AppserviceProvisionForUserCommand,
43+
{
44+
isAlwaysSupposedToUseDefaultRenderer: true,
45+
}
46+
);
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
// SPDX-FileCopyrightText: 2026 Catalan Lover <catalanlover@protonmail.com>
2+
//
3+
// SPDX-License-Identifier: AFL-3.0
4+
5+
import { AppserviceAdaptorContext } from "./AppserviceBotPrerequisite";
6+
import { ActionResult, Ok, isError } from "matrix-protection-suite";
7+
import {
8+
DeadDocumentJSX,
9+
describeCommand,
10+
} from "@the-draupnir-project/interface-manager";
11+
import { AppserviceBotInterfaceAdaptor } from "./AppserviceBotInterfaceAdaptor";
12+
import { CURRENT_BRANCH, SOFTWARE_VERSION } from "../../config";
13+
14+
type AppserviceVersionInfo = {
15+
version: string;
16+
branch: string;
17+
};
18+
19+
export const AppserviceVersionCommand = describeCommand({
20+
summary:
21+
"Show Draupnir version and branch information for this appservice deployment.",
22+
parameters: [],
23+
async executor(
24+
_context: AppserviceAdaptorContext
25+
): Promise<ActionResult<AppserviceVersionInfo>> {
26+
return Ok({
27+
version: SOFTWARE_VERSION,
28+
branch: CURRENT_BRANCH,
29+
});
30+
},
31+
});
32+
33+
AppserviceBotInterfaceAdaptor.describeRenderer(AppserviceVersionCommand, {
34+
JSXRenderer(result) {
35+
if (isError(result)) {
36+
return Ok(undefined);
37+
}
38+
return Ok(
39+
<root>
40+
<b>Version: </b>
41+
<code>{result.ok.version}</code>
42+
<br />
43+
<b>Branch: </b>
44+
<code>{result.ok.branch}</code>
45+
</root>
46+
);
47+
},
48+
});

apps/draupnir/src/appservice/config/config.example.yaml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,5 +16,14 @@ adminRoom: "#draupnir-admin:localhost:9999"
1616
# The directory the bot should store various bits of information in
1717
dataPath: "/data/storage"
1818

19+
# Maximum number of bots each user can provision.
20+
# Defaults to 1 when omitted.
21+
maxDraupnirsPerUser: 1
22+
23+
# Allow users to self-provision by inviting the appservice bot.
24+
# When false, provisioning must be done through admin commands.
25+
# Defaults to false when omitted.
26+
allowSelfServiceProvisioning: false
27+
1928
roomStateBackingStore:
2029
enabled: false

0 commit comments

Comments
 (0)