Skip to content

Commit 655e353

Browse files
authored
Add intentional mentions to generic webhooks (#1051)
* Add support for explicit mentions * Add test * changelog * tyoes
1 parent 39d5281 commit 655e353

File tree

7 files changed

+216
-121
lines changed

7 files changed

+216
-121
lines changed

changelog.d/1051.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add new `mentions` field to generic hook transformation functions, to intentionally mention users.

docs/setup/webhooks.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,10 @@ The `v2` api expects an object to be returned from the `result` variable.
174174
"plain": "Some text", // The plaintext value to be used for the Matrix message.
175175
"html": "<b>Some</b> text", // The HTML value to be used for the Matrix message. If not provided, plain will be interpreted as markdown.
176176
"msgtype": "some.type", // The message type, such as m.notice or m.text, to be used for the Matrix message. If not provided, m.notice will be used.
177+
"mentions": { // Explicitly mention these users, see https://spec.matrix.org/latest/client-server-api/#user-and-room-mentions
178+
"room": true,
179+
"user_ids": ["@foo:bar"]
180+
},
177181
"webhookResponse": { // Optional response to send to the webhook requestor. All fields are optional. Defaults listed.
178182
"body": "{ \"ok\": true }",
179183
"contentType": "application/json",

src/App/BridgeApp.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import BotUsersManager from "../Managers/BotUsersManager";
1010
import * as Sentry from '@sentry/node';
1111
import { GenericHookConnection } from "../Connections";
1212
import { UserTokenStore } from "../tokens/UserTokenStore";
13+
import { WebhookTransformer } from "../generic/transformer";
1314

1415
Logger.configure({console: "info"});
1516
const log = new Logger("App");
@@ -46,7 +47,7 @@ export async function start(config: BridgeConfig, registration: IAppserviceRegis
4647
}
4748

4849
if (config.generic?.allowJsTransformationFunctions) {
49-
await GenericHookConnection.initialiseQuickJS();
50+
await WebhookTransformer.initialiseQuickJS();
5051
}
5152

5253
const botUsersManager = new BotUsersManager(config, appservice);

src/Connections/GenericHook.ts

Lines changed: 18 additions & 117 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { GenericWebhookEventResult } from "../generic/types";
1515
import { StatusCodes } from "http-status-codes";
1616
import { IBridgeStorageProvider } from "../Stores/StorageProvider";
1717
import { formatDuration, isMatch, millisecondsToHours } from "date-fns";
18+
import { ExecuteResultContent, ExecuteResultWebhookResponse, WebhookTransformer } from "../generic/transformer";
1819

1920
export interface GenericHookConnectionState extends IConnectionState {
2021
/**
@@ -63,21 +64,6 @@ export interface GenericHookAccountData {
6364
[hookId: string]: string;
6465
}
6566

66-
export interface WebhookResponse {
67-
body: string;
68-
contentType?: string;
69-
statusCode?: number;
70-
}
71-
72-
interface WebhookTransformationResult {
73-
version: string;
74-
plain?: string;
75-
html?: string;
76-
msgtype?: string;
77-
empty?: boolean;
78-
webhookResponse?: WebhookResponse;
79-
}
80-
8167
export interface GenericHookServiceConfig {
8268
userIdPrefix?: string;
8369
allowJsTransformationFunctions?: boolean,
@@ -89,7 +75,6 @@ export interface GenericHookServiceConfig {
8975
const log = new Logger("GenericHookConnection");
9076
const md = new markdownit();
9177

92-
const TRANSFORMATION_TIMEOUT_MS = 500;
9378
const SANITIZE_MAX_DEPTH = 10;
9479
const SANITIZE_MAX_BREADTH = 50;
9580

@@ -104,12 +89,6 @@ const EXPIRY_NOTICE_MESSAGE = "The webhook **%NAME** will be expiring in %TIME."
10489
*/
10590
@Connection
10691
export class GenericHookConnection extends BaseConnection implements IConnection {
107-
private static quickModule?: QuickJSWASMModule;
108-
109-
public static async initialiseQuickJS() {
110-
GenericHookConnection.quickModule = await newQuickJSWASMModule();
111-
}
112-
11392
/**
11493
* Ensures a JSON payload is compatible with Matrix JSON requirements, such
11594
* as disallowing floating point values.
@@ -164,7 +143,7 @@ export class GenericHookConnection extends BaseConnection implements IConnection
164143
}
165144
// Use !=, not !==, to check for both undefined and null
166145
if (transformationFunction != undefined) {
167-
if (!this.quickModule) {
146+
if (!WebhookTransformer.canTransform) {
168147
throw new ApiError('Transformation functions are not allowed', ErrCode.DisabledFeature);
169148
}
170149
if (typeof transformationFunction !== "string") {
@@ -284,7 +263,7 @@ export class GenericHookConnection extends BaseConnection implements IConnection
284263
GenericHookConnection.LegacyCanonicalEventType,
285264
];
286265

287-
private transformationFunction?: string;
266+
private webhookTransformer?: WebhookTransformer;
288267
private cachedDisplayname?: string;
289268
private warnOnExpiryInterval?: NodeJS.Timeout;
290269

@@ -303,8 +282,8 @@ export class GenericHookConnection extends BaseConnection implements IConnection
303282
private readonly storage: IBridgeStorageProvider,
304283
) {
305284
super(roomId, stateKey, GenericHookConnection.CanonicalEventType);
306-
if (state.transformationFunction && GenericHookConnection.quickModule) {
307-
this.transformationFunction = state.transformationFunction;
285+
if (state.transformationFunction && WebhookTransformer.canTransform) {
286+
this.webhookTransformer = new WebhookTransformer(state.transformationFunction);
308287
}
309288
this.handleExpiryTimeUpdate(false).catch(ex => {
310289
log.warn("Failed to configure expiry time warning for hook", ex);
@@ -372,27 +351,20 @@ export class GenericHookConnection extends BaseConnection implements IConnection
372351
public async onStateUpdate(stateEv: MatrixEvent<unknown>) {
373352
const validatedConfig = GenericHookConnection.validateState(stateEv.content as Record<string, unknown>);
374353
if (validatedConfig.transformationFunction) {
375-
const ctx = GenericHookConnection.quickModule!.newContext();
376-
const codeEvalResult = ctx.evalCode(`function f(data) {${validatedConfig.transformationFunction}}`, undefined, { compileOnly: true });
377-
if (codeEvalResult.error) {
378-
const errorString = JSON.stringify(ctx.dump(codeEvalResult.error), null, 2);
379-
codeEvalResult.error.dispose();
380-
ctx.dispose();
381-
354+
const error = WebhookTransformer.validateScript(validatedConfig.transformationFunction);
355+
if (error) {
382356
const errorPrefix = "Could not compile transformation function:";
383357
await this.intent.sendEvent(this.roomId, {
384358
msgtype: "m.text",
385-
body: errorPrefix + "\n\n```json\n\n" + errorString + "\n\n```",
386-
formatted_body: `<p>${errorPrefix}</p><p><pre><code class=\\"language-json\\">${errorString}</code></pre></p>`,
359+
body: errorPrefix + "\n\n```json\n\n" + error + "\n\n```",
360+
formatted_body: `<p>${errorPrefix}</p><p><pre><code class=\\"language-json\\">${error}</code></pre></p>`,
387361
format: "org.matrix.custom.html",
388362
});
389363
} else {
390-
codeEvalResult.value.dispose();
391-
ctx.dispose();
392-
this.transformationFunction = validatedConfig.transformationFunction;
364+
this.webhookTransformer = new WebhookTransformer(validatedConfig.transformationFunction); ;
393365
}
394366
} else {
395-
this.transformationFunction = undefined;
367+
this.webhookTransformer = undefined;
396368
}
397369

398370
const prevDate = this.state.expirationDate;
@@ -469,78 +441,6 @@ export class GenericHookConnection extends BaseConnection implements IConnection
469441
return msg;
470442
}
471443

472-
public executeTransformationFunction(data: unknown): {content?: {plain: string, html?: string, msgtype?: string}, webhookResponse?: WebhookResponse} {
473-
if (!this.transformationFunction) {
474-
throw Error('Transformation function not defined');
475-
}
476-
let result;
477-
const ctx = GenericHookConnection.quickModule!.newContext();
478-
ctx.runtime.setInterruptHandler(shouldInterruptAfterDeadline(Date.now() + TRANSFORMATION_TIMEOUT_MS));
479-
try {
480-
ctx.setProp(ctx.global, 'HookshotApiVersion', ctx.newString('v2'));
481-
const ctxResult = ctx.evalCode(`const data = ${JSON.stringify(data)};\n(() => { ${this.state.transformationFunction} })();`);
482-
483-
if (ctxResult.error) {
484-
const e = Error(`Transformation failed to run: ${JSON.stringify(ctx.dump(ctxResult.error))}`);
485-
ctxResult.error.dispose();
486-
throw e;
487-
} else {
488-
const value = ctx.getProp(ctx.global, 'result');
489-
result = ctx.dump(value);
490-
value.dispose();
491-
ctxResult.value.dispose();
492-
}
493-
} finally {
494-
ctx.global.dispose();
495-
ctx.dispose();
496-
}
497-
498-
// Legacy v1 api
499-
if (typeof result === "string") {
500-
return {content: {plain: `Received webhook: ${result}`}};
501-
} else if (typeof result !== "object") {
502-
return {content: {plain: `No content`}};
503-
}
504-
const transformationResult = result as WebhookTransformationResult;
505-
if (transformationResult.version !== "v2") {
506-
throw Error("Result returned from transformation didn't specify version = v2");
507-
}
508-
509-
let content;
510-
if (!transformationResult.empty) {
511-
if (typeof transformationResult.plain !== "string") {
512-
throw Error("Result returned from transformation didn't provide a string value for plain");
513-
}
514-
if (transformationResult.html !== undefined && typeof transformationResult.html !== "string") {
515-
throw Error("Result returned from transformation didn't provide a string value for html");
516-
}
517-
if (transformationResult.msgtype !== undefined && typeof transformationResult.msgtype !== "string") {
518-
throw Error("Result returned from transformation didn't provide a string value for msgtype");
519-
}
520-
content = {
521-
plain: transformationResult.plain,
522-
html: transformationResult.html,
523-
msgtype: transformationResult.msgtype,
524-
};
525-
}
526-
527-
if (transformationResult.webhookResponse) {
528-
if (typeof transformationResult.webhookResponse.body !== "string") {
529-
throw Error("Result returned from transformation didn't provide a string value for webhookResponse.body");
530-
}
531-
if (transformationResult.webhookResponse.statusCode !== undefined && typeof transformationResult.webhookResponse.statusCode !== "number" && Number.isInteger(transformationResult.webhookResponse.statusCode)) {
532-
throw Error("Result returned from transformation didn't provide a number value for webhookResponse.statusCode");
533-
}
534-
if (transformationResult.webhookResponse.contentType !== undefined && typeof transformationResult.webhookResponse.contentType !== "string") {
535-
throw Error("Result returned from transformation didn't provide a contentType value for msgtype");
536-
}
537-
}
538-
539-
return {
540-
content,
541-
webhookResponse: transformationResult.webhookResponse,
542-
}
543-
}
544444

545445
/**
546446
* Processes an incoming generic hook
@@ -559,21 +459,21 @@ export class GenericHookConnection extends BaseConnection implements IConnection
559459
};
560460
}
561461

562-
let content: {plain: string, html?: string, msgtype?: string}|undefined;
563-
let webhookResponse: WebhookResponse|undefined;
462+
let content: ExecuteResultContent|undefined;
463+
let webhookResponse: ExecuteResultWebhookResponse|undefined;
564464
let successful = true;
565-
if (!this.transformationFunction) {
566-
content = this.transformHookData(data);
567-
} else {
465+
if (this.webhookTransformer) {
568466
try {
569-
const result = this.executeTransformationFunction(data);
467+
const result = this.webhookTransformer.execute(data);
570468
content = result.content;
571469
webhookResponse = result.webhookResponse;
572470
} catch (ex) {
573471
log.warn(`Failed to run transformation function`, ex);
574472
content = {plain: `Webhook received but failed to process via transformation function`};
575473
successful = false;
576474
}
475+
} else {
476+
content = this.transformHookData(data);
577477
}
578478

579479
if (content) {
@@ -591,6 +491,7 @@ export class GenericHookConnection extends BaseConnection implements IConnection
591491
body: content.plain,
592492
// render can output redundant trailing newlines, so trim it.
593493
formatted_body: content.html || md.render(content.plain).trim(),
494+
...(content.mentions ? {"m.mentions": content.mentions} : undefined),
594495
format: "org.matrix.custom.html",
595496
"uk.half-shot.hookshot.webhook_data": safeData,
596497
}, 'm.room.message', sender);

0 commit comments

Comments
 (0)