Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
7fd7959
Apply prettier
Half-Shot May 14, 2025
0aeb68f
Consistent file naming scheme
Half-Shot May 14, 2025
b77b95c
Rename
Half-Shot May 14, 2025
256a83b
changelog
Half-Shot May 14, 2025
6b4a04d
fix imports
Half-Shot May 14, 2025
61a6eed
more lint
Half-Shot May 14, 2025
16e7b13
even more import fixes
Half-Shot May 14, 2025
f82827c
Add create package command
Half-Shot May 14, 2025
ca7e2b3
Refactor and add command for creating a work package based off a reply.
Half-Shot May 14, 2025
0ad8fd4
Add close command
Half-Shot May 14, 2025
cfe5d7a
Merge remote-tracking branch 'origin/main' into hs/openproject-commands
Half-Shot May 14, 2025
ddebc1d
fixup
Half-Shot May 14, 2025
ab9c34f
changelog
Half-Shot May 14, 2025
39ce6b8
Add support for "global" commands.
Half-Shot May 15, 2025
60adfbb
Add url-join
Half-Shot May 15, 2025
a74ac31
Add close command.
Half-Shot May 15, 2025
0b55f09
Add priortiy, assign and responsible commands.
Half-Shot May 15, 2025
1cec19a
lint
Half-Shot May 15, 2025
08fdad4
Tweaks
Half-Shot May 16, 2025
432b98d
Add support for integrated bot commands.
Half-Shot May 16, 2025
80ed3bb
Merge remote-tracking branch 'origin/main' into hs/openproject-comman…
Half-Shot May 16, 2025
fef37b6
Dynamic event changes
Half-Shot May 19, 2025
b052428
Add hookshot Element module.
Half-Shot May 19, 2025
70ede9e
Merge remote-tracking branch 'origin/main' into hs/openproject-comman…
Half-Shot May 20, 2025
1d641e5
fix comparison
Half-Shot May 29, 2025
0e77c74
Merge branch 'main' into hs/openproject-commands-eventcmds
Half-Shot Jun 9, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions changelog.d/1056.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Add support for OpenProject.

5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@
"devDependencies": {
"@babel/core": "^7.26.9",
"@codemirror/lang-javascript": "^6.0.2",
"@element-hq/element-web-module-api": "^1.0.0",
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.15.0",
"@fontsource/inter": "^5.1.0",
Expand All @@ -108,6 +109,7 @@
"@types/node": "^22",
"@types/xml2js": "^0.4.11",
"@uiw/react-codemirror": "^4.12.3",
"@vitejs/plugin-react": "^4.4.1",
"busboy": "^1.6.0",
"chai": "^4",
"eslint": "^9.15.0",
Expand All @@ -119,12 +121,15 @@
"preact": "^10.26.2",
"prettier": "^3.5.3",
"rimraf": "6.0.1",
"rollup-plugin-external-globals": "^0.13.0",
"sass": "^1.81.0",
"styled-components": "^6.1.18",
"testcontainers": "^10.25.0",
"ts-node": "10.9.2",
"typescript": "^5.7.2",
"typescript-eslint": "^8.16.0",
"vite": "^5.4.19",
"vite-plugin-node-polyfills": "^0.23.0",
"vitest": "^3.1.3"
},
"packageManager": "[email protected]+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
Expand Down
87 changes: 82 additions & 5 deletions src/BotCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ export interface BotCommandOptions {
* so that users can execute a command using shorthand.
*/
runOnGlobalPrefix?: boolean;
// For org.matrix.matrix-hookshot.command activation
eventCommandName?: string;
}

type BotCommandResult = { status?: boolean; reaction?: string } | undefined;
Expand Down Expand Up @@ -136,6 +138,7 @@ export function compileBotCommands(
category: b.category,
includeReply: b.includeReply,
runOnGlobalPrefix: b.runOnGlobalPrefix,
eventCommandName: b.eventCommandName,
};
}
});
Expand Down Expand Up @@ -197,7 +200,7 @@ export async function handleCommand(
command: string,
parentEvent: MatrixEvent<unknown> | undefined,
botCommands: BotCommands,
obj: unknown,
parentThis: unknown,
permissionCheckFn: PermissionCheckFn,
defaultPermissionService?: string,
prefix?: string,
Expand Down Expand Up @@ -267,19 +270,19 @@ export async function handleCommand(
if (command.includeUserId && command.includeReply) {
result = await (
botCommands[prefix].fn as BotCommandFunctionWithUserIdAndReply
).apply(obj, [userId, parentEvent, ...args]);
).apply(parentThis, [userId, parentEvent, ...args]);
} else if (command.includeUserId) {
result = await (
botCommands[prefix].fn as BotCommandFunctionWithUserId
).apply(obj, [userId, ...args]);
).apply(parentThis, [userId, ...args]);
} else if (command.includeReply) {
result = await (
botCommands[prefix].fn as BotCommandFunctionWithReply
).apply(obj, [parentEvent, ...args]);
).apply(parentThis, [parentEvent, ...args]);
} else {
result = await (
botCommands[prefix].fn as BotCommandFunctionStandard
).apply(obj, args);
).apply(parentThis, args);
}
return { handled: true, result };
} catch (ex) {
Expand All @@ -296,3 +299,77 @@ export async function handleCommand(
}
return { handled: false };
}

export interface HookshotCommandContent {
command: string;
"m.relates_to": {
rel_type: "org.matrix-hooshot.command-target";
event_id: string;
};
}

export async function handleEventCommand(
userId: string,
eventContent: HookshotCommandContent,
parentEvent: MatrixEvent<unknown>,
botCommands: BotCommands,
obj: unknown,
permissionCheckFn: PermissionCheckFn,
defaultPermissionService?: string,
): Promise<
| CommandResultNotHandled
| CommandResultSuccess
| CommandResultErrorUnknown
| CommandResultErrorHuman
> {
const command = Object.values(botCommands).find(
(c) => c.eventCommandName === eventContent.command,
);
if (!command) {
return {
handled: false,
};
}
const permissionService =
command.permissionService || defaultPermissionService;
if (
permissionService &&
!permissionCheckFn(
permissionService,
command.permissionLevel || BridgePermissionLevel.commands,
)
) {
return {
handled: true,
humanError: "You do not have permission to use this command.",
};
}

if (command.requiredArgs?.length) {
return {
handled: true,
humanError: "Missing at least one required parameter.",
};
}
const args: unknown[] = [];
if (command.includeUserId) {
args.splice(0, 0, userId);
}
if (command.includeReply) {
args.splice(1, 0, parentEvent);
}
try {
const result = ((await command.fn) as any).apply(obj, args);
return { handled: true, result };
} catch (ex) {
const commandError = ex as CommandError;
if (ex instanceof ApiError) {
return { handled: true, humanError: ex.error };
}
return {
handled: true,
error: commandError,
humanError: commandError.humanError,
};
}
}
6 changes: 5 additions & 1 deletion src/Bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1771,7 +1771,11 @@ export class Bridge {
id: connection.connectionId,
});
try {
await connection.onEvent(event);
const checkPermission = (
service: string,
level: BridgePermissionLevel,
) => this.config.checkPermission(event.sender, service, level);
await connection.onEvent(event, checkPermission);
} catch (ex) {
Sentry.captureException(ex, scope);
log.warn(
Expand Down
24 changes: 24 additions & 0 deletions src/Connections/CommandConnection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ import {
botCommand,
BotCommands,
handleCommand,
handleEventCommand,
HelpFunction,
HookshotCommandContent,
} from "../BotCommands";
import { Logger } from "matrix-appservice-bridge";
import { IRichReplyMetadata, MatrixClient, MessageEvent } from "matrix-bot-sdk";
Expand Down Expand Up @@ -51,6 +53,28 @@ export abstract class CommandConnection<
content: unknown,
): Promise<ValidatedStateType> | ValidatedStateType;

public async onEvent(
ev: MatrixEvent<unknown>,
checkPermission: PermissionCheckFn,
) {
if (ev.type !== "org.matrix.matrix-hookshot.command") {
return;
}
const content = ev.content as HookshotCommandContent;
const res = await handleEventCommand(
ev.sender,
content,
await this.botClient.getEvent(
this.roomId,
content["m.relates_to"].event_id,
),
this.botCommands,
this,
checkPermission,
this.serviceName,
);
}

public async onMessageEvent(
ev: MatrixEvent<MatrixMessageContent>,
checkPermission: PermissionCheckFn,
Expand Down
5 changes: 4 additions & 1 deletion src/Connections/IConnection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,10 @@ export interface IConnection {
/**
* When a room gets any event
*/
onEvent?: (ev: MatrixEvent<unknown>) => Promise<void>;
onEvent?: (
ev: MatrixEvent<unknown>,
checkPermission: PermissionCheckFn,
) => Promise<void>;

/**
* When a room gets a message event.
Expand Down
15 changes: 11 additions & 4 deletions src/Connections/OpenProjectConnection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,7 @@ export class OpenProjectConnection
const extraData = formatWorkPackageForMatrix(
data.work_package,
this.config.baseURL,
this.stateKey,
);
const content = `${creator.name} created a new work package [${data.work_package.id}](${extraData["org.matrix.matrix-hookshot.openproject.work_package"].url}): "${data.work_package.subject}"`;
await this.intent.sendEvent(this.roomId, {
Expand All @@ -396,10 +397,6 @@ export class OpenProjectConnection
log.info(
`onWorkPackageUpdated ${this.roomId} ${this.projectId} ${data.work_package.id}`,
);
await this.storage.setOpenProjectWorkPackageState(
workPackageToCacheState(data.work_package),
data.work_package.id,
);

const creator = data.work_package._embedded.author;
if (!creator) {
Expand All @@ -408,11 +405,16 @@ export class OpenProjectConnection
const extraData = formatWorkPackageForMatrix(
data.work_package,
this.config.baseURL,
this.stateKey,
);
const oldChanges = await this.storage.getOpenProjectWorkPackageState(
data.work_package._embedded.project.id,
data.work_package.id,
);
await this.storage.setOpenProjectWorkPackageState(
workPackageToCacheState(data.work_package),
data.work_package.id,
);

// Detect what changed.
let changeStatement = "updated work package";
Expand All @@ -433,9 +435,11 @@ export class OpenProjectConnection
return;
}
}

if (!this.isInterestedInHookEvent(hookEvent ?? "work_package:updated")) {
return;
}

const content = `**${creator.name}** ${changeStatement} for [${data.work_package.id}](${extraData["org.matrix.matrix-hookshot.openproject.work_package"].url}): "${data.work_package.subject}"`;

await this.intent.sendEvent(this.roomId, {
Expand Down Expand Up @@ -545,6 +549,7 @@ export class OpenProjectConnection
const extraData = formatWorkPackageForMatrix(
workPackage,
this.config.baseURL,
this.stateKey,
);
const content = `${workPackage._embedded.author.name} created a new work package [${workPackage.id}](${extraData["org.matrix.matrix-hookshot.openproject.work_package"].url}): "${workPackage.subject}"`;
await this.intent.sendEvent(this.roomId, {
Expand All @@ -563,6 +568,7 @@ export class OpenProjectConnection
includeReply: true,
// We allow uses to call global for shorthand replies.
runOnGlobalPrefix: true,
eventCommandName: "org.matrix.matrix-hookshot.openproject.command.close",
})
public async commandCloseWorkPackage(
userId: string,
Expand Down Expand Up @@ -628,6 +634,7 @@ export class OpenProjectConnection
const extraData = formatWorkPackageForMatrix(
workPackage,
this.config.baseURL,
this.stateKey,
);
const content = `${workPackage._embedded.author.name} closed work package [${workPackage.id}](${extraData["org.matrix.matrix-hookshot.openproject.work_package"].url}): "${workPackage.subject}"`;
await this.intent.sendEvent(this.roomId, {
Expand Down
17 changes: 17 additions & 0 deletions src/openproject/Format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,21 @@ export interface OpenProjectWorkPackageMatrixEvent {
name: string;
url: string;
};
"org.matrix.matrix-hookshot.commands": {
"org.matrix.matrix-hookshot.openproject.command.close": {
label: string;
};
"org.matrix.matrix-hookshot.openproject.command.flag": {
label: string;
};
};
external_url: string;
}

export function formatWorkPackageForMatrix(
pkg: OpenProjectWorkPackage,
baseURL: URL,
_stateKey: string,
): OpenProjectWorkPackageMatrixEvent {
const url = new URL(
baseURL.href +
Expand Down Expand Up @@ -88,6 +97,14 @@ export function formatWorkPackageForMatrix(
baseURL,
).toString(),
},
"org.matrix.matrix-hookshot.commands": {
"org.matrix.matrix-hookshot.openproject.command.close": {
label: "Close work package",
},
"org.matrix.matrix-hookshot.openproject.command.flag": {
label: "Flag work package",
},
},
external_url: url,
};
}
Expand Down
47 changes: 47 additions & 0 deletions vite.elementmodule.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/*
Copyright 2025 New Vector Ltd.

SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/

import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { nodePolyfills } from "vite-plugin-node-polyfills";
import externalGlobals from "rollup-plugin-external-globals";

const __dirname = dirname(fileURLToPath(import.meta.url));

export default defineConfig({
build: {
lib: {
entry: resolve("web", "elementModule", "index.tsx"),
name: "hookshot-openproject",
fileName: "index",
formats: ["es"],
},
outDir: "public/elementModule",
target: "esnext",
sourcemap: true,
rollupOptions: {
external: ["preact", "react"],
},
},
plugins: [
react(),
nodePolyfills({
include: ["events"],
}),
externalGlobals({
// Reuse React from the host app
react: "window.React",
}),
],
define: {
// Use production mode for the build as it is tested against production builds of Element Web,
// this is required for React JSX versions to be compatible.
process: { env: { NODE_ENV: "production" } },
},
});
Loading
Loading