Store generic hook requests

This commit is contained in:
Half-Shot 2022-09-14 10:15:00 +01:00
parent cbc7718808
commit f7bc38d941
4 changed files with 108 additions and 23 deletions

View File

@ -564,12 +564,12 @@ export class Bridge {
try { try {
// TODO: Support webhook responses to more than one room // TODO: Support webhook responses to more than one room
if (index !== 0) { if (index !== 0) {
await c.onGenericHook(data.hookData); await c.onGenericHook(data);
return; return;
} }
let successful: boolean|null = null; let successful: boolean|null = null;
if (this.config.generic?.waitForComplete) { if (this.config.generic?.waitForComplete) {
successful = await c.onGenericHook(data.hookData); successful = await c.onGenericHook(data);
} }
await this.queue.push<GenericWebhookEventResult>({ await this.queue.push<GenericWebhookEventResult>({
data: {successful}, data: {successful},
@ -579,7 +579,7 @@ export class Bridge {
}); });
didPush = true; didPush = true;
if (!this.config.generic?.waitForComplete) { if (!this.config.generic?.waitForComplete) {
await c.onGenericHook(data.hookData); await c.onGenericHook(data);
} }
} }
catch (ex) { catch (ex) {

View File

@ -10,6 +10,7 @@ import { ApiError, ErrCode } from "../api";
import { BaseConnection } from "./BaseConnection"; import { BaseConnection } from "./BaseConnection";
import { GetConnectionsResponseItem } from "../provisioning/api"; import { GetConnectionsResponseItem } from "../provisioning/api";
import { BridgeConfigGenericWebhooks } from "../Config/Config"; import { BridgeConfigGenericWebhooks } from "../Config/Config";
import { GenericWebhookEvent } from "../generic/types";
export interface GenericHookConnectionState extends IConnectionState { export interface GenericHookConnectionState extends IConnectionState {
/** /**
@ -32,6 +33,10 @@ export interface GenericHookSecrets {
* The hookId of the webhook. * The hookId of the webhook.
*/ */
hookId: string; hookId: string;
/**
* The results of webhook actions.
*/
lastResults: Array<LastResult>;
} }
export type GenericHookResponseItem = GetConnectionsResponseItem<GenericHookConnectionState, GenericHookSecrets>; export type GenericHookResponseItem = GetConnectionsResponseItem<GenericHookConnectionState, GenericHookSecrets>;
@ -52,12 +57,33 @@ interface WebhookTransformationResult {
empty?: boolean; empty?: boolean;
} }
interface TransformationResult {
logs: string;
content: {
plain: string;
msgtype?: string;
html?: string;
}|null,
}
export interface LastResult {
timestamp: number;
metadata: {
userAgent?: string;
contentType?: string;
};
logs?: string;
ok: boolean;
error?: string;
}
const log = new LogWrapper("GenericHookConnection"); const log = new LogWrapper("GenericHookConnection");
const md = new markdownit(); const md = new markdownit();
const TRANSFORMATION_TIMEOUT_MS = 500; const TRANSFORMATION_TIMEOUT_MS = 500;
const SANITIZE_MAX_DEPTH = 5; const SANITIZE_MAX_DEPTH = 5;
const SANITIZE_MAX_BREADTH = 25; const SANITIZE_MAX_BREADTH = 25;
const MAX_LAST_RESULT_ITEMS = 5;
/** /**
* Handles rooms connected to a generic webhook. * Handles rooms connected to a generic webhook.
@ -192,6 +218,7 @@ export class GenericHookConnection extends BaseConnection implements IConnection
private transformationFunction?: Script; private transformationFunction?: Script;
private cachedDisplayname?: string; private cachedDisplayname?: string;
private readonly lastResults = new Array<LastResult>();
constructor(roomId: string, constructor(roomId: string,
private state: GenericHookConnectionState, private state: GenericHookConnectionState,
public readonly hookId: string, public readonly hookId: string,
@ -293,27 +320,50 @@ export class GenericHookConnection extends BaseConnection implements IConnection
return msg; return msg;
} }
public executeTransformationFunction(data: unknown): {plain: string, html?: string, msgtype?: string}|null { public executeTransformationFunction(data: unknown): TransformationResult {
if (!this.transformationFunction) { if (!this.transformationFunction) {
throw Error('Transformation function not defined'); throw Error('Transformation function not defined');
} }
let logs = "";
const vm = new NodeVM({ const vm = new NodeVM({
console: 'off', console: 'redirect',
wrapper: 'none', wrapper: 'none',
wasm: false, wasm: false,
eval: false, eval: false,
timeout: TRANSFORMATION_TIMEOUT_MS, timeout: TRANSFORMATION_TIMEOUT_MS,
}); })
vm.setGlobal('HookshotApiVersion', 'v2'); .setGlobal('HookshotApiVersion', 'v2')
vm.setGlobal('data', data); .setGlobal('data', data)
.on('console.log', (msg, ...args) => {
logs += `\n${msg} ${args.join(' ')}`;
})
.on('console.info', (msg, ...args) => {
logs += `\ninfo: ${msg} ${args.join(' ')}`;
})
.on('console.warn', (msg, ...args) => {
logs += `\nwarn: ${msg} ${args.join(' ')}`;
})
.on('console.error', (msg, ...args) => {
logs += `\nerror: ${msg} ${args.join(' ')}`;
})
vm.run(this.transformationFunction); vm.run(this.transformationFunction);
const result = vm.getGlobal('result'); const result = vm.getGlobal('result');
// Legacy v1 api // Legacy v1 api
if (typeof result === "string") { if (typeof result === "string") {
return {plain: `Received webhook: ${result}`}; return {
content: {
plain: `Received webhook: ${result}`
},
logs
};
} else if (typeof result !== "object") { } else if (typeof result !== "object") {
return {plain: `No content`}; return {
content: {
plain: "No content"
},
logs
};
} }
const transformationResult = result as WebhookTransformationResult; const transformationResult = result as WebhookTransformationResult;
if (transformationResult.version !== "v2") { if (transformationResult.version !== "v2") {
@ -321,7 +371,7 @@ export class GenericHookConnection extends BaseConnection implements IConnection
} }
if (transformationResult.empty) { if (transformationResult.empty) {
return null; // No-op return { logs, content: null }; // No-op
} }
const plain = transformationResult.plain; const plain = transformationResult.plain;
@ -336,10 +386,18 @@ export class GenericHookConnection extends BaseConnection implements IConnection
} }
return { return {
plain: plain, content: {
html: transformationResult.html, plain,
msgtype: transformationResult.msgtype, html: transformationResult.html,
} msgtype: transformationResult.msgtype,
},
logs
};
}
public addLastResult(result: LastResult) {
this.lastResults.unshift(result);
this.lastResults.splice(MAX_LAST_RESULT_ITEMS-1, 1);
} }
/** /**
@ -347,22 +405,38 @@ export class GenericHookConnection extends BaseConnection implements IConnection
* @param data Structured data. This may either be a string, or an object. * @param data Structured data. This may either be a string, or an object.
* @returns `true` if the webhook completed, or `false` if it failed to complete * @returns `true` if the webhook completed, or `false` if it failed to complete
*/ */
public async onGenericHook(data: unknown): Promise<boolean> { public async onGenericHook(event: GenericWebhookEvent): Promise<boolean> {
const data = event.hookData;
log.info(`onGenericHook ${this.roomId} ${this.hookId}`); log.info(`onGenericHook ${this.roomId} ${this.hookId}`);
let content: {plain: string, html?: string, msgtype?: string}; let content: {plain: string, html?: string, msgtype?: string};
let success = true; let success = true;
let functionResult: TransformationResult|undefined;
let error: string|undefined;
// Matrix cannot handle float data, so make sure we parse out any floats.
const safeData = GenericHookConnection.sanitiseObjectForMatrixJSON(data);
if (!this.transformationFunction) { if (!this.transformationFunction) {
content = this.transformHookData(data); content = this.transformHookData(data);
} else { } else {
try { try {
const potentialContent = this.executeTransformationFunction(data); functionResult = this.executeTransformationFunction(data);
if (potentialContent === null) { if (functionResult.content === null) {
// Explitly no action // Explitly no action
this.addLastResult({
ok: success,
timestamp: Date.now(),
logs: functionResult.logs,
metadata: {
userAgent: event.userAgent,
contentType: event.contentType,
}
});
return true; return true;
} }
content = potentialContent; content = functionResult.content;
} catch (ex) { } catch (ex) {
log.warn(`Failed to run transformation function`, ex); log.warn(`Failed to run transformation function`, ex);
error = ex.message;
content = {plain: `Webhook received but failed to process via transformation function`}; content = {plain: `Webhook received but failed to process via transformation function`};
success = false; success = false;
} }
@ -370,9 +444,6 @@ export class GenericHookConnection extends BaseConnection implements IConnection
const sender = this.getUserId(); const sender = this.getUserId();
await this.ensureDisplayname(); await this.ensureDisplayname();
// Matrix cannot handle float data, so make sure we parse out any floats.
const safeData = GenericHookConnection.sanitiseObjectForMatrixJSON(data);
await this.messageClient.sendMatrixMessage(this.roomId, { await this.messageClient.sendMatrixMessage(this.roomId, {
msgtype: content.msgtype || "m.notice", msgtype: content.msgtype || "m.notice",
@ -382,8 +453,17 @@ export class GenericHookConnection extends BaseConnection implements IConnection
format: "org.matrix.custom.html", format: "org.matrix.custom.html",
"uk.half-shot.hookshot.webhook_data": safeData, "uk.half-shot.hookshot.webhook_data": safeData,
}, 'm.room.message', sender); }, 'm.room.message', sender);
this.addLastResult({
ok: success,
timestamp: Date.now(),
logs: functionResult?.logs,
error,
metadata: {
userAgent: event.userAgent,
contentType: event.contentType,
}
});
return success; return success;
} }
public static getProvisionerDetails(botUserId: string) { public static getProvisionerDetails(botUserId: string) {
@ -407,6 +487,7 @@ export class GenericHookConnection extends BaseConnection implements IConnection
...(showSecrets ? { secrets: { ...(showSecrets ? { secrets: {
url: new URL(this.hookId, this.config.parsedUrlPrefix), url: new URL(this.hookId, this.config.parsedUrlPrefix),
hookId: this.hookId, hookId: this.hookId,
lastResults: this.lastResults,
} as GenericHookSecrets} : undefined) } as GenericHookSecrets} : undefined)
} }
} }

View File

@ -33,6 +33,8 @@ export class GenericWebhooksRouter {
data: { data: {
hookData: body, hookData: body,
hookId: req.params.hookId, hookId: req.params.hookId,
userAgent: req.headers["user-agent"],
contentType: req.headers["content-type"],
}, },
}, WEBHOOK_RESPONSE_TIMEOUT).then((response) => { }, WEBHOOK_RESPONSE_TIMEOUT).then((response) => {
if (response.notFound) { if (response.notFound) {

View File

@ -1,6 +1,8 @@
export interface GenericWebhookEvent { export interface GenericWebhookEvent {
hookData: unknown; hookData: unknown;
hookId: string; hookId: string;
userAgent?: string;
contentType?: string;
} }
export interface GenericWebhookEventResult { export interface GenericWebhookEventResult {