mirror of
https://github.com/matrix-org/matrix-hookshot.git
synced 2025-03-10 13:17:08 +00:00
Outbound Webhooks (#945)
* Initial support for outbound webhooks. * Refactor outbound into it's own connection type. * Add support for media / encrypted media. * Ensure we configure a sensible User Agent * Add a test for outbound webhooks * Checkpoint for feature completeness. * Lint tidy * Finish up media tests. * changelog * Add outbound documentation * update default config * fix tests
This commit is contained in:
parent
60ccc041b3
commit
7573d37527
3
changelog.d/945.feature
Normal file
3
changelog.d/945.feature
Normal file
@ -0,0 +1,3 @@
|
||||
Add support for new connection type "Outgoing Webhooks". This feature allows you to send outgoing HTTP requests to other services
|
||||
when a message appears in a Matrix room. See [the documentation](https://matrix-org.github.io/matrix-hookshot/latest/setup/webhooks.html)
|
||||
for help with this feature.
|
@ -100,6 +100,7 @@ listeners:
|
||||
# #'allowJsTransformationFunctions' will allow users to write short transformation snippets in code, and thus is unsafe in untrusted environments
|
||||
|
||||
# enabled: false
|
||||
# outbound: false
|
||||
# enableHttpGet: false
|
||||
# urlPrefix: https://example.com/webhook/
|
||||
# userIdPrefix: _webhooks_
|
||||
|
@ -1,7 +1,7 @@
|
||||
# Webhooks
|
||||
|
||||
Hookshot supports generic webhook support so that services can send messages into Matrix rooms without being aware of the Matrix protocol. This works
|
||||
by having services hit a unique URL that then transforms a HTTP payload into a Matrix message.
|
||||
Hookshot supports two kinds of webhooks, inbound (previously known as Generic Webhooks) and outbound.
|
||||
|
||||
|
||||
## Configuration
|
||||
|
||||
@ -10,6 +10,7 @@ You will need to add the following configuration to the config file.
|
||||
```yaml
|
||||
generic:
|
||||
enabled: true
|
||||
outbound: true # For outbound webhook support
|
||||
urlPrefix: https://example.com/mywebhookspath/
|
||||
allowJsTransformationFunctions: false
|
||||
waitForComplete: false
|
||||
@ -17,6 +18,11 @@ generic:
|
||||
# userIdPrefix: webhook_
|
||||
```
|
||||
|
||||
## Inbound Webhooks
|
||||
|
||||
Hookshot supports generic webhook support so that services can send messages into Matrix rooms without being aware of the Matrix protocol. This works
|
||||
by having services hit a unique URL that then transforms a HTTP payload into a Matrix message.
|
||||
|
||||
<section class="notice">
|
||||
Previous versions of the bridge listened for requests on `/` rather than `/webhook`. While this behaviour will continue to work,
|
||||
administators are advised to use `/webhook`.
|
||||
@ -50,7 +56,7 @@ namespaces:
|
||||
exclusive: true
|
||||
```
|
||||
|
||||
## Adding a webhook
|
||||
### Adding a webhook
|
||||
|
||||
To add a webhook to your room:
|
||||
- Invite the bot user to the room.
|
||||
@ -58,7 +64,7 @@ To add a webhook to your room:
|
||||
- Say `!hookshot webhook example` where `example` is a name for your hook.
|
||||
- The bot will respond with the webhook URL to be sent to services.
|
||||
|
||||
## Webhook Handling
|
||||
### Webhook Handling
|
||||
|
||||
Hookshot handles `POST` and `PUT` HTTP requests by default.
|
||||
|
||||
@ -76,7 +82,7 @@ If the body *also* contains a `username` key, then the message will be prepended
|
||||
If the body does NOT contain a `text` field, the full payload will be sent to the room. This can be adapted into a message by creating a **JavaScript transformation function**.
|
||||
|
||||
|
||||
### Payload formats
|
||||
#### Payload formats
|
||||
|
||||
If the request is a `POST`/`PUT`, the body of the request will be decoded and stored inside the event. Currently, Hookshot supports:
|
||||
|
||||
@ -88,7 +94,7 @@ If the request is a `POST`/`PUT`, the body of the request will be decoded and st
|
||||
Decoding is done in the order given above. E.g. `text/xml` would be parsed as XML. Any formats not described above are not
|
||||
decoded.
|
||||
|
||||
### GET requests
|
||||
#### GET requests
|
||||
|
||||
In previous versions of hookshot, it would also handle the `GET` HTTP method. This was disabled due to concerns that it was too easy for the webhook to be
|
||||
inadvertently triggered by URL preview features in clients and servers. If you still need this functionality, you can enable it in the config.
|
||||
@ -102,7 +108,7 @@ to a string representation of that value. This change is <strong>not applied</st
|
||||
variable, so it will contain proper float values.
|
||||
</section>
|
||||
|
||||
### Wait for complete
|
||||
#### Wait for complete
|
||||
|
||||
It is possible to choose whether a webhook response should be instant, or after hookshot has handled the message. The reason
|
||||
for this is that some services expect a quick response time (like Slack) whereas others will wait for the request to complete. You
|
||||
@ -111,7 +117,7 @@ can specify this either globally in your config, or on the widget with `waitForC
|
||||
If you make use of the `webhookResponse` feature, you will need to enable `waitForComplete` as otherwise hookshot will
|
||||
immeditately respond with it's default response values.
|
||||
|
||||
## JavaScript Transformations
|
||||
### JavaScript Transformations
|
||||
|
||||
<section class="notice">
|
||||
Although every effort has been made to securely sandbox scripts, running untrusted code from users is always risky. Ensure safe permissions
|
||||
@ -130,7 +136,7 @@ Please seek out documentation from your client on how to achieve this.
|
||||
|
||||
The script string should be set within the state event under the `transformationFunction` key.
|
||||
|
||||
### Script API
|
||||
#### Script API
|
||||
|
||||
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`.
|
||||
@ -141,7 +147,7 @@ Scripts are executed synchronously and expect the `result` variable to be set.
|
||||
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
|
||||
#### V2 API
|
||||
|
||||
The `v2` api expects an object to be returned from the `result` variable.
|
||||
|
||||
@ -176,7 +182,7 @@ if (data.counter === undefined) {
|
||||
```
|
||||
|
||||
|
||||
### V1 API
|
||||
#### V1 API
|
||||
|
||||
The v1 API expects `result` to be a string. The string will be automatically interpreted as Markdown and transformed into HTML. All webhook messages
|
||||
will be prefixed with `Received webhook:`. If `result` is falsey (undefined, false or null) then the message will be `No content`.
|
||||
@ -192,3 +198,36 @@ if (data.counter > data.maxValue) {
|
||||
result = `*Everything is fine*, the counter is under by ${data.maxValue - data.counter}`
|
||||
}
|
||||
```
|
||||
|
||||
## Outbound webhooks
|
||||
|
||||
You can also configure Hookshot to send outgoing requests to other services when a message appears
|
||||
on Matrix. To do so, you need to configure hookshot to enable outgoing messages with:
|
||||
|
||||
```yaml
|
||||
generic:
|
||||
outbound: true
|
||||
```
|
||||
|
||||
### Request format
|
||||
|
||||
Requests can be sent to any service that accepts HTTP requests. You may configure Hookshot to either use the HTTP `PUT` (default)
|
||||
or `POST` methods.
|
||||
|
||||
Each request will contain 3 headers which you may use to authenticate and direct traffic:
|
||||
|
||||
- 'X-Matrix-Hookshot-EventId' contains the event's ID.
|
||||
- 'X-Matrix-Hookshot-RoomId' contains the room ID where the message was sent.
|
||||
- 'X-Matrix-Hookshot-Token' is the unique authentication token provided when you created the webhook. Use this
|
||||
to verify that the message came from Hookshot.
|
||||
|
||||
The payloads are formatted as `multipart/form-data`.
|
||||
|
||||
The first file contains the event JSON data, proviced as the `event` file. This is a raw representation of the Matrix event data. If the
|
||||
event was encrypted, this will be the **decrypted** body.
|
||||
|
||||
If any media is linked to in the event, then a second file will be present named `media` which will contain the media referenced in
|
||||
the event.
|
||||
|
||||
All events that occur in the room will be sent to the outbound URL, so be careful to ensure your remote service can filter the
|
||||
traffic appropriately (e.g. check the `type` in the event JSON)
|
||||
|
@ -48,8 +48,8 @@
|
||||
"@octokit/rest": "^20.0.2",
|
||||
"@octokit/webhooks": "^12.0.10",
|
||||
"@sentry/node": "^7.52.1",
|
||||
"@vector-im/compound-design-tokens": "^0.1.0",
|
||||
"@vector-im/compound-web": "^0.9.4",
|
||||
"@vector-im/compound-design-tokens": "^1.3.0",
|
||||
"@vector-im/compound-web": "^4.8.0",
|
||||
"ajv": "^8.11.0",
|
||||
"axios": "^1.6.3",
|
||||
"cors": "^2.8.5",
|
||||
@ -86,6 +86,7 @@
|
||||
"@rollup/plugin-alias": "^5.1.0",
|
||||
"@tsconfig/node18": "^18.2.2",
|
||||
"@types/ajv": "^1.0.0",
|
||||
"@types/busboy": "^1.5.4",
|
||||
"@types/chai": "^4.2.22",
|
||||
"@types/cors": "^2.8.12",
|
||||
"@types/express": "^4.17.14",
|
||||
@ -100,6 +101,7 @@
|
||||
"@typescript-eslint/eslint-plugin": "^6.17.0",
|
||||
"@typescript-eslint/parser": "^6.17.0",
|
||||
"@uiw/react-codemirror": "^4.12.3",
|
||||
"busboy": "^1.6.0",
|
||||
"chai": "^4.3.4",
|
||||
"eslint": "^8.49.0",
|
||||
"eslint-config-preact": "^1.3.0",
|
||||
@ -116,5 +118,6 @@
|
||||
"ts-node": "^10.9.1",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.0.13"
|
||||
}
|
||||
},
|
||||
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
|
||||
}
|
||||
|
1
spec/util/fixtures.ts
Normal file
1
spec/util/fixtures.ts
Normal file
File diff suppressed because one or more lines are too long
201
spec/webhooks.spec.ts
Normal file
201
spec/webhooks.spec.ts
Normal file
@ -0,0 +1,201 @@
|
||||
import { E2ESetupTestTimeout, E2ETestEnv, E2ETestMatrixClient } from "./util/e2e-test";
|
||||
import { describe, it, beforeEach, afterEach } from "@jest/globals";
|
||||
import { OutboundHookConnection } from "../src/Connections";
|
||||
import { TextualMessageEventContent } from "matrix-bot-sdk";
|
||||
import { IncomingHttpHeaders, createServer } from "http";
|
||||
import busboy, { FileInfo } from "busboy";
|
||||
import { TEST_FILE } from "./util/fixtures";
|
||||
|
||||
async function createOutboundConnection(user: E2ETestMatrixClient, botMxid: string, roomId: string) {
|
||||
const join = user.waitForRoomJoin({ sender: botMxid, roomId });
|
||||
const connectionEvent = user.waitForRoomEvent({
|
||||
eventType: OutboundHookConnection.CanonicalEventType,
|
||||
stateKey: 'test',
|
||||
sender: botMxid
|
||||
});
|
||||
await user.inviteUser(botMxid, roomId);
|
||||
await user.setUserPowerLevel(botMxid, roomId, 50);
|
||||
await join;
|
||||
|
||||
// Note: Here we create the DM proactively so this works across multiple
|
||||
// tests.
|
||||
// Get the DM room so we can get the token.
|
||||
const dmRoomId = await user.dms.getOrCreateDm(botMxid);
|
||||
|
||||
await user.sendText(roomId, '!hookshot outbound-hook test http://localhost:8111/test-path');
|
||||
// Test the contents of this.
|
||||
await connectionEvent;
|
||||
|
||||
const msgPromise = user.waitForRoomEvent({ sender: botMxid, eventType: "m.room.message", roomId: dmRoomId });
|
||||
const { data: msgData } = await msgPromise;
|
||||
|
||||
const [_match, token ] = /<code>(.+)<\/code>/.exec((msgData.content as unknown as TextualMessageEventContent).formatted_body ?? "") ?? [];
|
||||
return token;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @returns
|
||||
*/
|
||||
function awaitOutboundWebhook() {
|
||||
return new Promise<{headers: IncomingHttpHeaders, files: {name: string, file: Buffer, info: FileInfo}[]}>((resolve, reject) => {
|
||||
const server = createServer((req, res) => {
|
||||
const bb = busboy({headers: req.headers});
|
||||
const files: {name: string, file: Buffer, info: FileInfo}[] = [];
|
||||
bb.on('file', (name, stream, info) => {
|
||||
const buffers: Buffer[] = [];
|
||||
stream.on('data', d => {
|
||||
buffers.push(d)
|
||||
});
|
||||
stream.once('close', () => {
|
||||
files.push({name, info, file: Buffer.concat(buffers)})
|
||||
});
|
||||
});
|
||||
|
||||
bb.once('close', () => {
|
||||
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
||||
res.end('OK');
|
||||
resolve({
|
||||
headers: req.headers,
|
||||
files,
|
||||
});
|
||||
clearTimeout(timer);
|
||||
server.close();
|
||||
});
|
||||
|
||||
req.pipe(bb);
|
||||
});
|
||||
server.listen(8111);
|
||||
let timer: NodeJS.Timeout;
|
||||
timer = setTimeout(() => {
|
||||
reject(new Error("Request did not arrive"));
|
||||
server.close();
|
||||
}, 10000);
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
describe('OutboundHooks', () => {
|
||||
let testEnv: E2ETestEnv;
|
||||
|
||||
beforeAll(async () => {
|
||||
const webhooksPort = 9500 + E2ETestEnv.workerId;
|
||||
testEnv = await E2ETestEnv.createTestEnv({
|
||||
matrixLocalparts: ['user'],
|
||||
config: {
|
||||
generic: {
|
||||
enabled: true,
|
||||
outbound: true,
|
||||
urlPrefix: `http://localhost:${webhooksPort}`
|
||||
},
|
||||
listeners: [{
|
||||
port: webhooksPort,
|
||||
bindAddress: '0.0.0.0',
|
||||
// Bind to the SAME listener to ensure we don't have conflicts.
|
||||
resources: ['webhooks'],
|
||||
}],
|
||||
}
|
||||
});
|
||||
await testEnv.setUp();
|
||||
}, E2ESetupTestTimeout);
|
||||
|
||||
afterAll(() => {
|
||||
return testEnv?.tearDown();
|
||||
});
|
||||
|
||||
it('should be able to create a new webhook and push an event.', async () => {
|
||||
const user = testEnv.getUser('user');
|
||||
const roomId = await user.createRoom({ name: 'My Test Webhooks room'});
|
||||
const token = await createOutboundConnection(user, testEnv.botMxid, roomId);
|
||||
const gotWebhookRequest = awaitOutboundWebhook();
|
||||
|
||||
const eventId = await user.sendText(roomId, 'hello!');
|
||||
const { headers, files } = await gotWebhookRequest;
|
||||
expect(headers['x-matrix-hookshot-roomid']).toEqual(roomId);
|
||||
expect(headers['x-matrix-hookshot-eventid']).toEqual(eventId);
|
||||
expect(headers['x-matrix-hookshot-token']).toEqual(token);
|
||||
|
||||
// And check the JSON payload
|
||||
const [event, media] = files;
|
||||
expect(event.name).toEqual('event');
|
||||
expect(event.info.mimeType).toEqual('application/json');
|
||||
expect(event.info.filename).toEqual('event_data.json');
|
||||
const eventJson = JSON.parse(event.file.toString('utf-8'));
|
||||
|
||||
// Check that the content looks sane.
|
||||
expect(eventJson.room_id).toEqual(roomId);
|
||||
expect(eventJson.event_id).toEqual(eventId);
|
||||
expect(eventJson.sender).toEqual(await user.getUserId());
|
||||
expect(eventJson.content.body).toEqual('hello!');
|
||||
|
||||
// No media should be present.
|
||||
expect(media).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should be able to create a new webhook and push a media attachment.', async () => {
|
||||
const user = testEnv.getUser('user');
|
||||
const roomId = await user.createRoom({ name: 'My Test Webhooks room'});
|
||||
await createOutboundConnection(user, testEnv.botMxid, roomId);
|
||||
const gotWebhookRequest = awaitOutboundWebhook();
|
||||
|
||||
const mxcUrl = await user.uploadContent(TEST_FILE, 'image/svg+xml', "matrix.svg");
|
||||
await user.sendMessage(roomId, {
|
||||
url: mxcUrl,
|
||||
msgtype: "m.file",
|
||||
body: "matrix.svg",
|
||||
})
|
||||
const { files } = await gotWebhookRequest;
|
||||
const [event, media] = files;
|
||||
expect(event.info.mimeType).toEqual('application/json');
|
||||
expect(event.info.filename).toEqual('event_data.json');
|
||||
const eventJson = JSON.parse(event.file.toString('utf-8'));
|
||||
expect(eventJson.content.body).toEqual('matrix.svg');
|
||||
|
||||
|
||||
expect(media.info.mimeType).toEqual('image/svg+xml');
|
||||
expect(media.info.filename).toEqual('matrix.svg');
|
||||
expect(media.file).toEqual(TEST_FILE);
|
||||
});
|
||||
|
||||
// TODO: This requires us to support Redis in test conditions, as encryption is not possible
|
||||
// in hookshot without it at the moment.
|
||||
|
||||
// it.only('should be able to create a new webhook and push an encrypted media attachment.', async () => {
|
||||
// const user = testEnv.getUser('user');
|
||||
// const roomId = await user.createRoom({ name: 'My Test Webhooks room', initial_state: [{
|
||||
// content: {
|
||||
// "algorithm": "m.megolm.v1.aes-sha2"
|
||||
// },
|
||||
// state_key: "",
|
||||
// type: "m.room.encryption"
|
||||
// }]});
|
||||
// await createOutboundConnection(user, testEnv.botMxid, roomId);
|
||||
// const gotWebhookRequest = awaitOutboundWebhook();
|
||||
|
||||
// const encrypted = await user.crypto.encryptMedia(Buffer.from(TEST_FILE));
|
||||
// const mxc = await user.uploadContent(TEST_FILE);
|
||||
// await user.sendMessage(roomId, {
|
||||
// msgtype: "m.image",
|
||||
// body: "matrix.svg",
|
||||
// info: {
|
||||
// mimetype: "image/svg+xml",
|
||||
// },
|
||||
// file: {
|
||||
// url: mxc,
|
||||
// ...encrypted.file,
|
||||
// },
|
||||
// });
|
||||
|
||||
// const { headers, files } = await gotWebhookRequest;
|
||||
// const [event, media] = files;
|
||||
// expect(event.info.mimeType).toEqual('application/json');
|
||||
// expect(event.info.filename).toEqual('event_data.json');
|
||||
// const eventJson = JSON.parse(event.file.toString('utf-8'));
|
||||
// expect(eventJson.content.body).toEqual('matrix.svg');
|
||||
|
||||
|
||||
// expect(media.info.mimeType).toEqual('image/svg+xml');
|
||||
// expect(media.info.filename).toEqual('matrix.svg');
|
||||
// expect(media.file).toEqual(TEST_FILE);
|
||||
// });
|
||||
});
|
@ -1164,11 +1164,15 @@ export class Bridge {
|
||||
}
|
||||
if (!existingConnections.length) {
|
||||
// Is anyone interested in this state?
|
||||
try {
|
||||
const connection = await this.connectionManager.createConnectionForState(roomId, new StateEvent(event), true);
|
||||
if (connection) {
|
||||
log.info(`New connected added to ${roomId}: ${connection.toString()}`);
|
||||
this.connectionManager.push(connection);
|
||||
}
|
||||
} catch (ex) {
|
||||
log.error(`Failed to handle connection for state ${event.type} in ${roomId}`, ex);
|
||||
}
|
||||
}
|
||||
|
||||
const botUsersInRoom = this.botUsersManager.getBotUsersInRoom(roomId);
|
||||
|
@ -467,7 +467,6 @@ export class GenericHookConnection extends BaseConnection implements IConnection
|
||||
successful,
|
||||
response: webhookResponse,
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
public static getProvisionerDetails(botUserId: string) {
|
||||
@ -492,7 +491,7 @@ export class GenericHookConnection extends BaseConnection implements IConnection
|
||||
...(showSecrets ? { secrets: {
|
||||
url: new URL(this.hookId, this.config.parsedUrlPrefix),
|
||||
hookId: this.hookId,
|
||||
} as GenericHookSecrets} : undefined)
|
||||
} satisfies GenericHookSecrets} : undefined)
|
||||
}
|
||||
}
|
||||
|
||||
|
281
src/Connections/OutboundHook.ts
Normal file
281
src/Connections/OutboundHook.ts
Normal file
@ -0,0 +1,281 @@
|
||||
import axios, { isAxiosError } from "axios";
|
||||
import { BaseConnection } from "./BaseConnection";
|
||||
import { Connection, IConnection, IConnectionState, InstantiateConnectionOpts, ProvisionConnectionOpts } from "./IConnection";
|
||||
import { ApiError, ErrCode, Logger } from "matrix-appservice-bridge";
|
||||
import { MatrixEvent } from "../MatrixEvent";
|
||||
import { FileMessageEventContent, Intent, StateEvent } from "matrix-bot-sdk";
|
||||
import { randomUUID } from "crypto";
|
||||
import UserAgent from "../UserAgent";
|
||||
import { hashId } from "../libRs";
|
||||
import { GetConnectionsResponseItem } from "../provisioning/api";
|
||||
|
||||
export interface OutboundHookConnectionState extends IConnectionState {
|
||||
name: string,
|
||||
url: string;
|
||||
method?: "PUT"|"POST";
|
||||
}
|
||||
|
||||
export interface OutboundHookSecrets {
|
||||
token: string;
|
||||
}
|
||||
|
||||
export type OutboundHookResponseItem = GetConnectionsResponseItem<OutboundHookConnectionState, OutboundHookSecrets>;
|
||||
|
||||
|
||||
const log = new Logger("OutboundHookConnection");
|
||||
|
||||
/**
|
||||
* Handles rooms connected to an outbound generic service.
|
||||
*/
|
||||
@Connection
|
||||
export class OutboundHookConnection extends BaseConnection implements IConnection {
|
||||
static readonly CanonicalEventType = "uk.half-shot.matrix-hookshot.outbound-hook";
|
||||
static readonly ServiceCategory = "genericOutbound";
|
||||
|
||||
static readonly EventTypes = [
|
||||
OutboundHookConnection.CanonicalEventType,
|
||||
];
|
||||
|
||||
private static getAccountDataKey(stateKey: string) {
|
||||
return `${OutboundHookConnection.CanonicalEventType}:${stateKey}`;
|
||||
}
|
||||
|
||||
static validateState(state: Record<string, unknown>): OutboundHookConnectionState {
|
||||
const {url, method, name} = state;
|
||||
if (typeof url !== "string") {
|
||||
throw new ApiError('Outbound URL must be a string', ErrCode.BadValue);
|
||||
}
|
||||
|
||||
if (typeof name !== "string") {
|
||||
throw new ApiError("A webhook name must be a string.", ErrCode.BadValue);
|
||||
}
|
||||
|
||||
try {
|
||||
const validatedUrl = new URL(url);
|
||||
if (validatedUrl.protocol !== "http:" && validatedUrl.protocol !== "https:") {
|
||||
throw new ApiError('Outbound URL protocol must be http or https', ErrCode.BadValue);
|
||||
}
|
||||
} catch (ex) {
|
||||
if (ex instanceof ApiError) {
|
||||
throw ex;
|
||||
}
|
||||
throw new ApiError('Outbound URL is invalid', ErrCode.BadValue);
|
||||
}
|
||||
|
||||
if (method === "PUT" || method === "POST" || method === undefined) {
|
||||
return {
|
||||
name,
|
||||
url,
|
||||
method: method ?? 'PUT',
|
||||
};
|
||||
}
|
||||
throw new ApiError('Outbound Method must be one of PUT,POST', ErrCode.BadValue);
|
||||
}
|
||||
|
||||
static async createConnectionForState(roomId: string, event: StateEvent<Record<string, unknown>>, {intent, config, tokenStore}: InstantiateConnectionOpts) {
|
||||
if (!config.generic) {
|
||||
throw Error('Generic webhooks are not configured');
|
||||
}
|
||||
// Generic hooks store the hookId in the account data
|
||||
const state = this.validateState(event.content);
|
||||
const token = await tokenStore.getGenericToken("outboundHookToken", hashId(`${roomId}:${event.stateKey}`));
|
||||
|
||||
if (!token) {
|
||||
throw new Error(`Missing stored token for connection`);
|
||||
}
|
||||
|
||||
return new OutboundHookConnection(
|
||||
roomId,
|
||||
state,
|
||||
token,
|
||||
event.stateKey,
|
||||
intent,
|
||||
);
|
||||
}
|
||||
|
||||
static async provisionConnection(roomId: string, userId: string, data: Record<string, unknown> = {}, {intent, config, tokenStore}: ProvisionConnectionOpts) {
|
||||
if (!config.generic) {
|
||||
throw Error('Generic Webhooks are not configured');
|
||||
}
|
||||
if (!config.generic.outbound) {
|
||||
throw Error('Outbound support for Generic Webhooks is not configured');
|
||||
}
|
||||
|
||||
const token = `hs-ob-${randomUUID()}`;
|
||||
|
||||
if (typeof data.name !== "string" || data.name.length < 3 || data.name.length > 64) {
|
||||
throw new ApiError("A webhook name must be between 3-64 characters.", ErrCode.BadValue);
|
||||
}
|
||||
|
||||
const validState = OutboundHookConnection.validateState(data);
|
||||
|
||||
const stateKey = data.name;
|
||||
const tokenKey = hashId(`${roomId}:${stateKey}`);
|
||||
await tokenStore.storeGenericToken("outboundHookToken", tokenKey, token);
|
||||
|
||||
await intent.underlyingClient.sendStateEvent(roomId, this.CanonicalEventType, stateKey, validState);
|
||||
const connection = new OutboundHookConnection(roomId, validState, token, stateKey, intent);
|
||||
return {
|
||||
connection,
|
||||
stateEventContent: validState,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param state Should be a pre-validated state object returned by {@link validateState}
|
||||
*/
|
||||
constructor(
|
||||
roomId: string,
|
||||
private state: OutboundHookConnectionState,
|
||||
public readonly outboundToken: string,
|
||||
stateKey: string,
|
||||
private readonly intent: Intent,
|
||||
) {
|
||||
super(roomId, stateKey, OutboundHookConnection.CanonicalEventType);
|
||||
}
|
||||
|
||||
public isInterestedInStateEvent(eventType: string, stateKey: string) {
|
||||
return OutboundHookConnection.EventTypes.includes(eventType) && this.stateKey === stateKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for any embedded media in the event, and if present then extract it as a blob. This
|
||||
* function also returns event content with the encryption details stripped from the event contents.
|
||||
* @param ev The Matrix event to inspect for embedded media.
|
||||
* @returns A blob and event object if media is found, otherwise null.
|
||||
* @throws If media was expected (due to the msgtype) but not provided, or if the media could not
|
||||
* be found or decrypted.
|
||||
*/
|
||||
private async extractMedia(ev: MatrixEvent<unknown>): Promise<{blob: Blob, event: MatrixEvent<unknown>}|null> {
|
||||
// Check for non-extendable event types first.
|
||||
const content = ev.content as FileMessageEventContent;
|
||||
|
||||
if (!["m.image", "m.audio", "m.file", "m.video"].includes(content.msgtype)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const client = this.intent.underlyingClient;
|
||||
let data: { data: Buffer, contentType?: string};
|
||||
if (client.crypto && content.file) {
|
||||
data = {
|
||||
data: await client.crypto.decryptMedia(content.file),
|
||||
contentType: content.info?.mimetype
|
||||
};
|
||||
const strippedContent = {...ev, content: {
|
||||
...content,
|
||||
file: null,
|
||||
}};
|
||||
return {
|
||||
blob: new File([await client.crypto.decryptMedia(content.file)], content.body, { type: data.contentType }),
|
||||
event: strippedContent
|
||||
}
|
||||
} else if (content.url) {
|
||||
data = await this.intent.underlyingClient.downloadContent(content.url);
|
||||
return {
|
||||
blob: new File([data.data], content.body, { type: data.contentType }),
|
||||
event: ev,
|
||||
};
|
||||
}
|
||||
|
||||
throw Error('Missing file or url key on event, not handling media');
|
||||
}
|
||||
|
||||
|
||||
public async onEvent(ev: MatrixEvent<unknown>): Promise<void> {
|
||||
// The event content first.
|
||||
const multipartBlob = new FormData();
|
||||
try {
|
||||
const mediaResult = await this.extractMedia(ev);
|
||||
if (mediaResult) {
|
||||
multipartBlob.set('event', new Blob([JSON.stringify(mediaResult?.event)], {
|
||||
type: 'application/json',
|
||||
}), "event_data.json");
|
||||
multipartBlob.set('media', mediaResult.blob);
|
||||
}
|
||||
} catch (ex) {
|
||||
log.warn(`Failed to get media for ${ev.event_id} in ${this.roomId}`, ex);
|
||||
}
|
||||
|
||||
if (!multipartBlob.has('event')) {
|
||||
multipartBlob.set('event', new Blob([JSON.stringify(ev)], {
|
||||
type: 'application/json',
|
||||
}), "event_data.json");
|
||||
}
|
||||
|
||||
try {
|
||||
await axios.request({
|
||||
url: this.state.url,
|
||||
data: multipartBlob,
|
||||
method: this.state.method,
|
||||
responseType: 'text',
|
||||
validateStatus: (status) => status >= 200 && status <= 299,
|
||||
headers: {
|
||||
'User-Agent': UserAgent,
|
||||
'X-Matrix-Hookshot-RoomId': this.roomId,
|
||||
'X-Matrix-Hookshot-EventId': ev.event_id,
|
||||
'X-Matrix-Hookshot-Token': this.outboundToken,
|
||||
},
|
||||
});
|
||||
log.info(`Sent webhook for ${ev.event_id}`);
|
||||
} catch (ex) {
|
||||
if (!isAxiosError(ex)) {
|
||||
log.error(`Failed to send outbound webhook`, ex);
|
||||
throw ex;
|
||||
}
|
||||
if (ex.status) {
|
||||
log.error(`Failed to send outbound webhook: HTTP ${ex.status}`);
|
||||
} else {
|
||||
log.error(`Failed to send outbound webhook: ${ex.code}`);
|
||||
}
|
||||
log.debug("Response from server", ex.response?.data);
|
||||
}
|
||||
}
|
||||
|
||||
public static getProvisionerDetails(botUserId: string) {
|
||||
return {
|
||||
service: "genericOutbound",
|
||||
eventType: OutboundHookConnection.CanonicalEventType,
|
||||
type: "Webhook",
|
||||
botUserId: botUserId,
|
||||
}
|
||||
}
|
||||
|
||||
public getProvisionerDetails(showSecrets = false): OutboundHookResponseItem {
|
||||
return {
|
||||
...OutboundHookConnection.getProvisionerDetails(this.intent.userId),
|
||||
id: this.connectionId,
|
||||
config: {
|
||||
url: this.state.url,
|
||||
method: this.state.method,
|
||||
name: this.state.name,
|
||||
},
|
||||
...(showSecrets ? { secrets: {
|
||||
token: this.outboundToken,
|
||||
} satisfies OutboundHookSecrets} : undefined)
|
||||
}
|
||||
}
|
||||
|
||||
public async onRemove() {
|
||||
log.info(`Removing ${this.toString()} for ${this.roomId}`);
|
||||
// Do a sanity check that the event exists.
|
||||
await this.intent.underlyingClient.getRoomStateEvent(this.roomId, OutboundHookConnection.CanonicalEventType, this.stateKey);
|
||||
await this.intent.underlyingClient.sendStateEvent(this.roomId, OutboundHookConnection.CanonicalEventType, this.stateKey, { disabled: true });
|
||||
// TODO: Remove token
|
||||
|
||||
}
|
||||
|
||||
public async provisionerUpdateConfig(userId: string, config: Record<string, unknown>) {
|
||||
config = { ...this.state, ...config };
|
||||
const validatedConfig = OutboundHookConnection.validateState(config);
|
||||
await this.intent.underlyingClient.sendStateEvent(this.roomId, OutboundHookConnection.CanonicalEventType, this.stateKey,
|
||||
{
|
||||
...validatedConfig,
|
||||
}
|
||||
);
|
||||
this.state = validatedConfig;
|
||||
}
|
||||
|
||||
public toString() {
|
||||
return `OutboundHookConnection ${this.roomId}`;
|
||||
}
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
import { BotCommands, botCommand, compileBotCommands, HelpFunction } from "../BotCommands";
|
||||
import { CommandConnection } from "./CommandConnection";
|
||||
import { GenericHookConnection, GenericHookConnectionState, GitHubRepoConnection, JiraProjectConnection, JiraProjectConnectionState } from ".";
|
||||
import { GenericHookConnection, GenericHookConnectionState, GitHubRepoConnection, JiraProjectConnection, JiraProjectConnectionState, OutboundHookConnection } from ".";
|
||||
import { CommandError } from "../errors";
|
||||
import { BridgePermissionLevel } from "../config/Config";
|
||||
import markdown from "markdown-it";
|
||||
@ -18,6 +18,8 @@ import { HoundConnection } from "./HoundConnection";
|
||||
const md = new markdown();
|
||||
const log = new Logger("SetupConnection");
|
||||
|
||||
const OUTBOUND_DOCS_LINK = "https://matrix-org.github.io/matrix-hookshot/latest/setup/webhooks.html";
|
||||
|
||||
/**
|
||||
* Handles setting up a room with connections. This connection is "virtual" in that it has
|
||||
* no state, and is only invoked when messages from other clients fall through.
|
||||
@ -284,6 +286,35 @@ export class SetupConnection extends CommandConnection {
|
||||
return this.client.sendHtmlNotice(this.roomId, md.renderInline(`Removed webhook \`${name}\``));
|
||||
}
|
||||
|
||||
|
||||
|
||||
@botCommand("outbound-hook", { help: "Create an outbound webhook.", requiredArgs: ["name", "url"], includeUserId: true, category: GenericHookConnection.ServiceCategory})
|
||||
public async onOutboundHook(userId: string, name: string, url: string) {
|
||||
if (!this.config.generic?.outbound) {
|
||||
throw new CommandError("not-configured", "The bridge is not configured to support webhooks.");
|
||||
}
|
||||
|
||||
await this.checkUserPermissions(userId, "webhooks", GitHubRepoConnection.CanonicalEventType);
|
||||
|
||||
const { connection }= await OutboundHookConnection.provisionConnection(this.roomId, userId, {name, url}, this.provisionOpts);
|
||||
this.pushConnections(connection);
|
||||
|
||||
const adminRoom = await this.getOrCreateAdminRoom(this.intent, userId);
|
||||
const safeRoomId = encodeURIComponent(this.roomId);
|
||||
|
||||
await this.client.sendHtmlNotice(
|
||||
adminRoom.roomId,
|
||||
md.renderInline(
|
||||
`You have bridged the webhook "${name}" in https://matrix.to/#/${safeRoomId} .\n` +
|
||||
// Line break before and no full stop after URL is intentional.
|
||||
// This makes copying and pasting the URL much easier.
|
||||
`Please use the secret token \`${connection.outboundToken}\` when validating the request.\n` +
|
||||
`See the [documentation](${OUTBOUND_DOCS_LINK}) for more information`,
|
||||
));
|
||||
return this.client.sendNotice(this.roomId, `Room configured to bridge outbound webhooks. See admin room for the secret token.`);
|
||||
}
|
||||
|
||||
|
||||
@botCommand("figma file", { help: "Bridge a Figma file to the room.", requiredArgs: ["url"], includeUserId: true, category: FigmaFileConnection.ServiceCategory})
|
||||
public async onFigma(userId: string, url: string) {
|
||||
if (!this.config.figma) {
|
||||
|
@ -11,3 +11,4 @@ export * from "./IConnection";
|
||||
export * from "./JiraProject";
|
||||
export * from "./FigmaFileConnection";
|
||||
export * from "./FeedConnection";
|
||||
export * from "./OutboundHook";
|
@ -100,7 +100,8 @@ export class BridgeWidgetApi extends ProvisioningApi {
|
||||
general: true,
|
||||
github: !!this.config.github,
|
||||
gitlab: !!this.config.gitlab,
|
||||
generic: !!this.config.generic,
|
||||
generic: !!this.config.generic?.enabled,
|
||||
genericOutbound: !!this.config.generic?.outbound,
|
||||
jira: !!this.config.jira,
|
||||
figma: !!this.config.figma,
|
||||
feeds: !!this.config.feeds?.enabled,
|
||||
|
@ -9,10 +9,9 @@ import { BridgeConfigActorPermission, BridgePermissions } from "../libRs";
|
||||
import { ConfigError } from "../errors";
|
||||
import { ApiError, ErrCode } from "../api";
|
||||
import { GithubInstance, GITHUB_CLOUD_URL } from "../github/GithubInstance";
|
||||
import { Logger } from "matrix-appservice-bridge";
|
||||
import { DefaultDisallowedIpRanges, Logger } from "matrix-appservice-bridge";
|
||||
import { BridgeConfigCache } from "./sections/cache";
|
||||
import { BridgeConfigQueue } from "./sections";
|
||||
import { DefaultConfigRoot } from "./Defaults";
|
||||
|
||||
const log = new Logger("Config");
|
||||
|
||||
@ -296,10 +295,13 @@ export interface BridgeGenericWebhooksConfigYAML {
|
||||
allowJsTransformationFunctions?: boolean;
|
||||
waitForComplete?: boolean;
|
||||
enableHttpGet?: boolean;
|
||||
outbound?: boolean;
|
||||
disallowedIpRanges?: string[];
|
||||
}
|
||||
|
||||
export class BridgeConfigGenericWebhooks {
|
||||
public readonly enabled: boolean;
|
||||
public readonly outbound: boolean;
|
||||
|
||||
@hideKey()
|
||||
public readonly parsedUrlPrefix: URL;
|
||||
@ -311,6 +313,7 @@ export class BridgeConfigGenericWebhooks {
|
||||
public readonly enableHttpGet: boolean;
|
||||
constructor(yaml: BridgeGenericWebhooksConfigYAML) {
|
||||
this.enabled = yaml.enabled || false;
|
||||
this.outbound = yaml.outbound || false;
|
||||
this.enableHttpGet = yaml.enableHttpGet || false;
|
||||
try {
|
||||
this.parsedUrlPrefix = makePrefixedUrl(yaml.urlPrefix);
|
||||
@ -757,6 +760,9 @@ remove "useLegacySledStore" from your configuration file, and restart Hookshot.
|
||||
}
|
||||
if (this.generic && this.generic.enabled) {
|
||||
services.push("generic");
|
||||
if (this.generic.outbound) {
|
||||
services.push("genericOutbound");
|
||||
}
|
||||
}
|
||||
if (this.github) {
|
||||
services.push("github");
|
||||
@ -788,6 +794,7 @@ remove "useLegacySledStore" from your configuration file, and restart Hookshot.
|
||||
case "gitlab":
|
||||
config = this.gitlab?.publicConfig;
|
||||
break;
|
||||
case "genericOutbound":
|
||||
case "jira":
|
||||
config = {};
|
||||
break;
|
||||
|
@ -26,8 +26,8 @@ const LEGACY_ACCOUNT_DATA_TYPE = "uk.half-shot.matrix-github.password-store:";
|
||||
const LEGACY_ACCOUNT_DATA_GITLAB_TYPE = "uk.half-shot.matrix-github.gitlab.password-store:";
|
||||
|
||||
const log = new Logger("UserTokenStore");
|
||||
export type TokenType = "github"|"gitlab"|"jira";
|
||||
export const AllowedTokenTypes = ["github", "gitlab", "jira"];
|
||||
export type TokenType = "github"|"gitlab"|"jira"|"generic";
|
||||
export const AllowedTokenTypes = ["github", "gitlab", "jira", "generic"];
|
||||
|
||||
interface StoredTokenData {
|
||||
encrypted: string|string[];
|
||||
@ -165,6 +165,37 @@ export class UserTokenStore extends TypedEmitter<Emitter> {
|
||||
return null;
|
||||
}
|
||||
|
||||
public async storeGenericToken(namespace: string, key: string, token: string) {
|
||||
const finalTokenKey = `generic:${namespace}:${key}`
|
||||
const tokenParts: string[] = this.tokenEncryption.encrypt(token);
|
||||
const data: StoredTokenData = {
|
||||
encrypted: tokenParts,
|
||||
keyId: this.keyId,
|
||||
algorithm: "rsa-pkcs1v15",
|
||||
};
|
||||
await this.intent.underlyingClient.setAccountData(finalTokenKey, data);
|
||||
log.debug(`Stored token ${namespace}`);
|
||||
}
|
||||
|
||||
public async getGenericToken(namespace: string, key: string): Promise<string|null> {
|
||||
const finalTokenKey = `generic:${namespace}:${key}`
|
||||
const obj = await this.intent.underlyingClient.getSafeAccountData<StoredTokenData|DeletedTokenData>(finalTokenKey);
|
||||
if (!obj || "deleted" in obj) {
|
||||
return null;
|
||||
}
|
||||
// For legacy we just assume it's the current configured key.
|
||||
const algorithm = stringToAlgo(obj.algorithm ?? "rsa");
|
||||
const keyId = obj.keyId ?? this.keyId;
|
||||
|
||||
if (keyId !== this.keyId) {
|
||||
throw new Error(`Stored data was encrypted with a different key to the one currently configured`);
|
||||
}
|
||||
|
||||
const encryptedParts = typeof obj.encrypted === "string" ? [obj.encrypted] : obj.encrypted;
|
||||
const token = this.tokenEncryption.decrypt(encryptedParts, algorithm);
|
||||
return token;
|
||||
}
|
||||
|
||||
public static parseGitHubToken(token: string): GitHubOAuthToken {
|
||||
if (!token.startsWith('{')) {
|
||||
// Old style token
|
||||
|
@ -4,6 +4,7 @@ import style from "./RoomConfigView.module.scss";
|
||||
import { ConnectionCard } from "./ConnectionCard";
|
||||
import { FeedsConfig } from "./roomConfig/FeedsConfig";
|
||||
import { GenericWebhookConfig } from "./roomConfig/GenericWebhookConfig";
|
||||
import { OutboundWebhookConfig } from "./roomConfig/OutboundWebhookConfig";
|
||||
import { GithubRepoConfig } from "./roomConfig/GithubRepoConfig";
|
||||
import { GitlabRepoConfig } from "./roomConfig/GitlabRepoConfig";
|
||||
import { JiraProjectConfig } from "./roomConfig/JiraProjectConfig";
|
||||
@ -25,6 +26,7 @@ interface IProps {
|
||||
enum ConnectionType {
|
||||
Feeds = "feeds",
|
||||
Generic = "generic",
|
||||
GenericOutbound = "genericOutbound",
|
||||
Github = "github",
|
||||
Gitlab = "gitlab",
|
||||
Jira = "jira",
|
||||
@ -65,12 +67,19 @@ const connections: Record<ConnectionType, IConnectionProps> = {
|
||||
component: JiraProjectConfig,
|
||||
},
|
||||
[ConnectionType.Generic]: {
|
||||
displayName: 'Generic Webhook',
|
||||
displayName: 'Inbound (Generic) Webhook',
|
||||
description: "Create a webhook which can be used to connect any service to Matrix",
|
||||
icon: WebhookIcon,
|
||||
darkIcon: true,
|
||||
component: GenericWebhookConfig,
|
||||
},
|
||||
[ConnectionType.GenericOutbound]: {
|
||||
displayName: 'Outbound Webhook',
|
||||
description: "Create a webhook which can be used to connect any service to Matrix",
|
||||
icon: WebhookIcon,
|
||||
darkIcon: true,
|
||||
component: OutboundWebhookConfig,
|
||||
},
|
||||
};
|
||||
|
||||
export default function RoomConfigView(props: IProps) {
|
||||
|
@ -103,7 +103,7 @@ interface ServiceConfig {
|
||||
}
|
||||
|
||||
const RoomConfigText = {
|
||||
header: 'Generic Webhooks',
|
||||
header: 'Inbound (Generic) Webhooks',
|
||||
createNew: 'Create new webhook',
|
||||
listCanEdit: 'Your webhooks',
|
||||
listCantEdit: 'Configured webhooks',
|
||||
|
85
web/components/roomConfig/OutboundWebhookConfig.tsx
Normal file
85
web/components/roomConfig/OutboundWebhookConfig.tsx
Normal file
@ -0,0 +1,85 @@
|
||||
import { FunctionComponent, createRef } from "preact";
|
||||
import { useCallback, useState } from "preact/hooks"
|
||||
import { BridgeConfig } from "../../BridgeAPI";
|
||||
import type { OutboundHookConnectionState, OutboundHookResponseItem } from "../../../src/Connections/OutboundHook";
|
||||
import { ConnectionConfigurationProps, RoomConfig } from "./RoomConfig";
|
||||
import { InputField, ButtonSet, Button } from "../elements";
|
||||
import WebhookIcon from "../../icons/webhook.png";
|
||||
|
||||
const ConnectionConfiguration: FunctionComponent<ConnectionConfigurationProps<ServiceConfig, OutboundHookResponseItem, OutboundHookConnectionState>> = ({existingConnection, onSave, onRemove, isUpdating}) => {
|
||||
const [outboundUrl, setOutboundUrl] = useState<string>(existingConnection?.config.url ?? '');
|
||||
|
||||
const nameRef = createRef<HTMLInputElement>();
|
||||
|
||||
const canEdit = !existingConnection || (existingConnection?.canEdit ?? false);
|
||||
const handleSave = useCallback((evt: Event) => {
|
||||
evt.preventDefault();
|
||||
if (!canEdit) {
|
||||
return;
|
||||
}
|
||||
onSave({
|
||||
name: nameRef?.current?.value || existingConnection?.config.name || "Generic Webhook",
|
||||
url: outboundUrl,
|
||||
});
|
||||
}, [canEdit, onSave, nameRef, outboundUrl, existingConnection]);
|
||||
|
||||
const onUrlChange = useCallback((evt: any) => {
|
||||
setOutboundUrl(evt.target?.value);
|
||||
}, [setOutboundUrl]);
|
||||
|
||||
const [tokenRevealed, setTokenRevealed] = useState<boolean>(false);
|
||||
|
||||
const revealToken = useCallback((evt: any) => {
|
||||
evt.preventDefault();
|
||||
setTokenRevealed(true);
|
||||
}, [setTokenRevealed]);
|
||||
|
||||
|
||||
|
||||
return <form onSubmit={handleSave}>
|
||||
<InputField visible={!existingConnection} label="Friendly name" noPadding={true}>
|
||||
<input ref={nameRef} disabled={!canEdit} placeholder="My webhook" type="text" value={existingConnection?.config.name} />
|
||||
</InputField>
|
||||
|
||||
<InputField label="URL" noPadding={true}>
|
||||
<input onChange={onUrlChange} placeholder="https://example.org/my-webhook" type="text" value={outboundUrl} />
|
||||
</InputField>
|
||||
|
||||
<InputField visible={!!existingConnection} label="Token" noPadding={true}>
|
||||
<input onClick={revealToken} readOnly={true} type="text" value={tokenRevealed ? existingConnection?.secrets?.token : "Click to reveal"} />
|
||||
</InputField>
|
||||
|
||||
<ButtonSet>
|
||||
{ canEdit && <Button disabled={isUpdating} type="submit">{ existingConnection ? "Save" : "Add webhook" }</Button>}
|
||||
{ canEdit && existingConnection && <Button disabled={isUpdating} intent="remove" onClick={onRemove}>Remove webhook</Button>}
|
||||
</ButtonSet>
|
||||
</form>;
|
||||
};
|
||||
|
||||
interface ServiceConfig {
|
||||
allowJsTransformationFunctions: boolean,
|
||||
waitForComplete: boolean,
|
||||
}
|
||||
|
||||
const RoomConfigText = {
|
||||
header: 'Outbound Webhooks',
|
||||
createNew: 'Create new webhook',
|
||||
listCanEdit: 'Your webhooks',
|
||||
listCantEdit: 'Configured webhooks',
|
||||
};
|
||||
|
||||
const RoomConfigListItemFunc = (c: OutboundHookResponseItem) => c.config.name;
|
||||
|
||||
export const OutboundWebhookConfig: BridgeConfig = ({ roomId, showHeader }) => {
|
||||
return <RoomConfig<ServiceConfig, OutboundHookResponseItem, OutboundHookConnectionState>
|
||||
headerImg={WebhookIcon}
|
||||
darkHeaderImg={true}
|
||||
showHeader={showHeader}
|
||||
roomId={roomId}
|
||||
type="genericOutbound"
|
||||
connectionEventType="uk.half-shot.matrix-hookshot.outbound-hook"
|
||||
text={RoomConfigText}
|
||||
listItemName={RoomConfigListItemFunc}
|
||||
connectionConfigComponent={ConnectionConfiguration}
|
||||
/>;
|
||||
};
|
127
yarn.lock
127
yarn.lock
@ -610,6 +610,13 @@
|
||||
resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.56.0.tgz#ef20350fec605a7f7035a01764731b2de0f3782b"
|
||||
integrity sha512-gMsVel9D7f2HLkBma9VbtzZRehRogVRfbr++f06nL2vnCGCNlzOD+/MUov/F4p8myyAHspEhVobgjpX64q5m6A==
|
||||
|
||||
"@floating-ui/core@^1.0.0":
|
||||
version "1.6.2"
|
||||
resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.6.2.tgz#d37f3e0ac1f1c756c7de45db13303a266226851a"
|
||||
integrity sha512-+2XpQV9LLZeanU4ZevzRnGFg2neDeKHgFLjP6YLW+tly0IvrhqT4u8enLGjLH3qeh85g19xY5rsAusfwTdn5lg==
|
||||
dependencies:
|
||||
"@floating-ui/utils" "^0.2.0"
|
||||
|
||||
"@floating-ui/core@^1.4.2":
|
||||
version "1.5.2"
|
||||
resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.5.2.tgz#53a0f7a98c550e63134d504f26804f6b83dbc071"
|
||||
@ -617,6 +624,14 @@
|
||||
dependencies:
|
||||
"@floating-ui/utils" "^0.1.3"
|
||||
|
||||
"@floating-ui/dom@^1.0.0":
|
||||
version "1.6.5"
|
||||
resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.6.5.tgz#323f065c003f1d3ecf0ff16d2c2c4d38979f4cb9"
|
||||
integrity sha512-Nsdud2X65Dz+1RHjAIP0t8z5e2ff/IRbei6BqFrl1urT8sDVzM1HMQ+R0XcU5ceRfyO3I6ayeqIfh+6Wb8LGTw==
|
||||
dependencies:
|
||||
"@floating-ui/core" "^1.0.0"
|
||||
"@floating-ui/utils" "^0.2.0"
|
||||
|
||||
"@floating-ui/dom@^1.5.1":
|
||||
version "1.5.3"
|
||||
resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.5.3.tgz#54e50efcb432c06c23cd33de2b575102005436fa"
|
||||
@ -632,11 +647,32 @@
|
||||
dependencies:
|
||||
"@floating-ui/dom" "^1.5.1"
|
||||
|
||||
"@floating-ui/react-dom@^2.0.8", "@floating-ui/react-dom@^2.1.0":
|
||||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/@floating-ui/react-dom/-/react-dom-2.1.0.tgz#4f0e5e9920137874b2405f7d6c862873baf4beff"
|
||||
integrity sha512-lNzj5EQmEKn5FFKc04+zasr09h/uX8RtJRNj5gUXsSQIXHVWTVh+hVAg1vOMCexkX8EgvemMvIFpQfkosnVNyA==
|
||||
dependencies:
|
||||
"@floating-ui/dom" "^1.0.0"
|
||||
|
||||
"@floating-ui/react@^0.26.9":
|
||||
version "0.26.17"
|
||||
resolved "https://registry.yarnpkg.com/@floating-ui/react/-/react-0.26.17.tgz#efa2e1a0dea3d9d308965c5ccd49756bb64a883d"
|
||||
integrity sha512-ESD+jYWwqwVzaIgIhExrArdsCL1rOAzryG/Sjlu8yaD3Mtqi3uVyhbE2V7jD58Mo52qbzKz2eUY/Xgh5I86FCQ==
|
||||
dependencies:
|
||||
"@floating-ui/react-dom" "^2.1.0"
|
||||
"@floating-ui/utils" "^0.2.0"
|
||||
tabbable "^6.0.0"
|
||||
|
||||
"@floating-ui/utils@^0.1.3":
|
||||
version "0.1.6"
|
||||
resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.1.6.tgz#22958c042e10b67463997bd6ea7115fe28cbcaf9"
|
||||
integrity sha512-OfX7E2oUDYxtBvsuS4e/jSn4Q9Qb6DzgeYtsAdkPZ47znpoNsMgZw0+tVijiv3uGNR6dgNlty6r9rzIzHjtd/A==
|
||||
|
||||
"@floating-ui/utils@^0.2.0":
|
||||
version "0.2.2"
|
||||
resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.2.tgz#d8bae93ac8b815b2bd7a98078cf91e2724ef11e5"
|
||||
integrity sha512-J4yDIIthosAsRZ5CPYP/jQvUAQtlZTTD/4suA08/FEnlxqW3sKS9iAhgsa9VYLZ6vDHn/ixJgIqRQPotoBjxIw==
|
||||
|
||||
"@humanwhocodes/config-array@^0.11.13":
|
||||
version "0.11.13"
|
||||
resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.11.13.tgz#075dc9684f40a531d9b26b0822153c1e832ee297"
|
||||
@ -1325,6 +1361,19 @@
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.13.10"
|
||||
|
||||
"@radix-ui/react-context-menu@^2.1.5":
|
||||
version "2.1.5"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-context-menu/-/react-context-menu-2.1.5.tgz#1bdbd72761439f9166f75dc4598f276265785c83"
|
||||
integrity sha512-R5XaDj06Xul1KGb+WP8qiOh7tKJNz2durpLBXAGZjSVtctcRFCuEvy2gtMwRJGePwQQE5nV77gs4FwRi8T+r2g==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.13.10"
|
||||
"@radix-ui/primitive" "1.0.1"
|
||||
"@radix-ui/react-context" "1.0.1"
|
||||
"@radix-ui/react-menu" "2.0.6"
|
||||
"@radix-ui/react-primitive" "1.0.3"
|
||||
"@radix-ui/react-use-callback-ref" "1.0.1"
|
||||
"@radix-ui/react-use-controllable-state" "1.0.1"
|
||||
|
||||
"@radix-ui/react-context@1.0.1":
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-context/-/react-context-1.0.1.tgz#fe46e67c96b240de59187dcb7a1a50ce3e2ec00c"
|
||||
@ -1523,7 +1572,7 @@
|
||||
"@babel/runtime" "^7.13.10"
|
||||
"@radix-ui/react-primitive" "1.0.3"
|
||||
|
||||
"@radix-ui/react-slot@1.0.2":
|
||||
"@radix-ui/react-slot@1.0.2", "@radix-ui/react-slot@^1.0.2":
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-slot/-/react-slot-1.0.2.tgz#a9ff4423eade67f501ffb32ec22064bc9d3099ab"
|
||||
integrity sha512-YeTpuq4deV+6DusvVUW4ivBgnkHwECUu0BiN43L5UCDFgdhsRUWAghhTF5MbvNTPzmiFOx90asDSUjWuCNapwg==
|
||||
@ -1859,6 +1908,13 @@
|
||||
resolved "https://registry.yarnpkg.com/@types/btoa-lite/-/btoa-lite-1.0.2.tgz#82bb6aab00abf7cff3ca2825abe010c0cd536ae5"
|
||||
integrity sha512-ZYbcE2x7yrvNFJiU7xJGrpF/ihpkM7zKgw8bha3LNJSesvTtUNxbpzaT7WXBIryf6jovisrxTBvymxMeLLj1Mg==
|
||||
|
||||
"@types/busboy@^1.5.4":
|
||||
version "1.5.4"
|
||||
resolved "https://registry.yarnpkg.com/@types/busboy/-/busboy-1.5.4.tgz#0038c31102ca90f2a7f0d8bc27ee5ebf1088e230"
|
||||
integrity sha512-kG7WrUuAKK0NoyxfQHsVE6j1m01s6kMma64E+OZenQABMQyTJop1DumUWcLwAQ2JzpefU7PDYoRDKl8uZosFjw==
|
||||
dependencies:
|
||||
"@types/node" "*"
|
||||
|
||||
"@types/caseless@*":
|
||||
version "0.12.5"
|
||||
resolved "https://registry.yarnpkg.com/@types/caseless/-/caseless-0.12.5.tgz#db9468cb1b1b5a925b8f34822f1669df0c5472f5"
|
||||
@ -2273,24 +2329,27 @@
|
||||
resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.2.0.tgz#756641adb587851b5ccb3e095daf27ae581c8406"
|
||||
integrity sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==
|
||||
|
||||
"@vector-im/compound-design-tokens@^0.1.0":
|
||||
version "0.1.0"
|
||||
resolved "https://registry.yarnpkg.com/@vector-im/compound-design-tokens/-/compound-design-tokens-0.1.0.tgz#1a574fba872ff93b1de8490f475e30b922cd02a2"
|
||||
integrity sha512-vnDrd1CPPR7CwQLss/JnIE1ga6QwmCkhgBvXm1huMhCs7nIiqf90Sbgc0WugbHNaRXGEEhMVGrE69DaQIUcqOA==
|
||||
"@vector-im/compound-design-tokens@^1.3.0":
|
||||
version "1.3.0"
|
||||
resolved "https://registry.yarnpkg.com/@vector-im/compound-design-tokens/-/compound-design-tokens-1.3.0.tgz#1d04f006a9e56b920432095d08d7c84c0933ebc7"
|
||||
integrity sha512-RXcyEAdxNzekMhVuvxtLPt9zb6yT2N+5cnb2Hul9zwRiF7+XEHpD36+IF6V0QOXk2pkN0wOr3jCvc9eOWOq9SQ==
|
||||
dependencies:
|
||||
svg2vectordrawable "^2.9.1"
|
||||
|
||||
"@vector-im/compound-web@^0.9.4":
|
||||
version "0.9.4"
|
||||
resolved "https://registry.yarnpkg.com/@vector-im/compound-web/-/compound-web-0.9.4.tgz#89ac6d136c5f9d553de0e8540398a8a4b6cdeb6f"
|
||||
integrity sha512-L1N0xe3G7k35b3i+5teYg1nplsbz8p+VOxIGWIPU4H7D4PBCxhf9i7ft8aJjLsIdIaInJkqjvKwPU+Yb/yvgUQ==
|
||||
"@vector-im/compound-web@^4.8.0":
|
||||
version "4.8.0"
|
||||
resolved "https://registry.yarnpkg.com/@vector-im/compound-web/-/compound-web-4.8.0.tgz#1fe11d78549694f8d91b40065994bad19a7cebf2"
|
||||
integrity sha512-kyB8wQPbdTUFWIzAbb4HcZ4iisUUpbm0xwmEjV9ZNN1/EIodidW6nLeYATh3Vc1fBvTGTgbFiPc1DiAcBuudiw==
|
||||
dependencies:
|
||||
"@floating-ui/react" "^0.26.9"
|
||||
"@floating-ui/react-dom" "^2.0.8"
|
||||
"@radix-ui/react-context-menu" "^2.1.5"
|
||||
"@radix-ui/react-dropdown-menu" "^2.0.6"
|
||||
"@radix-ui/react-form" "^0.0.3"
|
||||
"@radix-ui/react-separator" "^1.0.3"
|
||||
"@radix-ui/react-slot" "^1.0.2"
|
||||
"@radix-ui/react-tooltip" "^1.0.6"
|
||||
classnames "^2.3.2"
|
||||
graphemer "^1.4.0"
|
||||
vaul "^0.7.0"
|
||||
|
||||
abbrev@1:
|
||||
@ -2859,6 +2918,13 @@ buffer-from@^1.0.0:
|
||||
resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5"
|
||||
integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==
|
||||
|
||||
busboy@^1.6.0:
|
||||
version "1.6.0"
|
||||
resolved "https://registry.yarnpkg.com/busboy/-/busboy-1.6.0.tgz#966ea36a9502e43cdb9146962523b92f531f6893"
|
||||
integrity sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==
|
||||
dependencies:
|
||||
streamsearch "^1.1.0"
|
||||
|
||||
bytes@3.1.2:
|
||||
version "3.1.2"
|
||||
resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5"
|
||||
@ -7429,6 +7495,11 @@ stream-length@^1.0.2:
|
||||
dependencies:
|
||||
bluebird "^2.6.2"
|
||||
|
||||
streamsearch@^1.1.0:
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-1.1.0.tgz#404dd1e2247ca94af554e841a8ef0eaa238da764"
|
||||
integrity sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==
|
||||
|
||||
string-argv@^0.3.1:
|
||||
version "0.3.2"
|
||||
resolved "https://registry.yarnpkg.com/string-argv/-/string-argv-0.3.2.tgz#2b6d0ef24b656274d957d54e0a4bbf6153dc02b6"
|
||||
@ -7442,7 +7513,16 @@ string-length@^4.0.1:
|
||||
char-regex "^1.0.2"
|
||||
strip-ansi "^6.0.0"
|
||||
|
||||
"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
|
||||
"string-width-cjs@npm:string-width@^4.2.0":
|
||||
version "4.2.3"
|
||||
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
|
||||
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
|
||||
dependencies:
|
||||
emoji-regex "^8.0.0"
|
||||
is-fullwidth-code-point "^3.0.0"
|
||||
strip-ansi "^6.0.1"
|
||||
|
||||
string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
|
||||
version "4.2.3"
|
||||
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
|
||||
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
|
||||
@ -7509,7 +7589,14 @@ string_decoder@^1.1.1:
|
||||
dependencies:
|
||||
safe-buffer "~5.2.0"
|
||||
|
||||
"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
|
||||
"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
|
||||
version "6.0.1"
|
||||
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
|
||||
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
|
||||
dependencies:
|
||||
ansi-regex "^5.0.1"
|
||||
|
||||
strip-ansi@^6.0.0, strip-ansi@^6.0.1:
|
||||
version "6.0.1"
|
||||
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
|
||||
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
|
||||
@ -7626,6 +7713,11 @@ svgpath@^2.5.0:
|
||||
resolved "https://registry.yarnpkg.com/svgpath/-/svgpath-2.6.0.tgz#5b160ef3d742b7dfd2d721bf90588d3450d7a90d"
|
||||
integrity sha512-OIWR6bKzXvdXYyO4DK/UWa1VA1JeKq8E+0ug2DG98Y/vOmMpfZNj+TIG988HjfYSqtcy/hFOtZq/n/j5GSESNg==
|
||||
|
||||
tabbable@^6.0.0:
|
||||
version "6.2.0"
|
||||
resolved "https://registry.yarnpkg.com/tabbable/-/tabbable-6.2.0.tgz#732fb62bc0175cfcec257330be187dcfba1f3b97"
|
||||
integrity sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==
|
||||
|
||||
tdigest@^0.1.1:
|
||||
version "0.1.2"
|
||||
resolved "https://registry.yarnpkg.com/tdigest/-/tdigest-0.1.2.tgz#96c64bac4ff10746b910b0e23b515794e12faced"
|
||||
@ -8126,7 +8218,7 @@ workerpool@6.2.1:
|
||||
resolved "https://registry.yarnpkg.com/workerpool/-/workerpool-6.2.1.tgz#46fc150c17d826b86a008e5a4508656777e9c343"
|
||||
integrity sha512-ILEIE97kDZvF9Wb9f6h5aXK4swSlKGUcOEGiIYb2OOu/IrDU9iwj0fD//SsA6E5ibwJxpEvhullJY4Sl4GcpAw==
|
||||
|
||||
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0:
|
||||
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
|
||||
version "7.0.0"
|
||||
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
|
||||
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
|
||||
@ -8144,6 +8236,15 @@ wrap-ansi@^6.2.0:
|
||||
string-width "^4.1.0"
|
||||
strip-ansi "^6.0.0"
|
||||
|
||||
wrap-ansi@^7.0.0:
|
||||
version "7.0.0"
|
||||
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
|
||||
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
|
||||
dependencies:
|
||||
ansi-styles "^4.0.0"
|
||||
string-width "^4.1.0"
|
||||
strip-ansi "^6.0.0"
|
||||
|
||||
wrap-ansi@^8.1.0:
|
||||
version "8.1.0"
|
||||
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"
|
||||
|
Loading…
x
Reference in New Issue
Block a user