diff --git a/changelog.d/223.feature b/changelog.d/223.feature
new file mode 100644
index 00000000..88868c26
--- /dev/null
+++ b/changelog.d/223.feature
@@ -0,0 +1,2 @@
+Add support for `v2` webhook transformation functions, supporting more options.
+See https://matrix-org.github.io/matrix-hookshot/setup/webhooks.html#javascript-transformations for more information
\ No newline at end of file
diff --git a/docs/setup/webhooks.md b/docs/setup/webhooks.md
index 58a3fe5f..46ffa1ba 100644
--- a/docs/setup/webhooks.md
+++ b/docs/setup/webhooks.md
@@ -64,14 +64,50 @@ The script string should be set within the state event under the `transformation
### Script API
-The scripts have a very minimal API. The execution environment will contain a `data` field, which will be the body
-of the incoming request (JSON will be parsed into an `Object`). Scripts are executed syncronously and a variable `result`
-is expected to be set in the execution, which will be used as the text value for the script. `result` will be automatically
-transformed by a Markdown parser.
+Transformation scripts have a versioned API. You can check the version of the API that the hookshot instance supports
+at runtime by checking the `HookshotApiVersion` variable. If the variable is undefined, it should be considered `v1`.
-If the script contains errors or is otherwise unable to work, the bridge will send an error to the room.
+The execution environment will contain a `data` variable, which will be the body of the incoming request (JSON will be parsed into an `Object`).
+Scripts are executed syncronously and expect the `result` variable to be set.
-### Example script
+If the script contains errors or is otherwise unable to work, the bridge will send an error to the room. You can check the logs of the bridge
+for a more precise error.
+
+### V2 API
+
+The `v2` api expects an object to be returned from the `result` variable.
+
+```json5
+{
+ "version": "v2" // The version of the schema being returned from the function. This is always "v2".
+ "empty": true|false, // Should the webhook be ignored and no output returned. The default is false (plain must be provided).
+ "plain": "Some text", // The plaintext value to be used for the Matrix message.
+ "html": "Some text", // The HTML value to be used for the Matrix message. If not provided, plain will be interpreted as markdown.
+}
+```
+
+#### Example script
+
+Where `data` = `{"counter": 5, "maxValue": 4}`
+
+```js
+if (data.counter === undefined) {
+ // The API didn't give us a counter, send no message.
+ result = {empty: true, version: "v2"};
+} else if (data.counter > data.maxValue) {
+ result = {plain: `**Oh no!** The counter has gone over by ${data.counter - data.maxValue}`, version: "v2"};
+} else {
+ result = {plain: `*Everything is fine*, the counter is under by ${data.maxValue - data.counter}`, version: "v2"};
+}
+```
+
+
+### V1 API
+
+The v1 API expects `result` to be a string. The string will be automatically transformed into markdown. All webhook messages
+will be prefix'd with `Received webhook:`. If `result` is falsey (undefined, false or null) then the message will be `No content`.
+
+#### Example script
Where `data` = `{"counter": 5, "maxValue": 4}`
diff --git a/src/Connections/GenericHook.ts b/src/Connections/GenericHook.ts
index 0318dbf7..b21975fd 100644
--- a/src/Connections/GenericHook.ts
+++ b/src/Connections/GenericHook.ts
@@ -29,6 +29,13 @@ export interface GenericHookAccountData {
[hookId: string]: string;
}
+interface WebhookTransformationResult {
+ version: string;
+ plain?: string;
+ html?: string;
+ empty?: boolean;
+}
+
const log = new LogWrapper("GenericHookConnection");
const md = new markdownit();
@@ -195,6 +202,51 @@ export class GenericHookConnection extends BaseConnection implements IConnection
return msg;
}
+ public executeTransformationFunction(data: Record): {plain: string, html?: string}|null {
+ if (!this.transformationFunction) {
+ throw Error('Transformation function not defined');
+ }
+ const vm = new NodeVM({
+ console: 'off',
+ wrapper: 'none',
+ wasm: false,
+ eval: false,
+ timeout: TRANSFORMATION_TIMEOUT_MS,
+ });
+ vm.setGlobal('HookshotApiVersion', 'v2');
+ vm.setGlobal('data', data);
+ vm.run(this.transformationFunction);
+ const result = vm.getGlobal('result');
+
+ // Legacy v1 api
+ if (typeof result === "string") {
+ return {plain: `Received webhook: ${result}`};
+ } else if (typeof result !== "object") {
+ return {plain: `No content`};
+ }
+ const transformationResult = result as WebhookTransformationResult;
+ if (transformationResult.version !== "v2") {
+ throw Error("Result returned from transformation didn't specify version = v2");
+ }
+
+ if (transformationResult.empty) {
+ return null; // No-op
+ }
+
+ const plain = transformationResult.plain;
+ if (typeof plain !== "string") {
+ throw Error("Result returned from transformation didn't provide a string value for plain");
+ }
+ if (transformationResult.html && typeof transformationResult.html !== "string") {
+ throw Error("Result returned from transformation didn't provide a string value for html");
+ }
+
+ return {
+ plain: plain,
+ html: transformationResult.html,
+ }
+ }
+
public async onGenericHook(data: Record) {
log.info(`onGenericHook ${this.roomId} ${this.hookId}`);
let content: {plain: string, html?: string};
@@ -202,21 +254,12 @@ export class GenericHookConnection extends BaseConnection implements IConnection
content = this.transformHookData(data);
} else {
try {
- const vm = new NodeVM({
- console: 'off',
- wrapper: 'none',
- wasm: false,
- eval: false,
- timeout: TRANSFORMATION_TIMEOUT_MS,
- });
- vm.setGlobal('data', data);
- vm.run(this.transformationFunction);
- content = vm.getGlobal('result');
- if (typeof content === "string") {
- content = {plain: `Received webhook: ${content}`};
- } else {
- content = {plain: `No content`};
+ const potentialContent = this.executeTransformationFunction(data);
+ if (potentialContent === null) {
+ // Explitly no action
+ return;
}
+ content = potentialContent;
} catch (ex) {
log.warn(`Failed to run transformation function`, ex);
content = {plain: `Webhook received but failed to process via transformation function`};
diff --git a/tests/connections/GenericHookTest.ts b/tests/connections/GenericHookTest.ts
index 8ca66a44..bd1df81d 100644
--- a/tests/connections/GenericHookTest.ts
+++ b/tests/connections/GenericHookTest.ts
@@ -8,7 +8,8 @@ import { AppserviceMock } from "../utils/AppserviceMock";
const ROOM_ID = "!foo:bar";
-const TFFunction = "result = `The answer to '${data.question}' is ${data.answer}`;";
+const V1TFFunction = "result = `The answer to '${data.question}' is ${data.answer}`;";
+const V2TFFunction = "result = {plain: `The answer to '${data.question}' is ${data.answer}`, version: 'v2'}";
function createGenericHook(state: GenericHookConnectionState = {
name: "some-name"
@@ -109,9 +110,9 @@ describe("GenericHookConnection", () => {
type: 'm.room.message',
});
});
- it("will handle a hook event with a transformation function", async () => {
+ it("will handle a hook event with a v1 transformation function", async () => {
const webhookData = {question: 'What is the meaning of life?', answer: 42};
- const [connection, mq] = createGenericHook({name: 'test', transformationFunction: TFFunction}, {
+ const [connection, mq] = createGenericHook({name: 'test', transformationFunction: V1TFFunction}, {
enabled: true,
urlPrefix: "https://example.com/webhookurl",
allowJsTransformationFunctions: true,
@@ -132,6 +133,29 @@ describe("GenericHookConnection", () => {
type: 'm.room.message',
});
});
+ it("will handle a hook event with a v2 transformation function", async () => {
+ const webhookData = {question: 'What is the meaning of life?', answer: 42};
+ const [connection, mq] = createGenericHook({name: 'test', transformationFunction: V2TFFunction}, {
+ enabled: true,
+ urlPrefix: "https://example.com/webhookurl",
+ allowJsTransformationFunctions: true,
+ }
+ );
+ const messagePromise = handleMessage(mq);
+ await connection.onGenericHook(webhookData);
+ expect(await messagePromise).to.deep.equal({
+ roomId: ROOM_ID,
+ sender: connection.getUserId(),
+ content: {
+ body: "The answer to 'What is the meaning of life?' is 42",
+ format: "org.matrix.custom.html",
+ formatted_body: "The answer to 'What is the meaning of life?' is 42",
+ msgtype: "m.notice",
+ "uk.half-shot.hookshot.webhook_data": webhookData,
+ },
+ type: 'm.room.message',
+ });
+ });
it("will fail to handle a webhook with an invalid script", async () => {
const webhookData = {question: 'What is the meaning of life?', answer: 42};
const [connection, mq] = createGenericHook({name: 'test', transformationFunction: "bibble bobble"}, {