mirror of
https://github.com/matrix-org/matrix-hookshot.git
synced 2025-03-10 21:19:13 +00:00
Add support for a "v2" webhook transformation API (#223)
* Add support for a "v2" webhook transformation API * changelog
This commit is contained in:
parent
a14cf4de7a
commit
01d3d96f12
2
changelog.d/223.feature
Normal file
2
changelog.d/223.feature
Normal file
@ -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
|
@ -64,14 +64,50 @@ The script string should be set within the state event under the `transformation
|
|||||||
|
|
||||||
### Script API
|
### Script API
|
||||||
|
|
||||||
The scripts have a very minimal API. The execution environment will contain a `data` field, which will be the body
|
Transformation scripts have a versioned API. You can check the version of the API that the hookshot instance supports
|
||||||
of the incoming request (JSON will be parsed into an `Object`). Scripts are executed syncronously and a variable `result`
|
at runtime by checking the `HookshotApiVersion` variable. If the variable is undefined, it should be considered `v1`.
|
||||||
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.
|
|
||||||
|
|
||||||
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": "<b>Some</b> 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}`
|
Where `data` = `{"counter": 5, "maxValue": 4}`
|
||||||
|
|
||||||
|
@ -29,6 +29,13 @@ export interface GenericHookAccountData {
|
|||||||
[hookId: string]: string;
|
[hookId: string]: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface WebhookTransformationResult {
|
||||||
|
version: string;
|
||||||
|
plain?: string;
|
||||||
|
html?: string;
|
||||||
|
empty?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
const log = new LogWrapper("GenericHookConnection");
|
const log = new LogWrapper("GenericHookConnection");
|
||||||
const md = new markdownit();
|
const md = new markdownit();
|
||||||
|
|
||||||
@ -195,13 +202,10 @@ export class GenericHookConnection extends BaseConnection implements IConnection
|
|||||||
return msg;
|
return msg;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async onGenericHook(data: Record<string, unknown>) {
|
public executeTransformationFunction(data: Record<string, unknown>): {plain: string, html?: string}|null {
|
||||||
log.info(`onGenericHook ${this.roomId} ${this.hookId}`);
|
|
||||||
let content: {plain: string, html?: string};
|
|
||||||
if (!this.transformationFunction) {
|
if (!this.transformationFunction) {
|
||||||
content = this.transformHookData(data);
|
throw Error('Transformation function not defined');
|
||||||
} else {
|
}
|
||||||
try {
|
|
||||||
const vm = new NodeVM({
|
const vm = new NodeVM({
|
||||||
console: 'off',
|
console: 'off',
|
||||||
wrapper: 'none',
|
wrapper: 'none',
|
||||||
@ -209,14 +213,53 @@ export class GenericHookConnection extends BaseConnection implements IConnection
|
|||||||
eval: false,
|
eval: false,
|
||||||
timeout: TRANSFORMATION_TIMEOUT_MS,
|
timeout: TRANSFORMATION_TIMEOUT_MS,
|
||||||
});
|
});
|
||||||
|
vm.setGlobal('HookshotApiVersion', 'v2');
|
||||||
vm.setGlobal('data', data);
|
vm.setGlobal('data', data);
|
||||||
vm.run(this.transformationFunction);
|
vm.run(this.transformationFunction);
|
||||||
content = vm.getGlobal('result');
|
const result = vm.getGlobal('result');
|
||||||
if (typeof content === "string") {
|
|
||||||
content = {plain: `Received webhook: ${content}`};
|
// Legacy v1 api
|
||||||
} else {
|
if (typeof result === "string") {
|
||||||
content = {plain: `No content`};
|
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<string, unknown>) {
|
||||||
|
log.info(`onGenericHook ${this.roomId} ${this.hookId}`);
|
||||||
|
let content: {plain: string, html?: string};
|
||||||
|
if (!this.transformationFunction) {
|
||||||
|
content = this.transformHookData(data);
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const potentialContent = this.executeTransformationFunction(data);
|
||||||
|
if (potentialContent === null) {
|
||||||
|
// Explitly no action
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
content = potentialContent;
|
||||||
} catch (ex) {
|
} catch (ex) {
|
||||||
log.warn(`Failed to run transformation function`, ex);
|
log.warn(`Failed to run transformation function`, ex);
|
||||||
content = {plain: `Webhook received but failed to process via transformation function`};
|
content = {plain: `Webhook received but failed to process via transformation function`};
|
||||||
|
@ -8,7 +8,8 @@ import { AppserviceMock } from "../utils/AppserviceMock";
|
|||||||
|
|
||||||
const ROOM_ID = "!foo:bar";
|
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 = {
|
function createGenericHook(state: GenericHookConnectionState = {
|
||||||
name: "some-name"
|
name: "some-name"
|
||||||
@ -109,9 +110,9 @@ describe("GenericHookConnection", () => {
|
|||||||
type: 'm.room.message',
|
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 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,
|
enabled: true,
|
||||||
urlPrefix: "https://example.com/webhookurl",
|
urlPrefix: "https://example.com/webhookurl",
|
||||||
allowJsTransformationFunctions: true,
|
allowJsTransformationFunctions: true,
|
||||||
@ -132,6 +133,29 @@ describe("GenericHookConnection", () => {
|
|||||||
type: 'm.room.message',
|
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 () => {
|
it("will fail to handle a webhook with an invalid script", async () => {
|
||||||
const webhookData = {question: 'What is the meaning of life?', answer: 42};
|
const webhookData = {question: 'What is the meaning of life?', answer: 42};
|
||||||
const [connection, mq] = createGenericHook({name: 'test', transformationFunction: "bibble bobble"}, {
|
const [connection, mq] = createGenericHook({name: 'test', transformationFunction: "bibble bobble"}, {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user