({
+ eventType: 'm.room.message', sender: testEnv.botMxid, roomId: testRoomId, body: 'Hello world!'
+ });
+
+ // Send a webhook
+ await fetch(webhookUrl, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({text: 'Hello world!'})
+ });
+
+ // And await the notice.
+ await webhookNotice;
+ });
+});
diff --git a/spec/generic-hooks.spec.ts b/spec/generic-hooks.spec.ts
new file mode 100644
index 000000000..8b1f266b4
--- /dev/null
+++ b/spec/generic-hooks.spec.ts
@@ -0,0 +1,119 @@
+import { E2ESetupTestTimeout, E2ETestEnv, E2ETestMatrixClient } from "./util/e2e-test";
+import { describe, it } from "@jest/globals";
+import { GenericHookConnection } from "../src/Connections";
+import { TextualMessageEventContent } from "matrix-bot-sdk";
+import { add } from "date-fns/add";
+
+async function createInboundConnection(user: E2ETestMatrixClient, botMxid: string, roomId: string, duration?: string) {
+ const join = user.waitForRoomJoin({ sender: botMxid, roomId });
+ const connectionEvent = user.waitForRoomEvent({
+ eventType: GenericHookConnection.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 webhook test' + (duration ? ` ${duration}` : ""));
+ // 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 msgContent = msgData.content as unknown as TextualMessageEventContent;
+ const [_unused1, _unused2, url] = msgContent.body.split('\n');
+ return url;
+}
+
+describe('Inbound (Generic) Webhooks', () => {
+ let testEnv: E2ETestEnv;
+
+ beforeAll(async () => {
+ const webhooksPort = 9500 + E2ETestEnv.workerId;
+ testEnv = await E2ETestEnv.createTestEnv({
+ matrixLocalparts: ['user'],
+ config: {
+ generic: {
+ enabled: true,
+ // Prefer to wait for complete as it reduces the concurrency of the test.
+ waitForComplete: 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 handle an incoming request.', async () => {
+ const user = testEnv.getUser('user');
+ const roomId = await user.createRoom({ name: 'My Test Webhooks room'});
+ const okMsg = user.waitForRoomEvent({ eventType: "m.room.message", sender: testEnv.botMxid, roomId });
+ const url = await createInboundConnection(user, testEnv.botMxid, roomId);
+ expect((await okMsg).data.content.body).toEqual('Room configured to bridge webhooks. See admin room for secret url.');
+
+ const expectedMsg = user.waitForRoomEvent({ eventType: "m.room.message", sender: testEnv.botMxid, roomId });
+ const req = await fetch(url, {
+ method: "PUT",
+ body: "Hello world"
+ });
+ expect(req.status).toEqual(200);
+ expect(await req.json()).toEqual({ ok: true });
+ expect((await expectedMsg).data.content).toEqual({
+ msgtype: 'm.notice',
+ body: 'Received webhook data: Hello world',
+ formatted_body: 'Received webhook data: Hello world
',
+ format: 'org.matrix.custom.html',
+ 'uk.half-shot.hookshot.webhook_data': 'Hello world'
+ });
+ });
+
+ it('should be able to create a new expiring webhook and handle valid requests.', async () => {
+ jest.useFakeTimers();
+ const user = testEnv.getUser('user');
+ const roomId = await user.createRoom({ name: 'My Test Webhooks room'});
+ const okMsg = user.waitForRoomEvent({ eventType: "m.room.message", sender: testEnv.botMxid, roomId });
+ const url = await createInboundConnection(user, testEnv.botMxid, roomId, '2h');
+ expect((await okMsg).data.content.body).toEqual('Room configured to bridge webhooks. See admin room for secret url.');
+
+ const expectedMsg = user.waitForRoomEvent({ eventType: "m.room.message", sender: testEnv.botMxid, roomId });
+ const req = await fetch(url, {
+ method: "PUT",
+ body: "Hello world"
+ });
+ expect(req.status).toEqual(200);
+ expect(await req.json()).toEqual({ ok: true });
+ expect((await expectedMsg).data.content).toEqual({
+ msgtype: 'm.notice',
+ body: 'Received webhook data: Hello world',
+ formatted_body: 'Received webhook data: Hello world
',
+ format: 'org.matrix.custom.html',
+ 'uk.half-shot.hookshot.webhook_data': 'Hello world'
+ });
+ jest.setSystemTime(add(new Date(), { hours: 3 }));
+ const expiredReq = await fetch(url, {
+ method: "PUT",
+ body: "Hello world"
+ });
+ expect(expiredReq.status).toEqual(404);
+ expect(await expiredReq.json()).toEqual({
+ ok: false,
+ error: "This hook has expired",
+ });
+ });
+});
diff --git a/spec/setup-jest.ts b/spec/setup-jest.ts
new file mode 100644
index 000000000..a5481961e
--- /dev/null
+++ b/spec/setup-jest.ts
@@ -0,0 +1,2 @@
+// In CI, the network creation for the homerunner containers can race (https://github.com/matrix-org/complement/issues/720).
+jest.retryTimes(process.env.CI ? 3 : 1);
\ No newline at end of file
diff --git a/spec/util/e2e-test.ts b/spec/util/e2e-test.ts
index 20cf38c25..7d842c7b3 100644
--- a/spec/util/e2e-test.ts
+++ b/spec/util/e2e-test.ts
@@ -5,13 +5,24 @@ import { BridgeConfig, BridgeConfigRoot } from "../../src/config/Config";
import { start } from "../../src/App/BridgeApp";
import { RSAKeyPairOptions, generateKeyPair } from "node:crypto";
import path from "node:path";
+import Redis from "ioredis";
-const WAIT_EVENT_TIMEOUT = 10000;
+const WAIT_EVENT_TIMEOUT = 20000;
export const E2ESetupTestTimeout = 60000;
+const REDIS_DATABASE_URI = process.env.HOOKSHOT_E2E_REDIS_DB_URI ?? "redis://localhost:6379";
interface Opts {
matrixLocalparts?: string[];
config?: Partial,
+ enableE2EE?: boolean,
+ useRedis?: boolean,
+}
+
+interface WaitForEventResponse> {
+ roomId: string,
+ data: {
+ sender: string, type: string, state_key?: string, content: T, event_id: string,
+ }
}
export class E2ETestMatrixClient extends MatrixClient {
@@ -55,13 +66,10 @@ export class E2ETestMatrixClient extends MatrixClient {
}, `Timed out waiting for powerlevel from in ${roomId}`)
}
- public async waitForRoomEvent>(
- opts: {eventType: string, sender: string, roomId?: string, stateKey?: string, body?: string}
- ): Promise<{roomId: string, data: {
- sender: string, type: string, state_key?: string, content: T, event_id: string,
- }}> {
- const {eventType, sender, roomId, stateKey} = opts;
- return this.waitForEvent('room.event', (eventRoomId: string, eventData: {
+ private async innerWaitForRoomEvent>(
+ {eventType, sender, roomId, stateKey, eventId, body}: {eventType: string, sender: string, roomId?: string, stateKey?: string, body?: string, eventId?: string}, expectEncrypted: boolean,
+ ): Promise> {
+ return this.waitForEvent(expectEncrypted ? 'room.decrypted_event' : 'room.event', (eventRoomId: string, eventData: {
sender: string, type: string, state_key?: string, content: T, event_id: string,
}) => {
if (eventData.sender !== sender) {
@@ -73,21 +81,36 @@ export class E2ETestMatrixClient extends MatrixClient {
if (roomId && eventRoomId !== roomId) {
return undefined;
}
+ if (eventId && eventData.event_id !== eventId) {
+ return undefined;
+ }
if (stateKey !== undefined && eventData.state_key !== stateKey) {
return undefined;
}
- const body = 'body' in eventData.content && eventData.content.body;
- if (opts.body && body !== opts.body) {
+ const evtBody = 'body' in eventData.content && eventData.content.body;
+ if (body && body !== evtBody) {
return undefined;
}
console.info(
// eslint-disable-next-line max-len
- `${eventRoomId} ${eventData.event_id} ${eventData.type} ${eventData.sender} ${eventData.state_key ?? body ?? ''}`
+ `${eventRoomId} ${eventData.event_id} ${eventData.type} ${eventData.sender} ${eventData.state_key ?? evtBody ?? ''}`
);
return {roomId: eventRoomId, data: eventData};
}, `Timed out waiting for ${eventType} from ${sender} in ${roomId || "any room"}`)
}
+ public async waitForRoomEvent>(
+ opts: Parameters[0]
+ ): Promise> {
+ return this.innerWaitForRoomEvent(opts, false);
+ }
+
+ public async waitForEncryptedEvent>(
+ opts: Parameters[0]
+ ): Promise> {
+ return this.innerWaitForRoomEvent(opts, true);
+ }
+
public async waitForRoomJoin(
opts: {sender: string, roomId?: string}
): Promise<{roomId: string, data: unknown}> {
@@ -173,10 +196,11 @@ export class E2ETestEnv {
if (err) { reject(err) } else { resolve(privateKey) }
}));
+ const dir = await mkdtemp('hookshot-int-test');
+
// Configure homeserver and bots
- const [homeserver, dir, privateKey] = await Promise.all([
- createHS([...matrixLocalparts || []], workerID),
- mkdtemp('hookshot-int-test'),
+ const [homeserver, privateKey] = await Promise.all([
+ createHS([...matrixLocalparts || []], workerID, opts.enableE2EE ? path.join(dir, 'client-crypto') : undefined),
keyPromise,
]);
const keyPath = path.join(dir, 'key.pem');
@@ -193,6 +217,15 @@ export class E2ETestEnv {
providedConfig.github.auth.privateKeyFile = keyPath;
}
+ opts.useRedis = opts.enableE2EE || opts.useRedis;
+
+ let cacheConfig: BridgeConfigRoot["cache"]|undefined;
+ if (opts.useRedis) {
+ cacheConfig = {
+ redisUri: `${REDIS_DATABASE_URI}/${workerID}`,
+ }
+ }
+
const config = new BridgeConfig({
bridge: {
domain: homeserver.domain,
@@ -201,10 +234,7 @@ export class E2ETestEnv {
bindAddress: '0.0.0.0',
},
logging: {
- level: 'info',
- },
- queue: {
- monolithic: true,
+ level: 'debug',
},
// Always enable webhooks so that hookshot starts.
generic: {
@@ -217,6 +247,12 @@ export class E2ETestEnv {
resources: ['webhooks'],
}],
passFile: keyPath,
+ ...(opts.enableE2EE ? {
+ encryption: {
+ storagePath: path.join(dir, 'crypto-store'),
+ }
+ } : undefined),
+ cache: cacheConfig,
...providedConfig,
});
const registration: IAppserviceRegistration = {
@@ -230,7 +266,8 @@ export class E2ETestEnv {
}],
rooms: [],
aliases: [],
- }
+ },
+ "de.sorunome.msc2409.push_ephemeral": true
};
const app = await start(config, registration);
app.listener.finaliseListeners();
@@ -258,6 +295,12 @@ export class E2ETestEnv {
await this.app.bridgeApp.stop();
await this.app.listener.stop();
await this.app.storage.disconnect?.();
+
+ // Clear the redis DB.
+ if (this.config.cache?.redisUri) {
+ await new Redis(this.config.cache.redisUri).flushdb();
+ }
+
this.homeserver.users.forEach(u => u.client.stop());
await destroyHS(this.homeserver.id);
await rm(this.dir, { recursive: true });
diff --git a/spec/util/fixtures.ts b/spec/util/fixtures.ts
new file mode 100644
index 000000000..ae6de768c
--- /dev/null
+++ b/spec/util/fixtures.ts
@@ -0,0 +1 @@
+export const TEST_FILE = Buffer.from(`PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4NCjwhLS0gR2VuZXJhdG9yOiBBZG9iZSBJbGx1c3RyYXRvciAxMy4wLjAsIFNWRyBFeHBvcnQgUGx1Zy1JbiAuIFNWRyBWZXJzaW9uOiA2LjAwIEJ1aWxkIDE0NTc2KSAgLS0+DQo8IURPQ1RZUEUgc3ZnIFBVQkxJQyAiLS8vVzNDLy9EVEQgU1ZHIDEuMS8vRU4iICJodHRwOi8vd3d3LnczLm9yZy9HcmFwaGljcy9TVkcvMS4xL0RURC9zdmcxMS5kdGQiPg0KPHN2ZyB2ZXJzaW9uPSIxLjEiIGlkPSJMYXllcl8xIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB4PSIwcHgiIHk9IjBweCINCgkgd2lkdGg9Ijc5My4zMjJweCIgaGVpZ2h0PSIzNDAuODA5cHgiIHZpZXdCb3g9IjAgMCA3OTMuMzIyIDM0MC44MDkiIGVuYWJsZS1iYWNrZ3JvdW5kPSJuZXcgMCAwIDc5My4zMjIgMzQwLjgwOSINCgkgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+DQo8cGF0aCBvcGFjaXR5PSIwLjUiIGZpbGw9IiNGRkZGRkYiIGQ9Ik0zNC4wMDQsMzQwLjgwOUgyYy0xLjEwNCwwLTItMC44OTYtMi0yVjJjMC0xLjEwNCwwLjg5Ni0yLDItMmgzMi4wMDRjMS4xMDQsMCwyLDAuODk2LDIsMg0KCXY3LjcxYzAsMS4xMDQtMC44OTYsMi0yLDJoLTIxLjEzdjMxNy4zODZoMjEuMTNjMS4xMDQsMCwyLDAuODk2LDIsMi4wMDF2Ny43MTJDMzYuMDA0LDMzOS45MTMsMzUuMTA4LDM0MC44MDksMzQuMDA0LDM0MC44MDkNCglMMzQuMDA0LDM0MC44MDl6Ii8+DQo8cGF0aCBvcGFjaXR5PSIwLjUiIGZpbGw9IiNGRkZGRkYiIGQ9Ik0xMC44NzUsOS43MTF2MzIxLjM4NmgyMy4xM3Y3LjcxMUgxLjk5OVYyLjAwMWgzMi4wMDZ2Ny43MUgxMC44NzV6Ii8+DQo8cGF0aCBvcGFjaXR5PSIwLjUiIGZpbGw9IiNGRkZGRkYiIGQ9Ik0yNTIuNDAyLDIzMy43MTFoLTMyLjk5M2MtMS4xMDQsMC0yLTAuODk2LTItMnYtNjguMDczYzAtMy45NDktMC4xNTQtNy43MjItMC40NTctMTEuMjEzDQoJYy0wLjI4OS0zLjI4Mi0xLjA3NC02LjE1My0yLjMzMi04LjUzYy0xLjIwNC0yLjI3Ni0zLjAxNy00LjExOS01LjM4NC01LjQ3NmMtMi4zOTMtMS4zNjItNS43NzUtMi4wNTYtMTAuMDQyLTIuMDU2DQoJYy00LjIzOCwwLTcuNjc0LDAuNzk4LTEwLjIxMywyLjM3MWMtMi41NjUsMS41OTYtNC42MDQsMy43MDEtNi4wNTMsNi4yNThjLTEuNDk4LDIuNjQzLTIuNTEsNS42OTQtMy4wMTMsOS4wNjcNCgljLTAuNTI2LDMuNTEzLTAuNzkzLDcuMTI1LTAuNzkzLDEwLjc0MXY2Ni45MWMwLDEuMTA0LTAuODk2LDItMiwyaC0zMi45OTFjLTEuMTA0LDAtMi0wLjg5Ni0yLTJ2LTY3LjM3Mw0KCWMwLTMuNDM1LTAuMDc4LTYuOTY0LTAuMjI4LTEwLjQ4NWMtMC4xNDgtMy4yNTEtMC43NjctNi4yNzgtMS44NDEtOC45OTVjLTEuMDE4LTIuNTcxLTIuNjY3LTQuNTg0LTUuMDQ3LTYuMTUzDQoJYy0yLjM3Mi0xLjU1Mi02LjAyOS0yLjM0MS0xMC44NjUtMi4zNDFjLTEuMzcyLDAtMy4yNjUsMC4zMjgtNS42MjksMC45NzZjLTIuMjgsMC42MjQtNC41MzYsMS44MjYtNi43MDUsMy41NzcNCgljLTIuMTUyLDEuNzMyLTQuMDM2LDQuMzA2LTUuNjA1LDcuNjU1Yy0xLjU2OSwzLjM1Ni0yLjM2Nyw3Ljg3Ny0yLjM2NywxMy40Mzh2NjkuNzAxYzAsMS4xMDQtMC44OTUsMi0yLDJINjguODU3DQoJYy0xLjEwNCwwLTItMC44OTYtMi0yVjExMS41OTRjMC0xLjEwNCwwLjg5Ni0xLjk5OSwyLTEuOTk5aDMxLjEzYzEuMTA0LDAsMiwwLjg5NiwyLDEuOTk5djExLjAwNw0KCWMzLjgzNC00LjQ5OSw4LjI0OC04LjE1MiwxMy4xNzMtMTAuODk2YzYuMzk2LTMuNTU5LDEzLjc5OS01LjM2MiwyMi4wMDItNS4zNjJjNy44NDYsMCwxNS4xMjcsMS41NDgsMjEuNjQyLDQuNjA0DQoJYzUuNzk0LDIuNzIyLDEwLjQyNCw3LjI2LDEzLjc5MSwxMy41MmMzLjQ0OS00LjM2Miw3LjgzMy04LjMwNiwxMy4wNzEtMTEuNzUyYzYuNDIyLTQuMjI4LDE0LjEwMi02LjM3MSwyMi44MjQtNi4zNzENCgljNi40OTksMCwxMi42MjUsMC44MDcsMTguMjA5LDIuMzk5YzUuNjg2LDEuNjI4LDEwLjYzNSw0LjI3MSwxNC43MTIsNy44NTdjNC4wODgsMy42MDUsNy4zMTgsOC4zNTcsOS42MDEsMTQuMTIzDQoJYzIuMjUsNS43MTksMy4zOTEsMTIuNjQ5LDMuMzkxLDIwLjYwNHY4MC4zODRDMjU0LjQwMiwyMzIuODE1LDI1My41MDcsMjMzLjcxMSwyNTIuNDAyLDIzMy43MTFMMjUyLjQwMiwyMzMuNzExeiIvPg0KPHBhdGggb3BhY2l0eT0iMC41IiBmaWxsPSIjRkZGRkZGIiBkPSJNOTkuOTg4LDExMS41OTV2MTYuMjY0aDAuNDYzYzQuMzM4LTYuMTkxLDkuNTYzLTEwLjk5OCwxNS42ODQtMTQuNDA2DQoJYzYuMTE3LTMuNDAyLDEzLjEyOS01LjExLDIxLjAyNy01LjExYzcuNTg4LDAsMTQuNTIxLDEuNDc1LDIwLjc5Myw0LjQxNWM2LjI3NCwyLjk0NSwxMS4wMzgsOC4xMzEsMTQuMjkxLDE1LjU2Nw0KCWMzLjU2LTUuMjY1LDguNC05LjkxMywxNC41MjEtMTMuOTRjNi4xMTctNC4wMjUsMTMuMzU4LTYuMDQyLDIxLjcyNC02LjA0MmM2LjM1MSwwLDEyLjIzNCwwLjc3NiwxNy42NiwyLjMyNQ0KCWM1LjQxOCwxLjU0OSwxMC4wNjUsNC4wMjcsMTMuOTM4LDcuNDM0YzMuODY5LDMuNDEsNi44ODksNy44NjMsOS4wNjIsMTMuMzU3YzIuMTY3LDUuNTA0LDMuMjUzLDEyLjEyMiwzLjI1MywxOS44Njl2ODAuMzg1SDIxOS40MQ0KCXYtNjguMDc0YzAtNC4wMjUtMC4xNTQtNy44Mi0wLjQ2NS0xMS4zODVjLTAuMzEzLTMuNTYtMS4xNjEtNi42NTYtMi41NTUtOS4yOTNjLTEuMzk1LTIuNjMxLTMuNDUtNC43MjQtNi4xNTctNi4yNzQNCgljLTIuNzExLTEuNTQzLTYuMzkxLTIuMzIyLTExLjAzNy0yLjMyMnMtOC40MDMsMC44OTYtMTEuMjY5LDIuNjcxYy0yLjg2OCwxLjc4NC01LjExMiw0LjEwOS02LjczNyw2Ljk3MQ0KCWMtMS42MjYsMi44NjktMi43MTEsNi4xMi0zLjI1Miw5Ljc2MmMtMC41NDUsMy42MzgtMC44MTQsNy4zMTgtMC44MTQsMTEuMDM1djY2LjkxaC0zMi45OTF2LTY3LjM3NWMwLTMuNTYyLTAuMDgxLTcuMDg3LTAuMjMtMTAuNTcNCgljLTAuMTU4LTMuNDg3LTAuODE0LTYuNy0xLjk3OC05LjY0NWMtMS4xNjItMi45NC0zLjA5OS01LjMwNC01LjgwOS03LjA4OGMtMi43MTEtMS43NzUtNi42OTktMi42NzEtMTEuOTY1LTIuNjcxDQoJYy0xLjU1MSwwLTMuNjAzLDAuMzQ5LTYuMTU2LDEuMDQ4Yy0yLjU1NiwwLjY5Ny01LjAzNiwyLjAxNi03LjQzNSwzLjk0OWMtMi40MDQsMS45MzgtNC40NTQsNC43MjYtNi4xNTgsOC4zNjMNCgljLTEuNzA1LDMuNjQyLTIuNTU2LDguNDAyLTIuNTU2LDE0LjI4N3Y2OS43MDFoLTMyLjk5VjExMS41OTVIOTkuOTg4eiIvPg0KPHBhdGggb3BhY2l0eT0iMC41IiBmaWxsPSIjRkZGRkZGIiBkPSJNMzA0LjkwOSwyMzYuNzMzYy01Ljg4MywwLTExLjQ2LTAuNzI5LTE2LjU3NC0yLjE2M2MtNS4xOTItMS40NjQtOS44MDYtMy43NzQtMTMuNzEzLTYuODcxDQoJYy0zLjk0NC0zLjExNy03LjA2OC03LjExMS05LjI4Mi0xMS44NzFjLTIuMjA1LTQuNzMzLTMuMzI0LTEwLjQxMi0zLjMyNC0xNi44NzZjMC03LjEzLDEuMjkzLTEzLjExNywzLjg0Ni0xNy43OTcNCgljMi41NDItNC42NzQsNS44NzctOC40NjQsOS45MTItMTEuMjYzYzMuOTctMi43NTIsOC41NTYtNC44NDIsMTMuNjMtNi4yMDljNC45MDEtMS4zMjIsOS45MzctMi4zOTQsMTQuOTYxLTMuMTg0DQoJYzQuOTg2LTAuNzc1LDkuOTQ5LTEuNDA0LDE0Ljc1NC0xLjg3MmM0LjY3OS0wLjQ1Miw4Ljg4LTEuMTM5LDEyLjQ4OS0yLjAzOWMzLjQxMi0wLjg1NCw2LjExOC0yLjA5LDguMDQyLTMuNjcyDQoJYzEuNjY2LTEuMzcsMi40MTYtMy4zODQsMi4yOTItNi4xNTFjLTAuMDAyLTMuMjg5LTAuNTAyLTUuODE2LTEuNDkyLTcuNTk1Yy0wLjk5OC0xLjc5OC0yLjI4My0zLjE1LTMuOTI3LTQuMTM4DQoJYy0xLjcwMy0xLjAyLTMuNzI1LTEuNzEzLTYuMDEyLTIuMDYyYy0yLjQ3LTAuMzctNS4xNDYtMC41NTctNy45NDctMC41NTdjLTYuMDM0LDAtMTAuNzg5LDEuMjcxLTE0LjEzNSwzLjc4Mw0KCWMtMy4yMzMsMi40MjQtNS4xNTUsNi42NC01LjcxNCwxMi41MjdjLTAuMDk4LDEuMDI2LTAuOTYxLDEuODEyLTEuOTkyLDEuODEyaC0zMi45OTJjLTAuNTUyLDAtMS4wNzktMC4yMjktMS40NTctMC42MjkNCgljLTAuMzc2LTAuNDAyLTAuNTcyLTAuOTQxLTAuNTQtMS40OTFjMC40ODUtOC4wNzMsMi41NS0xNC44OTQsNi4xNDItMjAuMjcyYzMuNTQ4LTUuMzMxLDguMTQ3LTkuNjgyLDEzLjY2MS0xMi45MzENCgljNS40MjQtMy4xOTEsMTEuNjEyLTUuNDk4LDE4LjM5Mi02Ljg1N2M2LjY4NC0xLjMzNSwxMy41LTIuMDEzLDIwLjI2LTIuMDEzYzYuMDk2LDAsMTIuMzY1LDAuNDM3LDE4LjYyNiwxLjI5Ng0KCWM2LjM3NywwLjg4LDEyLjI4NSwyLjYyMiwxNy41NjIsNS4xNzdjNS4zNzYsMi42MDQsOS44NDUsNi4yOSwxMy4yODIsMTAuOTUxYzMuNDk4LDQuNzQ0LDUuMjcxLDExLjA0OCw1LjI3MSwxOC43MzF2NjIuNDk0DQoJYzAsNS4zMDcsMC4zMDYsMTAuNDYyLDAuOTE1LDE1LjMxOWMwLjU3Niw0LjY0LDEuNTcyLDguMTE2LDIuOTYzLDEwLjMzOGMwLjM4NSwwLjYxNiwwLjQwNywxLjM5NSwwLjA1NSwyLjAzMQ0KCWMtMC4zNTMsMC42MzUtMS4wMjIsMS4wMy0xLjc1LDEuMDNoLTMzLjQ1N2MtMC44NjEsMC0xLjYyNC0wLjU1LTEuODk4LTEuMzY3Yy0wLjY0Ni0xLjk0MS0xLjE3Ni0zLjkzOS0xLjU3Mi01LjkzNg0KCWMtMC4xNDEtMC42OTYtMC4yNjctMS40MDItMC4zOC0yLjEyYy00LjgyNSw0LjE4NC0xMC4zNDksNy4yNC0xNi40NzQsOS4xMDVDMzIwLjAzMywyMzUuNjA5LDMxMi40ODksMjM2LjczMywzMDQuOTA5LDIzNi43MzMNCglMMzA0LjkwOSwyMzYuNzMzeiBNMzQxLjk0MSwxNzYuNjYxYy0wLjgwOSwwLjQwOS0xLjY3NiwwLjc2OC0yLjU5NiwxLjA3NGMtMi4xNjEsMC43Mi00LjUxMSwxLjMyNi02Ljk4OCwxLjgwNw0KCWMtMi40NDIsMC40NzUtNS4wMzMsMC44NzItNy42OTksMS4xODZjLTIuNjMxLDAuMzExLTUuMjUxLDAuNjk3LTcuNzg0LDEuMTQ2Yy0yLjMyOSwwLjQzMy00LjcwNSwxLjAzNS03LjA1MSwxLjc5Mg0KCWMtMi4xOTQsMC43MTEtNC4xMTQsMS42NjctNS42OTksMi44NDJjLTEuNTMxLDEuMTI4LTIuNzg1LDIuNTg3LTMuNzMxLDQuMzM1Yy0wLjkxNywxLjcwOS0xLjM4NSwzLjk3LTEuMzg1LDYuNzE5DQoJYzAsMi41OTgsMC40NjUsNC43NzgsMS4zODUsNi40ODFjMC45MjgsMS43MjIsMi4xNDIsMy4wMzUsMy43MTYsNC4wMThjMS42NDQsMS4wMjYsMy42MDEsMS43NTcsNS44MTYsMi4xNw0KCWMyLjM0NCwwLjQzOSw0Ljc5OSwwLjY2Myw3LjI5NywwLjY2M2M2LjEwNSwwLDEwLjgzNi0wLjk5NiwxNC4wNjMtMi45NjFjMy4yNDQtMS45NzMsNS42NjYtNC4zNDksNy4xOTktNy4wNjINCgljMS41NjgtMi43OCwyLjU0Mi01LjYyLDIuODkyLTguNDM2YzAuMzc2LTMuMDE5LDAuNTY1LTUuNDM2LDAuNTY1LTcuMTg3VjE3Ni42NjFMMzQxLjk0MSwxNzYuNjYxeiIvPg0KPHBhdGggb3BhY2l0eT0iMC41IiBmaWxsPSIjRkZGRkZGIiBkPSJNMjczLjU0NCwxMjkuMjU1YzMuNDA1LTUuMTEzLDcuNzQ0LTkuMjE1LDEzLjAxMi0xMi4zMTYNCgljNS4yNjQtMy4wOTcsMTEuMTg2LTUuMzAzLDE3Ljc3MS02LjYyMWM2LjU4Mi0xLjMxNSwxMy4yMDUtMS45NzYsMTkuODY1LTEuOTc2YzYuMDQyLDAsMTIuMTU4LDAuNDI4LDE4LjM1NCwxLjI3Nw0KCWM2LjE5NSwwLjg1NSwxMS44NSwyLjUyMiwxNi45NjIsNC45OTdjNS4xMTEsMi40NzcsOS4yOTIsNS45MjYsMTIuNTQ2LDEwLjMzOGMzLjI1Myw0LjQxNCw0Ljg3OSwxMC4yNjIsNC44NzksMTcuNTQzdjYyLjQ5NA0KCWMwLDUuNDI4LDAuMzEsMTAuNjExLDAuOTMxLDE1LjU2N2MwLjYxNSw0Ljk1OSwxLjcwMSw4LjY3NiwzLjI1MSwxMS4xNTNIMzQ3LjY2Yy0wLjYyMS0xLjg2LTEuMTI2LTMuNzU1LTEuNTExLTUuNjkzDQoJYy0wLjM5LTEuOTMzLTAuNjYxLTMuOTA4LTAuODEzLTUuOTIzYy01LjI2Nyw1LjQyMi0xMS40NjUsOS4yMTctMTguNTg1LDExLjM4NmMtNy4xMjcsMi4xNjMtMTQuNDA3LDMuMjUxLTIxLjg0MiwzLjI1MQ0KCWMtNS43MzMsMC0xMS4wNzctMC42OTgtMTYuMDMzLTIuMDljLTQuOTU4LTEuMzk1LTkuMjkzLTMuNTYyLTEzLjAxLTYuNTFjLTMuNzE4LTIuOTM4LTYuNjIyLTYuNjU2LTguNzEzLTExLjE0Nw0KCXMtMy4xMzgtOS44NC0zLjEzOC0xNi4wMzNjMC02LjgxMywxLjE5OS0xMi40MywzLjYwNC0xNi44NGMyLjM5OS00LjQxNyw1LjQ5NS03LjkzOSw5LjI5NS0xMC41NzUNCgljMy43OTMtMi42MzIsOC4xMjktNC42MDcsMTMuMDEtNS45MjNjNC44NzgtMS4zMTUsOS43OTUtMi4zNTgsMTQuNzUyLTMuMTM3YzQuOTU3LTAuNzcyLDkuODM1LTEuMzkzLDE0LjYzOC0xLjg1Nw0KCWM0LjgwMS0wLjQ2Niw5LjA2Mi0xLjE2NCwxMi43NzktMi4wOTNjMy43MTgtMC45MjksNi42NTgtMi4yODIsOC44MjktNC4wNjVjMi4xNjUtMS43ODEsMy4xNzItNC4zNzUsMy4wMi03Ljc4NQ0KCWMwLTMuNTYtMC41OC02LjM4OS0xLjc0Mi04LjQ3OWMtMS4xNjEtMi4wOS0yLjcxMS0zLjcxOS00LjY0Ni00Ljg4Yy0xLjkzNy0xLjE2MS00LjE4My0xLjkzNi02LjczNy0yLjMyNQ0KCWMtMi41NTctMC4zODItNS4zMDktMC41OC04LjI0OC0wLjU4Yy02LjUwNiwwLTExLjYxNywxLjM5NS0xNS4zMzUsNC4xODNjLTMuNzE2LDIuNzg4LTUuODg5LDcuNDM3LTYuNTA2LDEzLjk0aC0zMi45OTENCglDMjY4LjE5OSwxNDAuNzk0LDI3MC4xMzIsMTM0LjM2MywyNzMuNTQ0LDEyOS4yNTV6IE0zMzguNzEzLDE3NS44MzhjLTIuMDksMC42OTYtNC4zMzcsMS4yNzUtNi43MzYsMS43NDENCgljLTIuNDAyLDAuNDY1LTQuOTE4LDAuODUzLTcuNTUxLDEuMTYxYy0yLjYzNSwwLjMxMy01LjI2OCwwLjY5OC03Ljg5OSwxLjE2M2MtMi40OCwwLjQ2MS00LjkxOSwxLjA4Ni03LjMxNywxLjg1Nw0KCWMtMi40MDQsMC43NzktNC40OTUsMS44MjItNi4yNzQsMy4xMzhjLTEuNzg0LDEuMzE3LTMuMjE2LDIuOTg1LTQuMyw0Ljk5NGMtMS4wODUsMi4wMTQtMS42MjYsNC41NzEtMS42MjYsNy42NjgNCgljMCwyLjk0LDAuNTQxLDUuNDIyLDEuNjI2LDcuNDMxYzEuMDg0LDIuMDE3LDIuNTU4LDMuNjA0LDQuNDE2LDQuNzY1czQuMDI1LDEuOTc2LDYuNTA3LDIuNDM4YzIuNDc1LDAuNDY2LDUuMDMxLDAuNjk4LDcuNjY1LDAuNjk4DQoJYzYuNTA1LDAsMTEuNTM3LTEuMDgyLDE1LjEwMy0zLjI1M2MzLjU2MS0yLjE2Niw2LjE5Mi00Ljc2Miw3Ljg5OS03Ljc4NWMxLjcwMi0zLjAxOSwyLjc0OS02LjA3MiwzLjEzNy05LjE3NA0KCWMwLjM4NC0zLjA5NywwLjU4LTUuNTc2LDAuNTgtNy40MzR2LTEyLjMxNkMzNDIuNTQ3LDE3NC4xNzMsMzQwLjgwNSwxNzUuMTQsMzM4LjcxMywxNzUuODM4eiIvPg0KPHBhdGggb3BhY2l0eT0iMC41IiBmaWxsPSIjRkZGRkZGIiBkPSJNNDQ0LjU0MiwyMzQuODc0Yy01LjE4NywwLTEwLjE3My0wLjM2MS0xNC44MjMtMS4wNjljLTQuODAyLTAuNzMyLTkuMTA0LTIuMTgzLTEyLjc3OS00LjMxMw0KCWMtMy43ODktMi4xODUtNi44MjEtNS4zNDEtOS4wMDYtOS4zNzVjLTIuMTYzLTMuOTg2LTMuMjYtOS4yMzItMy4yNi0xNS41OXYtNjguODU5aC0xNy45ODFjLTEuMTA0LDAtMi0wLjg5Ni0yLTEuOTk5di0yMi4wNzMNCgljMC0xLjEwNCwwLjg5Ni0xLjk5OSwyLTEuOTk5aDE3Ljk4MVY3NS41ODJjMC0xLjEwNCwwLjg5Ni0yLDItMmgzMi45OTJjMS4xMDQsMCwyLDAuODk2LDIsMnYzNC4wMTRoMjIuMTYyYzEuMTA0LDAsMiwwLjg5NiwyLDEuOTk5DQoJdjIyLjA3M2MwLDEuMTA0LTAuODk2LDEuOTk5LTIsMS45OTloLTIyLjE2MnY1Ny40NzljMCw2LjIyOSwxLjE5OCw4LjczMSwyLjIwMiw5LjczM2MxLjAwNCwxLjAwNywzLjUwNiwyLjIwNSw5LjczOCwyLjIwNQ0KCWMxLjgwNCwwLDMuNTQyLTAuMDc2LDUuMTYxLTAuMjI1YzEuNjA0LTAuMTQ0LDMuMTc0LTAuMzY3LDQuNjY5LTAuNjY1YzAuMTMtMC4wMjYsMC4yNjEtMC4wMzksMC4zOTEtMC4wMzkNCgljMC40NTgsMCwwLjkwNywwLjE1OSwxLjI3LDAuNDU0YzAuNDYzLDAuMzc5LDAuNzMsMC45NDYsMC43MywxLjU0NnYyNS41NTVjMCwwLjk3OS0wLjcwNywxLjgxMy0xLjY3MiwxLjk3NA0KCWMtMi44MzQsMC40NzItNi4wNDEsMC43OTQtOS41MjcsMC45NTdDNDUxLjAxNSwyMzQuNzk4LDQ0Ny43MTgsMjM0Ljg3NCw0NDQuNTQyLDIzNC44NzRMNDQ0LjU0MiwyMzQuODc0eiIvPg0KPHBhdGggb3BhY2l0eT0iMC41IiBmaWxsPSIjRkZGRkZGIiBkPSJNNDYzLjgyNSwxMTEuNTk1djIyLjA3MmgtMjQuMTYxdjU5LjQ3OWMwLDUuNTczLDAuOTI4LDkuMjkyLDIuNzg4LDExLjE0OQ0KCWMxLjg1NiwxLjg1OSw1LjU3NiwyLjc4OCwxMS4xNTIsMi43ODhjMS44NTksMCwzLjYzOC0wLjA3Niw1LjM0My0wLjIzMmMxLjcwMy0wLjE1MiwzLjMzLTAuMzg4LDQuODc4LTAuNjk2djI1LjU1Nw0KCWMtMi43ODgsMC40NjUtNS44ODcsMC43NzMtOS4yOTMsMC45MzFjLTMuNDA3LDAuMTQ5LTYuNzM3LDAuMjMtOS45OSwwLjIzYy01LjExMSwwLTkuOTUzLTAuMzUtMTQuNTIxLTEuMDQ4DQoJYy00LjU3MS0wLjY5NS04LjU5Ny0yLjA0Ny0xMi4wODEtNC4wNjNjLTMuNDg2LTIuMDExLTYuMjM2LTQuODgtOC4yNDgtOC41OTdjLTIuMDE2LTMuNzE0LTMuMDIxLTguNTk1LTMuMDIxLTE0LjYzOXYtNzAuODU5aC0xOS45OA0KCXYtMjIuMDcyaDE5Ljk4Vjc1LjU4M2gzMi45OTJ2MzYuMDEySDQ2My44MjV6Ii8+DQo8cGF0aCBvcGFjaXR5PSIwLjUiIGZpbGw9IiNGRkZGRkYiIGQ9Ik01MTIuNjEzLDIzMy43MTFoLTMyLjk5MWMtMS4xMDQsMC0yLTAuODk2LTItMlYxMTEuNTk0YzAtMS4xMDQsMC44OTYtMS45OTksMi0xLjk5OWgzMS4zNjYNCgljMS4xMDQsMCwyLDAuODk2LDIsMS45OTl2MTUuMDY5YzAuOTY3LTEuNTE2LDIuMDM0LTIuOTc4LDMuMTk5LTQuMzgyYzIuNzU0LTMuMzEyLDUuOTQ5LTYuMTgyLDkuNDk2LTguNTIyDQoJYzMuNTQ1LTIuMzMyLDcuMzg1LTQuMTY5LDExLjQxNS01LjQ2MmM0LjA1Ni0xLjI5OCw4LjMyNy0xLjk1NCwxMi42OTEtMS45NTRjMi4zNDEsMCw0Ljk1MywwLjQxOCw3Ljc2NiwxLjI0Mw0KCWMwLjg1MiwwLjI1LDEuNDM3LDEuMDMyLDEuNDM3LDEuOTJ2MzAuNjdjMCwwLjYtMC4yNjksMS4xNjctMC43MzIsMS41NDdjLTAuMzYxLDAuMjk2LTAuODA4LDAuNDUyLTEuMjY1LDAuNDUyDQoJYy0wLjEzMywwLTAuMjY1LTAuMDEzLTAuMzk4LTAuMDM5Yy0xLjQ4NC0wLjMtMy4yOTktMC41NjUtNS4zOTItMC43ODdjLTIuMDk4LTAuMjI0LTQuMTM2LTAuMzM5LTYuMDYyLTAuMzM5DQoJYy01LjcwNiwwLTEwLjU3MiwwLjk1LTE0LjQ2NywyLjgyM2MtMy44NjIsMS44Ni03LjAxMiw0LjQyOC05LjM2MSw3LjYyOWMtMi4zODksMy4yNjMtNC4xMTUsNy4xMi01LjEyNywxMS40Nw0KCWMtMS4wNDMsNC40NzktMS41NzQsOS40MDktMS41NzQsMTQuNjQ3djU0LjEzMkM1MTQuNjEzLDIzMi44MTUsNTEzLjcxNywyMzMuNzExLDUxMi42MTMsMjMzLjcxMUw1MTIuNjEzLDIzMy43MTF6Ii8+DQo8cGF0aCBvcGFjaXR5PSIwLjUiIGZpbGw9IiNGRkZGRkYiIGQ9Ik01MTAuOTg4LDExMS41OTVWMTMzLjloMC40NjVjMS41NDYtMy43MiwzLjYzNi03LjE2Myw2LjI3Mi0xMC4zNDENCgljMi42MzQtMy4xNzIsNS42NTItNS44ODUsOS4wNi04LjEzMWMzLjQwNS0yLjI0Miw3LjA0Ny0zLjk4NSwxMC45MjMtNS4yMjhjMy44NjgtMS4yMzcsNy44OTgtMS44NTksMTIuMDgxLTEuODU5DQoJYzIuMTY4LDAsNC41NjYsMC4zOSw3LjIwMiwxLjE2M3YzMC42N2MtMS41NTEtMC4zMTItMy40MS0wLjU4NC01LjU3Ni0wLjgxNGMtMi4xNy0wLjIzMy00LjI2LTAuMzUtNi4yNzQtMC4zNQ0KCWMtNi4wNDEsMC0xMS4xNTIsMS4wMS0xNS4zMzIsMy4wMjFjLTQuMTgyLDIuMDE0LTcuNTUsNC43NjEtMTAuMTA3LDguMjQ3Yy0yLjU1NSwzLjQ4Ny00LjM3OSw3LjU1LTUuNDYyLDEyLjE5OA0KCWMtMS4wODMsNC42NDUtMS42MjUsOS42ODItMS42MjUsMTUuMTAydjU0LjEzM2gtMzIuOTkxVjExMS41OTVINTEwLjk4OHoiLz4NCjxwYXRoIG9wYWNpdHk9IjAuNSIgZmlsbD0iI0ZGRkZGRiIgZD0iTTYwMy45MjMsMjMzLjcxMUg1NzAuOTNjLTEuMTA0LDAtMi0wLjg5Ni0yLTJWMTExLjU5NGMwLTEuMTA0LDAuODk2LTEuOTk5LDItMS45OTloMzIuOTk0DQoJYzEuMTA0LDAsMiwwLjg5NiwyLDEuOTk5djEyMC4xMTdDNjA1LjkyMywyMzIuODE1LDYwNS4wMjcsMjMzLjcxMSw2MDMuOTIzLDIzMy43MTFMNjAzLjkyMywyMzMuNzExeiBNNjAzLjkyMyw5NS4wMDZINTcwLjkzDQoJYy0xLjEwNCwwLTItMC44OTYtMi0xLjk5OVY2NS44MjVjMC0xLjEwNCwwLjg5Ni0yLDItMmgzMi45OTRjMS4xMDQsMCwyLDAuODk2LDIsMnYyNy4xODINCglDNjA1LjkyMyw5NC4xMSw2MDUuMDI3LDk1LjAwNiw2MDMuOTIzLDk1LjAwNkw2MDMuOTIzLDk1LjAwNnoiLz4NCjxwYXRoIG9wYWNpdHk9IjAuNSIgZmlsbD0iI0ZGRkZGRiIgZD0iTTU3MC45Myw5My4wMDdWNjUuODI0aDMyLjk5NHYyNy4xODNINTcwLjkzeiBNNjAzLjkyNCwxMTEuNTk1djEyMC4xMTdINTcwLjkzVjExMS41OTUNCglINjAzLjkyNHoiLz4NCjxwYXRoIG9wYWNpdHk9IjAuNSIgZmlsbD0iI0ZGRkZGRiIgZD0iTTc0Mi4xNjMsMjMzLjcxMWgtMzcuNjRjLTAuNjcxLDAtMS4yOTctMC4zMzUtMS42NjctMC44OTZsLTIzLjQyNi0zNS4zNTJsLTIzLjQyNiwzNS4zNTINCgljLTAuMzY5LDAuNTYxLTAuOTk1LDAuODk2LTEuNjY3LDAuODk2aC0zNi45MzhjLTAuNzQxLDAtMS40MjQtMC40MTEtMS43Ny0xLjA2N2MtMC4zNDUtMC42NTQtMC4zLTEuNDQ5LDAuMTE4LTIuMDYxbDQyLjQzNS02Mi4wNTUNCglsLTM4LjcxLTU1Ljc5M2MtMC40MjQtMC42MTMtMC40NzQtMS40MDgtMC4xMjgtMi4wNjljMC4zNDMtMC42NTgsMS4wMjgtMS4wNzEsMS43NzEtMS4wNzFoMzcuNjM2YzAuNjY1LDAsMS4yODcsMC4zMywxLjY1OCwwLjg4Mg0KCWwxOS40NzcsMjguODkzbDE5LjI1NS0yOC44ODRjMC4zNzItMC41NTYsMC45OTYtMC44OTEsMS42NjUtMC44OTFoMzYuNDc1YzAuNzQ2LDAsMS40MywwLjQxNSwxLjc3NiwxLjA3OA0KCWMwLjM0MywwLjY2LDAuMjg5LDEuNDYtMC4xMzksMi4wNzFsLTM4LjY5LDU1LjA4Mmw0My41NzgsNjIuNzQ0YzAuNDI0LDAuNjEsMC40NzQsMS40MDgsMC4xMjgsMi4wNjYNCglDNzQzLjU5MSwyMzMuMjk4LDc0Mi45MDgsMjMzLjcxMSw3NDIuMTYzLDIzMy43MTFMNzQyLjE2MywyMzMuNzExeiIvPg0KPHBhdGggb3BhY2l0eT0iMC41IiBmaWxsPSIjRkZGRkZGIiBkPSJNNjIxLjExNSwxMTEuNTk1aDM3LjYzN2wyMS4xNDQsMzEuMzY1bDIwLjkxMS0zMS4zNjVoMzYuNDc2bC0zOS40OTYsNTYuMjI2bDQ0LjM3Nyw2My44OTINCgloLTM3LjY0bC0yNS4wOTMtMzcuODdsLTI1LjA5NCwzNy44N2gtMzYuOTM4bDQzLjIxMy02My4xOTNMNjIxLjExNSwxMTEuNTk1eiIvPg0KPHBhdGggb3BhY2l0eT0iMC41IiBmaWxsPSIjRkZGRkZGIiBkPSJNNzkxLjMyMiwzNDAuODA5aC0zMi4wMDhjLTEuMTA1LDAtMi0wLjg5Ni0yLTJ2LTcuNzEyYzAtMS4xMDUsMC44OTYtMi4wMDEsMi0yLjAwMWgyMS4xMw0KCVYxMS43MWgtMjEuMTNjLTEuMTA1LDAtMi0wLjg5Ni0yLTJWMmMwLTEuMTA0LDAuODk2LTIsMi0yaDMyLjAwOGMxLjEwNCwwLDIsMC44OTYsMiwydjMzNi44MDkNCglDNzkzLjMyMiwzMzkuOTEzLDc5Mi40MjYsMzQwLjgwOSw3OTEuMzIyLDM0MC44MDlMNzkxLjMyMiwzNDAuODA5eiIvPg0KPHBhdGggb3BhY2l0eT0iMC41IiBmaWxsPSIjRkZGRkZGIiBkPSJNNzgyLjQ0MywzMzEuMDk3VjkuNzExaC0yMy4xM3YtNy43MWgzMi4wMDh2MzM2LjgwN2gtMzIuMDA4di03LjcxMUg3ODIuNDQzeiIvPg0KPHBhdGggZD0iTTEwLjg3NSw5LjcxMXYzMjEuMzg2aDIzLjEzdjcuNzExSDEuOTk5VjIuMDAxaDMyLjAwNnY3LjcxSDEwLjg3NXoiLz4NCjxwYXRoIGQ9Ik05OS45ODgsMTExLjU5NXYxNi4yNjRoMC40NjNjNC4zMzgtNi4xOTEsOS41NjMtMTAuOTk4LDE1LjY4NC0xNC40MDZjNi4xMTctMy40MDIsMTMuMTI5LTUuMTEsMjEuMDI3LTUuMTENCgljNy41ODgsMCwxNC41MjEsMS40NzUsMjAuNzkzLDQuNDE1YzYuMjc0LDIuOTQ1LDExLjAzOCw4LjEzMSwxNC4yOTEsMTUuNTY3YzMuNTYtNS4yNjUsOC40LTkuOTEzLDE0LjUyMS0xMy45NA0KCWM2LjExNy00LjAyNSwxMy4zNTgtNi4wNDIsMjEuNzI0LTYuMDQyYzYuMzUxLDAsMTIuMjM0LDAuNzc2LDE3LjY2LDIuMzI1YzUuNDE4LDEuNTQ5LDEwLjA2NSw0LjAyNywxMy45MzgsNy40MzQNCgljMy44NjksMy40MSw2Ljg4OSw3Ljg2Myw5LjA2MiwxMy4zNTdjMi4xNjcsNS41MDQsMy4yNTMsMTIuMTIyLDMuMjUzLDE5Ljg2OXY4MC4zODVIMjE5LjQxdi02OC4wNzQNCgljMC00LjAyNS0wLjE1NC03LjgyLTAuNDY1LTExLjM4NWMtMC4zMTMtMy41Ni0xLjE2MS02LjY1Ni0yLjU1NS05LjI5M2MtMS4zOTUtMi42MzEtMy40NS00LjcyNC02LjE1Ny02LjI3NA0KCWMtMi43MTEtMS41NDMtNi4zOTEtMi4zMjItMTEuMDM3LTIuMzIycy04LjQwMywwLjg5Ni0xMS4yNjksMi42NzFjLTIuODY4LDEuNzg0LTUuMTEyLDQuMTA5LTYuNzM3LDYuOTcxDQoJYy0xLjYyNiwyLjg2OS0yLjcxMSw2LjEyLTMuMjUyLDkuNzYyYy0wLjU0NSwzLjYzOC0wLjgxNCw3LjMxOC0wLjgxNCwxMS4wMzV2NjYuOTFoLTMyLjk5MXYtNjcuMzc1YzAtMy41NjItMC4wODEtNy4wODctMC4yMy0xMC41Nw0KCWMtMC4xNTgtMy40ODctMC44MTQtNi43LTEuOTc4LTkuNjQ1Yy0xLjE2Mi0yLjk0LTMuMDk5LTUuMzA0LTUuODA5LTcuMDg4Yy0yLjcxMS0xLjc3NS02LjY5OS0yLjY3MS0xMS45NjUtMi42NzENCgljLTEuNTUxLDAtMy42MDMsMC4zNDktNi4xNTYsMS4wNDhjLTIuNTU2LDAuNjk3LTUuMDM2LDIuMDE2LTcuNDM1LDMuOTQ5Yy0yLjQwNCwxLjkzOC00LjQ1NCw0LjcyNi02LjE1OCw4LjM2Mw0KCWMtMS43MDUsMy42NDItMi41NTYsOC40MDItMi41NTYsMTQuMjg3djY5LjcwMWgtMzIuOTlWMTExLjU5NUg5OS45ODh6Ii8+DQo8cGF0aCBkPSJNMjczLjU0NCwxMjkuMjU1YzMuNDA1LTUuMTEzLDcuNzQ0LTkuMjE1LDEzLjAxMi0xMi4zMTZjNS4yNjQtMy4wOTcsMTEuMTg2LTUuMzAzLDE3Ljc3MS02LjYyMQ0KCWM2LjU4Mi0xLjMxNSwxMy4yMDUtMS45NzYsMTkuODY1LTEuOTc2YzYuMDQyLDAsMTIuMTU4LDAuNDI4LDE4LjM1NCwxLjI3N2M2LjE5NSwwLjg1NSwxMS44NSwyLjUyMiwxNi45NjIsNC45OTcNCgljNS4xMTEsMi40NzcsOS4yOTIsNS45MjYsMTIuNTQ2LDEwLjMzOGMzLjI1Myw0LjQxNCw0Ljg3OSwxMC4yNjIsNC44NzksMTcuNTQzdjYyLjQ5NGMwLDUuNDI4LDAuMzEsMTAuNjExLDAuOTMxLDE1LjU2Nw0KCWMwLjYxNSw0Ljk1OSwxLjcwMSw4LjY3NiwzLjI1MSwxMS4xNTNIMzQ3LjY2Yy0wLjYyMS0xLjg2LTEuMTI2LTMuNzU1LTEuNTExLTUuNjkzYy0wLjM5LTEuOTMzLTAuNjYxLTMuOTA4LTAuODEzLTUuOTIzDQoJYy01LjI2Nyw1LjQyMi0xMS40NjUsOS4yMTctMTguNTg1LDExLjM4NmMtNy4xMjcsMi4xNjMtMTQuNDA3LDMuMjUxLTIxLjg0MiwzLjI1MWMtNS43MzMsMC0xMS4wNzctMC42OTgtMTYuMDMzLTIuMDkNCgljLTQuOTU4LTEuMzk1LTkuMjkzLTMuNTYyLTEzLjAxLTYuNTFjLTMuNzE4LTIuOTM4LTYuNjIyLTYuNjU2LTguNzEzLTExLjE0N3MtMy4xMzgtOS44NC0zLjEzOC0xNi4wMzMNCgljMC02LjgxMywxLjE5OS0xMi40MywzLjYwNC0xNi44NGMyLjM5OS00LjQxNyw1LjQ5NS03LjkzOSw5LjI5NS0xMC41NzVjMy43OTMtMi42MzIsOC4xMjktNC42MDcsMTMuMDEtNS45MjMNCgljNC44NzgtMS4zMTUsOS43OTUtMi4zNTgsMTQuNzUyLTMuMTM3YzQuOTU3LTAuNzcyLDkuODM1LTEuMzkzLDE0LjYzOC0xLjg1N2M0LjgwMS0wLjQ2Niw5LjA2Mi0xLjE2NCwxMi43NzktMi4wOTMNCgljMy43MTgtMC45MjksNi42NTgtMi4yODIsOC44MjktNC4wNjVjMi4xNjUtMS43ODEsMy4xNzItNC4zNzUsMy4wMi03Ljc4NWMwLTMuNTYtMC41OC02LjM4OS0xLjc0Mi04LjQ3OQ0KCWMtMS4xNjEtMi4wOS0yLjcxMS0zLjcxOS00LjY0Ni00Ljg4Yy0xLjkzNy0xLjE2MS00LjE4My0xLjkzNi02LjczNy0yLjMyNWMtMi41NTctMC4zODItNS4zMDktMC41OC04LjI0OC0wLjU4DQoJYy02LjUwNiwwLTExLjYxNywxLjM5NS0xNS4zMzUsNC4xODNjLTMuNzE2LDIuNzg4LTUuODg5LDcuNDM3LTYuNTA2LDEzLjk0aC0zMi45OTENCglDMjY4LjE5OSwxNDAuNzk0LDI3MC4xMzIsMTM0LjM2MywyNzMuNTQ0LDEyOS4yNTV6IE0zMzguNzEzLDE3NS44MzhjLTIuMDksMC42OTYtNC4zMzcsMS4yNzUtNi43MzYsMS43NDENCgljLTIuNDAyLDAuNDY1LTQuOTE4LDAuODUzLTcuNTUxLDEuMTYxYy0yLjYzNSwwLjMxMy01LjI2OCwwLjY5OC03Ljg5OSwxLjE2M2MtMi40OCwwLjQ2MS00LjkxOSwxLjA4Ni03LjMxNywxLjg1Nw0KCWMtMi40MDQsMC43NzktNC40OTUsMS44MjItNi4yNzQsMy4xMzhjLTEuNzg0LDEuMzE3LTMuMjE2LDIuOTg1LTQuMyw0Ljk5NGMtMS4wODUsMi4wMTQtMS42MjYsNC41NzEtMS42MjYsNy42NjgNCgljMCwyLjk0LDAuNTQxLDUuNDIyLDEuNjI2LDcuNDMxYzEuMDg0LDIuMDE3LDIuNTU4LDMuNjA0LDQuNDE2LDQuNzY1czQuMDI1LDEuOTc2LDYuNTA3LDIuNDM4YzIuNDc1LDAuNDY2LDUuMDMxLDAuNjk4LDcuNjY1LDAuNjk4DQoJYzYuNTA1LDAsMTEuNTM3LTEuMDgyLDE1LjEwMy0zLjI1M2MzLjU2MS0yLjE2Niw2LjE5Mi00Ljc2Miw3Ljg5OS03Ljc4NWMxLjcwMi0zLjAxOSwyLjc0OS02LjA3MiwzLjEzNy05LjE3NA0KCWMwLjM4NC0zLjA5NywwLjU4LTUuNTc2LDAuNTgtNy40MzR2LTEyLjMxNkMzNDIuNTQ3LDE3NC4xNzMsMzQwLjgwNSwxNzUuMTQsMzM4LjcxMywxNzUuODM4eiIvPg0KPHBhdGggZD0iTTQ2My44MjUsMTExLjU5NXYyMi4wNzJoLTI0LjE2MXY1OS40NzljMCw1LjU3MywwLjkyOCw5LjI5MiwyLjc4OCwxMS4xNDljMS44NTYsMS44NTksNS41NzYsMi43ODgsMTEuMTUyLDIuNzg4DQoJYzEuODU5LDAsMy42MzgtMC4wNzYsNS4zNDMtMC4yMzJjMS43MDMtMC4xNTIsMy4zMy0wLjM4OCw0Ljg3OC0wLjY5NnYyNS41NTdjLTIuNzg4LDAuNDY1LTUuODg3LDAuNzczLTkuMjkzLDAuOTMxDQoJYy0zLjQwNywwLjE0OS02LjczNywwLjIzLTkuOTksMC4yM2MtNS4xMTEsMC05Ljk1My0wLjM1LTE0LjUyMS0xLjA0OGMtNC41NzEtMC42OTUtOC41OTctMi4wNDctMTIuMDgxLTQuMDYzDQoJYy0zLjQ4Ni0yLjAxMS02LjIzNi00Ljg4LTguMjQ4LTguNTk3Yy0yLjAxNi0zLjcxNC0zLjAyMS04LjU5NS0zLjAyMS0xNC42Mzl2LTcwLjg1OWgtMTkuOTh2LTIyLjA3MmgxOS45OFY3NS41ODNoMzIuOTkydjM2LjAxMg0KCUg0NjMuODI1eiIvPg0KPHBhdGggZD0iTTUxMC45ODgsMTExLjU5NVYxMzMuOWgwLjQ2NWMxLjU0Ni0zLjcyLDMuNjM2LTcuMTYzLDYuMjcyLTEwLjM0MWMyLjYzNC0zLjE3Miw1LjY1Mi01Ljg4NSw5LjA2LTguMTMxDQoJYzMuNDA1LTIuMjQyLDcuMDQ3LTMuOTg1LDEwLjkyMy01LjIyOGMzLjg2OC0xLjIzNyw3Ljg5OC0xLjg1OSwxMi4wODEtMS44NTljMi4xNjgsMCw0LjU2NiwwLjM5LDcuMjAyLDEuMTYzdjMwLjY3DQoJYy0xLjU1MS0wLjMxMi0zLjQxLTAuNTg0LTUuNTc2LTAuODE0Yy0yLjE3LTAuMjMzLTQuMjYtMC4zNS02LjI3NC0wLjM1Yy02LjA0MSwwLTExLjE1MiwxLjAxLTE1LjMzMiwzLjAyMQ0KCWMtNC4xODIsMi4wMTQtNy41NSw0Ljc2MS0xMC4xMDcsOC4yNDdjLTIuNTU1LDMuNDg3LTQuMzc5LDcuNTUtNS40NjIsMTIuMTk4Yy0xLjA4Myw0LjY0NS0xLjYyNSw5LjY4Mi0xLjYyNSwxNS4xMDJ2NTQuMTMzaC0zMi45OTENCglWMTExLjU5NUg1MTAuOTg4eiIvPg0KPHBhdGggZD0iTTU3MC45Myw5My4wMDdWNjUuODI0aDMyLjk5NHYyNy4xODNINTcwLjkzeiBNNjAzLjkyNCwxMTEuNTk1djEyMC4xMTdINTcwLjkzVjExMS41OTVINjAzLjkyNHoiLz4NCjxwYXRoIGQ9Ik02MjEuMTE1LDExMS41OTVoMzcuNjM3bDIxLjE0NCwzMS4zNjVsMjAuOTExLTMxLjM2NWgzNi40NzZsLTM5LjQ5Niw1Ni4yMjZsNDQuMzc3LDYzLjg5MmgtMzcuNjRsLTI1LjA5My0zNy44Nw0KCWwtMjUuMDk0LDM3Ljg3aC0zNi45MzhsNDMuMjEzLTYzLjE5M0w2MjEuMTE1LDExMS41OTV6Ii8+DQo8cGF0aCBkPSJNNzgyLjQ0MywzMzEuMDk3VjkuNzExaC0yMy4xM3YtNy43MWgzMi4wMDh2MzM2LjgwN2gtMzIuMDA4di03LjcxMUg3ODIuNDQzeiIvPg0KPC9zdmc+DQo=`, "base64");
\ No newline at end of file
diff --git a/spec/util/homerunner.ts b/spec/util/homerunner.ts
index a33bbc96a..ac7ada3bb 100644
--- a/spec/util/homerunner.ts
+++ b/spec/util/homerunner.ts
@@ -1,9 +1,10 @@
-import { MatrixClient } from "matrix-bot-sdk";
+import { MatrixClient, MemoryStorageProvider, RustSdkCryptoStorageProvider, RustSdkCryptoStoreType } from "matrix-bot-sdk";
import { createHash, createHmac, randomUUID } from "crypto";
import { Homerunner } from "homerunner-client";
import { E2ETestMatrixClient } from "./e2e-test";
+import path from "node:path";
-const HOMERUNNER_IMAGE = process.env.HOMERUNNER_IMAGE || 'ghcr.io/element-hq/synapse/complement-synapse:latest';
+const HOMERUNNER_IMAGE = process.env.HOMERUNNER_IMAGE || 'ghcr.io/element-hq/synapse/complement-synapse:nightly';
export const DEFAULT_REGISTRATION_SHARED_SECRET = (
process.env.REGISTRATION_SHARED_SECRET || 'complement'
);
@@ -41,7 +42,7 @@ async function waitForHomerunner() {
}
}
-export async function createHS(localparts: string[] = [], workerId: number): Promise {
+export async function createHS(localparts: string[] = [], workerId: number, cryptoRootPath?: string): Promise {
await waitForHomerunner();
const appPort = 49600 + workerId;
@@ -60,26 +61,36 @@ export async function createHS(localparts: string[] = [], workerId: number): Pro
URL: `http://${COMPLEMENT_HOSTNAME_RUNNING_COMPLEMENT}:${appPort}`,
SenderLocalpart: 'hookshot',
RateLimited: false,
- ...{ASToken: asToken,
- HSToken: hsToken},
+ ASToken: asToken,
+ HSToken: hsToken,
+ SendEphemeral: true,
+ EnableEncryption: true,
}]
}],
}
});
const [homeserverName, homeserver] = Object.entries(blueprintResponse.homeservers)[0];
// Skip AS user.
- const users = Object.entries(homeserver.AccessTokens)
+ const users = await Promise.all(Object.entries(homeserver.AccessTokens)
.filter(([_uId, accessToken]) => accessToken !== asToken)
- .map(([userId, accessToken]) => ({
- userId: userId,
- accessToken,
- deviceId: homeserver.DeviceIDs[userId],
- client: new E2ETestMatrixClient(homeserver.BaseURL, accessToken),
- })
- );
+ .map(async ([userId, accessToken]) => {
+ const cryptoStore = cryptoRootPath ? new RustSdkCryptoStorageProvider(path.join(cryptoRootPath, userId), RustSdkCryptoStoreType.Sqlite) : undefined;
+ const client = new E2ETestMatrixClient(homeserver.BaseURL, accessToken, new MemoryStorageProvider(), cryptoStore);
+ if (cryptoStore) {
+ await client.crypto.prepare();
+ }
+ // Start syncing proactively.
+ await client.start();
+ return {
+ userId: userId,
+ accessToken,
+ deviceId: homeserver.DeviceIDs[userId],
+ client,
+ }
+ }
+ ));
+
- // Start syncing proactively.
- await Promise.all(users.map(u => u.client.start()));
return {
users,
id: blueprint,
@@ -119,7 +130,7 @@ export async function registerUser(
.update(password).update("\x00")
.update(user.admin ? 'admin' : 'notadmin')
.digest('hex');
- return await fetch(registerUrl, { method: "POST", body: JSON.stringify(
+ const req = await fetch(registerUrl, { method: "POST", body: JSON.stringify(
{
nonce,
username: user.username,
@@ -127,8 +138,10 @@ export async function registerUser(
admin: user.admin,
mac: hmac,
}
- )}).then(res => res.json()).then(res => ({
- mxid: (res as {user_id: string}).user_id,
- client: new MatrixClient(homeserverUrl, (res as {access_token: string}).access_token),
- })).catch(err => { console.log(err.response.body); throw new Error(`Failed to register user: ${err}`); });
+ )});
+ const res = await req.json() as {user_id: string, access_token: string};
+ return {
+ mxid: res.user_id,
+ client: new MatrixClient(homeserverUrl, res.access_token),
+ };
}
diff --git a/spec/webhooks.spec.ts b/spec/webhooks.spec.ts
new file mode 100644
index 000000000..9238caa41
--- /dev/null
+++ b/spec/webhooks.spec.ts
@@ -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>/.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);
+ // });
+});
diff --git a/src/AdminRoom.ts b/src/AdminRoom.ts
index efa2c130c..9c9b85553 100644
--- a/src/AdminRoom.ts
+++ b/src/AdminRoom.ts
@@ -1,4 +1,3 @@
-/* eslint-disable @typescript-eslint/ban-ts-comment */
import "reflect-metadata";
import { AdminAccountData, AdminRoomCommandHandler, Category } from "./AdminRoomCommandHandler";
import { botCommand, compileBotCommands, handleCommand, BotCommands, HelpFunction } from "./BotCommands";
@@ -16,7 +15,7 @@ import { Intent } from "matrix-bot-sdk";
import { JiraBotCommands } from "./jira/AdminCommands";
import { NotifFilter, NotificationFilterStateContent } from "./NotificationFilters";
import { ProjectsListResponseData } from "./github/Types";
-import { UserTokenStore } from "./UserTokenStore";
+import { UserTokenStore } from "./tokens/UserTokenStore";
import { Logger } from "matrix-appservice-bridge";
import markdown from "markdown-it";
type ProjectsListForRepoResponseData = Endpoints["GET /repos/{owner}/{repo}/projects"]["response"];
diff --git a/src/AdminRoomCommandHandler.ts b/src/AdminRoomCommandHandler.ts
index 10102e7e8..e11a081f1 100644
--- a/src/AdminRoomCommandHandler.ts
+++ b/src/AdminRoomCommandHandler.ts
@@ -1,7 +1,7 @@
import EventEmitter from "events";
import { Intent } from "matrix-bot-sdk";
import { BridgeConfig } from "./config/Config";
-import { UserTokenStore } from "./UserTokenStore";
+import { UserTokenStore } from "./tokens/UserTokenStore";
export enum Category {
@@ -13,7 +13,6 @@ export enum Category {
export interface AdminAccountData {
- // eslint-disable-next-line camelcase
admin_user: string;
github?: {
notifications?: {
diff --git a/src/App/BridgeApp.ts b/src/App/BridgeApp.ts
index a21049e48..83f90957d 100644
--- a/src/App/BridgeApp.ts
+++ b/src/App/BridgeApp.ts
@@ -1,6 +1,5 @@
import { Bridge } from "../Bridge";
import { BridgeConfig, parseRegistrationFile } from "../config/Config";
-import { Webhooks } from "../Webhooks";
import { MatrixSender } from "../MatrixSender";
import { UserNotificationWatcher } from "../Notifications/UserNotificationWatcher";
import { ListenerService } from "../ListenerService";
@@ -10,6 +9,7 @@ import { getAppservice } from "../appservice";
import BotUsersManager from "../Managers/BotUsersManager";
import * as Sentry from '@sentry/node';
import { GenericHookConnection } from "../Connections";
+import { UserTokenStore } from "../tokens/UserTokenStore";
Logger.configure({console: "info"});
const log = new Logger("App");
@@ -27,7 +27,7 @@ export async function start(config: BridgeConfig, registration: IAppserviceRegis
const {appservice, storage} = getAppservice(config, registration);
- if (config.queue.monolithic) {
+ if (!config.queue) {
const matrixSender = new MatrixSender(config, appservice);
matrixSender.listen();
const userNotificationWatcher = new UserNotificationWatcher(config);
@@ -51,7 +51,8 @@ export async function start(config: BridgeConfig, registration: IAppserviceRegis
const botUsersManager = new BotUsersManager(config, appservice);
- const bridgeApp = new Bridge(config, listener, appservice, storage, botUsersManager);
+ const tokenStore = await UserTokenStore.fromKeyPath(config.passFile , appservice.botIntent, config);
+ const bridgeApp = new Bridge(config, tokenStore, listener, appservice, storage, botUsersManager);
process.once("SIGTERM", () => {
log.error("Got SIGTERM");
diff --git a/src/Bridge.ts b/src/Bridge.ts
index c92517458..36bd695e7 100644
--- a/src/Bridge.ts
+++ b/src/Bridge.ts
@@ -10,7 +10,7 @@ import { GetIssueResponse, GetIssueOpts } from "./Gitlab/Types"
import { GithubInstance } from "./github/GithubInstance";
import { IBridgeStorageProvider } from "./Stores/StorageProvider";
import { IConnection, GitHubDiscussionSpace, GitHubDiscussionConnection, GitHubUserSpace, JiraProjectConnection, GitLabRepoConnection,
- GitHubIssueConnection, GitHubProjectConnection, GitHubRepoConnection, GitLabIssueConnection, FigmaFileConnection, FeedConnection, GenericHookConnection, WebhookResponse } from "./Connections";
+ GitHubIssueConnection, GitHubProjectConnection, GitHubRepoConnection, GitLabIssueConnection, FigmaFileConnection, FeedConnection, GenericHookConnection } from "./Connections";
import { IGitLabWebhookIssueStateEvent, IGitLabWebhookMREvent, IGitLabWebhookNoteEvent, IGitLabWebhookPushEvent, IGitLabWebhookReleaseEvent, IGitLabWebhookTagPushEvent, IGitLabWebhookWikiPageEvent } from "./Gitlab/WebhookTypes";
import { JiraIssueEvent, JiraIssueUpdatedEvent, JiraVersionEvent } from "./jira/WebhookTypes";
import { JiraOAuthResult } from "./jira/Types";
@@ -23,7 +23,7 @@ import { NotificationsEnableEvent, NotificationsDisableEvent, Webhooks } from ".
import { GitHubOAuthToken, GitHubOAuthTokenResponse, ProjectsGetResponseData } from "./github/Types";
import { retry } from "./PromiseUtil";
import { UserNotificationsEvent } from "./Notifications/UserNotificationWatcher";
-import { UserTokenStore } from "./UserTokenStore";
+import { UserTokenStore } from "./tokens/UserTokenStore";
import * as GitHubWebhookTypes from "@octokit/webhooks-types";
import { Logger } from "matrix-appservice-bridge";
import { Provisioner } from "./provisioning/provisioner";
@@ -39,8 +39,9 @@ import { JiraOAuthRequestCloud, JiraOAuthRequestOnPrem, JiraOAuthRequestResult }
import { GenericWebhookEvent, GenericWebhookEventResult } from "./generic/types";
import { SetupWidget } from "./Widgets/SetupWidget";
import { FeedEntry, FeedError, FeedReader, FeedSuccess } from "./feeds/FeedReader";
-import PQueue from "p-queue";
import * as Sentry from '@sentry/node';
+import { HoundConnection, HoundPayload } from "./Connections/HoundConnection";
+import { HoundReader } from "./hound/reader";
const log = new Logger("Bridge");
@@ -49,12 +50,11 @@ export class Bridge {
private readonly queue: MessageQueue;
private readonly commentProcessor: CommentProcessor;
private readonly notifProcessor: NotificationProcessor;
- private readonly tokenStore: UserTokenStore;
private connectionManager?: ConnectionManager;
private github?: GithubInstance;
private adminRooms: Map = new Map();
- private widgetApi?: BridgeWidgetApi;
private feedReader?: FeedReader;
+ private houndReader?: HoundReader;
private provisioningApi?: Provisioner;
private replyProcessor = new RichRepliesPreprocessor(true);
@@ -62,6 +62,7 @@ export class Bridge {
constructor(
private config: BridgeConfig,
+ private readonly tokenStore: UserTokenStore,
private readonly listener: ListenerService,
private readonly as: Appservice,
private readonly storage: IBridgeStorageProvider,
@@ -71,8 +72,6 @@ export class Bridge {
this.messageClient = new MessageSenderClient(this.queue);
this.commentProcessor = new CommentProcessor(this.as, this.config.bridge.mediaUrl || this.config.bridge.url);
this.notifProcessor = new NotificationProcessor(this.storage, this.messageClient);
- this.tokenStore = new UserTokenStore(this.config.passFile || "./passkey.pem", this.as.botIntent, this.config);
- this.tokenStore.on("onNewToken", this.onTokenUpdated.bind(this));
// Legacy routes, to be removed.
this.as.expressAppInstance.get("/live", (_, res) => res.send({ok: true}));
@@ -81,14 +80,15 @@ export class Bridge {
public stop() {
this.feedReader?.stop();
+ this.houndReader?.stop();
this.tokenStore.stop();
this.as.stop();
if (this.queue.stop) this.queue.stop();
}
public async start() {
+ this.tokenStore.on("onNewToken", this.onTokenUpdated.bind(this));
log.info('Starting up');
- await this.tokenStore.load();
await this.storage.connect?.();
await this.queue.connect?.();
@@ -97,7 +97,7 @@ export class Bridge {
while (!reached) {
try {
// Make a request to determine if we can reach the homeserver
- await this.as.botIntent.getJoinedRooms();
+ await this.as.botIntent.underlyingClient.getWhoAmI();
reached = true;
} catch (e) {
log.warn("Failed to connect to homeserver, retrying in 5s", e);
@@ -343,6 +343,12 @@ export class Bridge {
(c, data) => c.onMergeRequestOpened(data),
);
+ this.bindHandlerToQueue(
+ "gitlab.merge_request.reopen",
+ (data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace),
+ (c, data) => c.onMergeRequestReopened(data),
+ );
+
this.bindHandlerToQueue(
"gitlab.merge_request.close",
(data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace),
@@ -599,7 +605,7 @@ export class Bridge {
if (!connections.length) {
await this.queue.push({
- data: {notFound: true},
+ data: {successful: true, notFound: true},
sender: "Bridge",
messageId: messageId,
eventName: "response.generic-webhook.event",
@@ -614,21 +620,19 @@ export class Bridge {
await c.onGenericHook(data.hookData);
return;
}
- let successful: boolean|null = null;
- let response: WebhookResponse|undefined;
if (this.config.generic?.waitForComplete || c.waitForComplete) {
const result = await c.onGenericHook(data.hookData);
- successful = result.successful;
- response = result.response;
await this.queue.push({
- data: {successful, response},
+ data: result,
sender: "Bridge",
messageId,
eventName: "response.generic-webhook.event",
});
} else {
await this.queue.push({
- data: {},
+ data: {
+ successful: null,
+ },
sender: "Bridge",
messageId,
eventName: "response.generic-webhook.event",
@@ -681,67 +685,76 @@ export class Bridge {
(c, data) => c.handleFeedError(data),
);
- const queue = new PQueue({
- concurrency: 2,
- });
- // Set up already joined rooms
- await queue.addAll(this.botUsersManager.joinedRooms.map((roomId) => async () => {
- log.debug("Fetching state for " + roomId);
+ this.bindHandlerToQueue(
+ "hound.activity",
+ (data) => connManager.getConnectionsForHoundChallengeId(data.challengeId),
+ (c, data) => c.handleNewActivity(data.activity)
+ );
- try {
- await connManager.createConnectionsForRoomId(roomId, false);
- } catch (ex) {
- log.error(`Unable to create connection for ${roomId}`, ex);
- return;
- }
+ const allRooms = this.botUsersManager.joinedRooms;
- const botUser = this.botUsersManager.getBotUserInRoom(roomId);
- if (!botUser) {
- log.error(`Failed to find a bot in room '${roomId}' when setting up admin room`);
- return;
- }
+ const processRooms = async () => {
+ for (let roomId = allRooms.pop(); roomId !== undefined; roomId = allRooms.pop()) {
+ log.debug("Fetching state for " + roomId);
- // TODO: Refactor this to be a connection
- try {
- let accountData = await botUser.intent.underlyingClient.getSafeRoomAccountData(
- BRIDGE_ROOM_TYPE, roomId,
- );
- if (!accountData) {
- accountData = await botUser.intent.underlyingClient.getSafeRoomAccountData(
- LEGACY_BRIDGE_ROOM_TYPE, roomId,
- );
- if (!accountData) {
- log.debug(`Room ${roomId} has no connections and is not an admin room`);
- return;
- } else {
- // Upgrade the room
- await botUser.intent.underlyingClient.setRoomAccountData(BRIDGE_ROOM_TYPE, roomId, accountData);
- }
+ try {
+ await connManager.createConnectionsForRoomId(roomId, false);
+ } catch (ex) {
+ log.error(`Unable to create connection for ${roomId}`, ex);
+ continue;
}
-
- let notifContent;
+
+ const botUser = this.botUsersManager.getBotUserInRoom(roomId);
+ if (!botUser) {
+ log.error(`Failed to find a bot in room '${roomId}' when setting up admin room`);
+ continue;
+ }
+
+ // TODO: Refactor this to be a connection
try {
- notifContent = await botUser.intent.underlyingClient.getRoomStateEvent(
- roomId, NotifFilter.StateType, "",
+ let accountData = await botUser.intent.underlyingClient.getSafeRoomAccountData(
+ BRIDGE_ROOM_TYPE, roomId,
);
- } catch (ex) {
+ if (!accountData) {
+ accountData = await botUser.intent.underlyingClient.getSafeRoomAccountData(
+ LEGACY_BRIDGE_ROOM_TYPE, roomId,
+ );
+ if (!accountData) {
+ log.debug(`Room ${roomId} has no connections and is not an admin room`);
+ continue;
+ } else {
+ // Upgrade the room
+ await botUser.intent.underlyingClient.setRoomAccountData(BRIDGE_ROOM_TYPE, roomId, accountData);
+ }
+ }
+
+ let notifContent;
try {
notifContent = await botUser.intent.underlyingClient.getRoomStateEvent(
- roomId, NotifFilter.LegacyStateType, "",
+ roomId, NotifFilter.StateType, "",
);
+ } catch (ex) {
+ try {
+ notifContent = await botUser.intent.underlyingClient.getRoomStateEvent(
+ roomId, NotifFilter.LegacyStateType, "",
+ );
+ }
+ catch (ex) {
+ // No state yet
+ }
}
- catch (ex) {
- // No state yet
- }
- }
- const adminRoom = await this.setUpAdminRoom(botUser.intent, roomId, accountData, notifContent || NotifFilter.getDefaultContent());
- // Call this on startup to set the state
- await this.onAdminRoomSettingsChanged(adminRoom, accountData, { admin_user: accountData.admin_user });
- log.debug(`Room ${roomId} is connected to: ${adminRoom.toString()}`);
- } catch (ex) {
- log.error(`Failed to set up admin room ${roomId}:`, ex);
+ const adminRoom = await this.setUpAdminRoom(botUser.intent, roomId, accountData, notifContent || NotifFilter.getDefaultContent());
+ // Call this on startup to set the state
+ await this.onAdminRoomSettingsChanged(adminRoom, accountData, { admin_user: accountData.admin_user });
+ log.debug(`Room ${roomId} is connected to: ${adminRoom.toString()}`);
+ } catch (ex) {
+ log.error(`Failed to set up admin room ${roomId}:`, ex);
+ }
}
- }));
+ }
+
+ // Concurrency of two.
+ const roomQueue = await Promise.all([processRooms(), processRooms()])
// Handle spaces
for (const discussion of connManager.getAllConnectionsOfType(GitHubDiscussionSpace)) {
@@ -755,7 +768,7 @@ export class Bridge {
if (apps.length > 1) {
throw Error('You may only bind `widgets` to one listener.');
}
- this.widgetApi = new BridgeWidgetApi(
+ new BridgeWidgetApi(
this.adminRooms,
this.config,
this.storage,
@@ -774,7 +787,7 @@ export class Bridge {
if (this.config.metrics?.enabled) {
this.listener.bindResource('metrics', Metrics.expressRouter);
}
- await queue.onIdle();
+ await roomQueue;
log.info(`All connections loaded`);
// Load feeds after connections, to limit the chances of us double
@@ -788,6 +801,15 @@ export class Bridge {
);
}
+ if (this.config.challengeHound?.token) {
+ this.houndReader = new HoundReader(
+ this.config.challengeHound,
+ this.connectionManager,
+ this.queue,
+ this.storage,
+ );
+ }
+
const webhookHandler = new Webhooks(this.config);
this.listener.bindResource('webhooks', webhookHandler.expressRouter);
@@ -1142,10 +1164,14 @@ export class Bridge {
}
if (!existingConnections.length) {
// Is anyone interested in this state?
- 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);
+ 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);
}
}
diff --git a/src/CommentProcessor.ts b/src/CommentProcessor.ts
index 7f1bf2f7c..b2d0fb6e8 100644
--- a/src/CommentProcessor.ts
+++ b/src/CommentProcessor.ts
@@ -16,7 +16,6 @@ const log = new Logger("CommentProcessor");
const mime = import('mime');
interface IMatrixCommentEvent extends MatrixMessageContent {
- // eslint-disable-next-line camelcase
external_url: string;
"uk.half-shot.matrix-hookshot.github.comment": {
id: number;
@@ -158,7 +157,7 @@ export class CommentProcessor {
body = body.replace(rawUrl, url);
} catch (ex) {
- log.warn("Failed to upload file");
+ log.warn("Failed to upload file", ex);
}
}
return body;
diff --git a/src/ConnectionManager.ts b/src/ConnectionManager.ts
index c475aa778..5f8508c82 100644
--- a/src/ConnectionManager.ts
+++ b/src/ConnectionManager.ts
@@ -8,7 +8,8 @@ import { Appservice, Intent, StateEvent } from "matrix-bot-sdk";
import { ApiError, ErrCode } from "./api";
import { BridgeConfig, BridgePermissionLevel, GitLabInstance } from "./config/Config";
import { CommentProcessor } from "./CommentProcessor";
-import { ConnectionDeclaration, ConnectionDeclarations, GenericHookConnection, GitHubDiscussionConnection, GitHubDiscussionSpace, GitHubIssueConnection, GitHubProjectConnection, GitHubRepoConnection, GitHubUserSpace, GitLabIssueConnection, GitLabRepoConnection, IConnection, IConnectionState, JiraProjectConnection } from "./Connections";
+import { ConnectionDeclaration, ConnectionDeclarations, GenericHookConnection, GitHubDiscussionConnection, GitHubDiscussionSpace, GitHubIssueConnection,
+ GitHubProjectConnection, GitHubRepoConnection, GitHubUserSpace, GitLabIssueConnection, GitLabRepoConnection, IConnection, IConnectionState, JiraProjectConnection } from "./Connections";
import { FigmaFileConnection, FeedConnection } from "./Connections";
import { GetConnectionTypeResponseItem } from "./provisioning/api";
import { GitLabClient } from "./Gitlab/Client";
@@ -17,11 +18,12 @@ import { IBridgeStorageProvider } from "./Stores/StorageProvider";
import { JiraProject, JiraVersion } from "./jira/Types";
import { Logger } from "matrix-appservice-bridge";
import { MessageSenderClient } from "./MatrixSender";
-import { UserTokenStore } from "./UserTokenStore";
+import { UserTokenStore } from "./tokens/UserTokenStore";
import BotUsersManager from "./Managers/BotUsersManager";
import { retry, retryMatrixErrorFilter } from "./PromiseUtil";
import Metrics from "./Metrics";
import EventEmitter from "events";
+import { HoundConnection } from "./Connections/HoundConnection";
const log = new Logger("ConnectionManager");
@@ -341,6 +343,10 @@ export class ConnectionManager extends EventEmitter {
return this.connections.filter(c => c instanceof FeedConnection && c.feedUrl === url) as FeedConnection[];
}
+ public getConnectionsForHoundChallengeId(challengeId: string): HoundConnection[] {
+ return this.connections.filter(c => c instanceof HoundConnection && c.challengeId === challengeId) as HoundConnection[];
+ }
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
public getAllConnectionsOfType(typeT: new (...params : any[]) => T): T[] {
return this.connections.filter((c) => (c instanceof typeT)) as T[];
diff --git a/src/Connections/FigmaFileConnection.ts b/src/Connections/FigmaFileConnection.ts
index 1a662dede..918fc7360 100644
--- a/src/Connections/FigmaFileConnection.ts
+++ b/src/Connections/FigmaFileConnection.ts
@@ -65,7 +65,7 @@ export class FigmaFileConnection extends BaseConnection implements IConnection {
}
}
- private readonly grantChecker: GrantChecker<{fileId: string, instanceName: string}> = new ConfigGrantChecker("figma", this.as, this.config);
+ private readonly grantChecker: GrantChecker<{fileId: string, instanceName: string}>;
constructor(
roomId: string,
@@ -76,6 +76,7 @@ export class FigmaFileConnection extends BaseConnection implements IConnection {
private readonly intent: Intent,
private readonly storage: IBridgeStorageProvider) {
super(roomId, stateKey, FigmaFileConnection.CanonicalEventType)
+ this.grantChecker = new ConfigGrantChecker("figma", this.as, this.config);
}
public isInterestedInStateEvent() {
diff --git a/src/Connections/GenericHook.ts b/src/Connections/GenericHook.ts
index 5aa87de04..2f0e831f7 100644
--- a/src/Connections/GenericHook.ts
+++ b/src/Connections/GenericHook.ts
@@ -8,9 +8,13 @@ import { Appservice, Intent, StateEvent } from "matrix-bot-sdk";
import { ApiError, ErrCode } from "../api";
import { BaseConnection } from "./BaseConnection";
import { GetConnectionsResponseItem } from "../provisioning/api";
-import { BridgeConfigGenericWebhooks } from "../config/Config";
+import { BridgeConfigGenericWebhooks } from "../config/sections";
import { ensureUserIsInRoom } from "../IntentUtils";
import { randomUUID } from 'node:crypto';
+import { GenericWebhookEventResult } from "../generic/types";
+import { StatusCodes } from "http-status-codes";
+import { IBridgeStorageProvider } from "../Stores/StorageProvider";
+import { formatDuration, isMatch, millisecondsToHours } from "date-fns";
export interface GenericHookConnectionState extends IConnectionState {
/**
@@ -21,11 +25,17 @@ export interface GenericHookConnectionState extends IConnectionState {
* The name given in the provisioning UI and displaynames.
*/
name: string;
- transformationFunction: string|undefined;
+ transformationFunction?: string;
/**
* Should the webhook only respond on completion.
*/
- waitForComplete: boolean|undefined;
+ waitForComplete?: boolean|undefined;
+
+ /**
+ * If the webhook has an expriation date, then the date at which the webhook is no longer value
+ * (in UTC) time.
+ */
+ expirationDate?: string;
}
export interface GenericHookSecrets {
@@ -37,6 +47,10 @@ export interface GenericHookSecrets {
* The hookId of the webhook.
*/
hookId: string;
+ /**
+ * How long remains until the webhook expires.
+ */
+ timeRemainingMs?: number
}
export type GenericHookResponseItem = GetConnectionsResponseItem;
@@ -64,6 +78,14 @@ interface WebhookTransformationResult {
webhookResponse?: WebhookResponse;
}
+export interface GenericHookServiceConfig {
+ userIdPrefix?: string;
+ allowJsTransformationFunctions?: boolean,
+ waitForComplete?: boolean,
+ maxExpiryTime?: number,
+ requireExpiryTime: boolean,
+}
+
const log = new Logger("GenericHookConnection");
const md = new markdownit();
@@ -71,6 +93,12 @@ const TRANSFORMATION_TIMEOUT_MS = 500;
const SANITIZE_MAX_DEPTH = 10;
const SANITIZE_MAX_BREADTH = 50;
+const WARN_AT_EXPIRY_MS = 3 * 24 * 60 * 60 * 1000;
+const MIN_EXPIRY_MS = 60 * 60 * 1000;
+const CHECK_EXPIRY_MS = 15 * 60 * 1000;
+
+const EXPIRY_NOTICE_MESSAGE = "The webhook **%NAME** will be expiring in %TIME."
+
/**
* Handles rooms connected to a generic webhook.
*/
@@ -123,8 +151,8 @@ export class GenericHookConnection extends BaseConnection implements IConnection
return obj;
}
- static validateState(state: Record): GenericHookConnectionState {
- const {name, transformationFunction, waitForComplete} = state;
+ static validateState(state: Partial>): GenericHookConnectionState {
+ const {name, transformationFunction, waitForComplete, expirationDate: expirationDateStr} = state;
if (!name) {
throw new ApiError('Missing name', ErrCode.BadValue);
}
@@ -143,14 +171,26 @@ export class GenericHookConnection extends BaseConnection implements IConnection
throw new ApiError('Transformation functions must be a string', ErrCode.BadValue);
}
}
+ let expirationDate: string|undefined;
+ if (expirationDateStr != undefined) {
+ if (typeof expirationDateStr !== "string" || !expirationDateStr) {
+ throw new ApiError("'expirationDate' must be a non-empty string", ErrCode.BadValue);
+ }
+ if (!isMatch(expirationDateStr, "yyyy-MM-dd'T'HH:mm:ss.SSSXX")) {
+ throw new ApiError("'expirationDate' must be a valid date", ErrCode.BadValue);
+ }
+ expirationDate = expirationDateStr;
+ }
+
return {
name,
transformationFunction: transformationFunction || undefined,
waitForComplete,
+ expirationDate,
};
}
- static async createConnectionForState(roomId: string, event: StateEvent>, {as, intent, config, messageClient}: InstantiateConnectionOpts) {
+ static async createConnectionForState(roomId: string, event: StateEvent>, {as, intent, config, messageClient, storage}: InstantiateConnectionOpts) {
if (!config.generic) {
throw Error('Generic webhooks are not configured');
}
@@ -162,6 +202,10 @@ export class GenericHookConnection extends BaseConnection implements IConnection
if (!hookId) {
hookId = randomUUID();
log.warn(`hookId for ${roomId} not set in accountData, setting to ${hookId}`);
+ // If this is a new hook...
+ if (config.generic.requireExpiryTime && !state.expirationDate) {
+ throw new Error('Expiration date must be set');
+ }
await GenericHookConnection.ensureRoomAccountData(roomId, intent, hookId, event.stateKey);
}
@@ -174,18 +218,41 @@ export class GenericHookConnection extends BaseConnection implements IConnection
config.generic,
as,
intent,
+ storage,
);
}
- static async provisionConnection(roomId: string, userId: string, data: Record = {}, {as, intent, config, messageClient}: ProvisionConnectionOpts) {
+ static async provisionConnection(roomId: string, userId: string, data: Partial> = {}, {as, intent, config, messageClient, storage}: ProvisionConnectionOpts) {
if (!config.generic) {
throw Error('Generic Webhooks are not configured');
}
const hookId = randomUUID();
const validState = GenericHookConnection.validateState(data);
+ if (validState.expirationDate) {
+ const durationRemaining = new Date(validState.expirationDate).getTime() - Date.now();
+ if (config.generic.maxExpiryTimeMs) {
+ if (durationRemaining > config.generic.maxExpiryTimeMs) {
+ throw new ApiError('Expiration date cannot exceed the configured max expiry time', ErrCode.BadValue);
+ }
+ }
+ if (durationRemaining < MIN_EXPIRY_MS) {
+ // If the webhook is actually created with a shorter expiry time than
+ // our warning period, then just mark it as warned.
+ throw new ApiError('Expiration date must at least be a hour in the future', ErrCode.BadValue);
+ }
+ if (durationRemaining < WARN_AT_EXPIRY_MS) {
+ // If the webhook is actually created with a shorter expiry time than
+ // our warning period, then just mark it as warned.
+ await storage.setHasGenericHookWarnedExpiry(hookId, true);
+ }
+ } else if (config.generic.requireExpiryTime) {
+ throw new ApiError('Expiration date must be set', ErrCode.BadValue);
+ }
+
+
await GenericHookConnection.ensureRoomAccountData(roomId, intent, hookId, validState.name);
await intent.underlyingClient.sendStateEvent(roomId, this.CanonicalEventType, validState.name, validState);
- const connection = new GenericHookConnection(roomId, validState, hookId, validState.name, messageClient, config.generic, as, intent);
+ const connection = new GenericHookConnection(roomId, validState, hookId, validState.name, messageClient, config.generic, as, intent, storage);
return {
connection,
stateEventContent: validState,
@@ -218,6 +285,8 @@ export class GenericHookConnection extends BaseConnection implements IConnection
private transformationFunction?: string;
private cachedDisplayname?: string;
+ private warnOnExpiryInterval?: NodeJS.Timeout;
+
/**
* @param state Should be a pre-validated state object returned by {@link validateState}
*/
@@ -230,11 +299,19 @@ export class GenericHookConnection extends BaseConnection implements IConnection
private readonly config: BridgeConfigGenericWebhooks,
private readonly as: Appservice,
private readonly intent: Intent,
+ private readonly storage: IBridgeStorageProvider,
) {
super(roomId, stateKey, GenericHookConnection.CanonicalEventType);
if (state.transformationFunction && GenericHookConnection.quickModule) {
this.transformationFunction = state.transformationFunction;
}
+ this.handleExpiryTimeUpdate(false).catch(ex => {
+ log.warn("Failed to configure expiry time warning for hook", ex);
+ });
+ }
+
+ public get expiresAt(): Date|undefined {
+ return this.state.expirationDate ? new Date(this.state.expirationDate) : undefined;
}
/**
@@ -313,7 +390,49 @@ export class GenericHookConnection extends BaseConnection implements IConnection
} else {
this.transformationFunction = undefined;
}
+
+ const prevDate = this.state.expirationDate;
this.state = validatedConfig;
+ if (prevDate !== validatedConfig.expirationDate) {
+ await this.handleExpiryTimeUpdate(true);
+ }
+ }
+
+ /**
+ * Called when the expiry time has been updated for the connection. If the connection
+ * no longer has an expiry time. This voids the interval.
+ * @returns
+ */
+ private async handleExpiryTimeUpdate(shouldWrite: boolean) {
+ if (!this.config.sendExpiryNotice) {
+ return;
+ }
+ if (this.warnOnExpiryInterval) {
+ clearInterval(this.warnOnExpiryInterval);
+ this.warnOnExpiryInterval = undefined;
+ }
+ if (!this.state.expirationDate) {
+ return;
+ }
+
+ const durationRemaining = new Date(this.state.expirationDate).getTime() - Date.now();
+ if (durationRemaining < WARN_AT_EXPIRY_MS) {
+ // If the webhook is actually created with a shorter expiry time than
+ // our warning period, then just mark it as warned.
+ if (shouldWrite) {
+ await this.storage.setHasGenericHookWarnedExpiry(this.hookId, true);
+ }
+ } else {
+ const fuzzCheckTimeMs = Math.round((Math.random() * CHECK_EXPIRY_MS));
+ this.warnOnExpiryInterval = setInterval(() => {
+ this.checkAndWarnExpiry().catch(ex => {
+ log.warn("Failed to check expiry time for hook", ex);
+ })
+ }, CHECK_EXPIRY_MS + fuzzCheckTimeMs);
+ if (shouldWrite) {
+ await this.storage.setHasGenericHookWarnedExpiry(this.hookId, false);
+ }
+ }
}
public transformHookData(data: unknown): {plain: string, html?: string} {
@@ -424,8 +543,18 @@ export class GenericHookConnection extends BaseConnection implements IConnection
* @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
*/
- public async onGenericHook(data: unknown): Promise<{successful: boolean, response?: WebhookResponse}> {
+ public async onGenericHook(data: unknown): Promise {
log.info(`onGenericHook ${this.roomId} ${this.hookId}`);
+
+ if (this.expiresAt && new Date() >= this.expiresAt) {
+ log.warn("Ignoring incoming webhook. This hook has expired");
+ return {
+ successful: false,
+ statusCode: StatusCodes.NOT_FOUND,
+ error: 'This hook has expired',
+ };
+ }
+
let content: {plain: string, html?: string, msgtype?: string}|undefined;
let webhookResponse: WebhookResponse|undefined;
let successful = true;
@@ -467,7 +596,6 @@ export class GenericHookConnection extends BaseConnection implements IConnection
successful,
response: webhookResponse,
};
-
}
public static getProvisionerDetails(botUserId: string) {
@@ -488,16 +616,19 @@ export class GenericHookConnection extends BaseConnection implements IConnection
transformationFunction: this.state.transformationFunction,
waitForComplete: this.waitForComplete,
name: this.state.name,
+ expirationDate: this.state.expirationDate,
},
...(showSecrets ? { secrets: {
url: new URL(this.hookId, this.config.parsedUrlPrefix),
hookId: this.hookId,
- } as GenericHookSecrets} : undefined)
+ timeRemainingMs: this.expiresAt ? this.expiresAt.getTime() - Date.now() : undefined,
+ } satisfies GenericHookSecrets} : undefined)
}
}
public async onRemove() {
log.info(`Removing ${this.toString()} for ${this.roomId}`);
+ clearInterval(this.warnOnExpiryInterval);
// Do a sanity check that the event exists.
try {
await this.intent.underlyingClient.getRoomStateEvent(this.roomId, GenericHookConnection.CanonicalEventType, this.stateKey);
@@ -509,8 +640,9 @@ export class GenericHookConnection extends BaseConnection implements IConnection
await GenericHookConnection.ensureRoomAccountData(this.roomId, this.intent, this.hookId, this.stateKey, true);
}
- public async provisionerUpdateConfig(userId: string, config: Record) {
+ public async provisionerUpdateConfig(_userId: string, config: Record) {
// Apply previous state to the current config, as provisioners might not return "unknown" keys.
+ config.expirationDate = config.expirationDate ?? undefined;
config = { ...this.state, ...config };
const validatedConfig = GenericHookConnection.validateState(config);
await this.intent.underlyingClient.sendStateEvent(this.roomId, GenericHookConnection.CanonicalEventType, this.stateKey,
@@ -522,6 +654,35 @@ export class GenericHookConnection extends BaseConnection implements IConnection
this.state = validatedConfig;
}
+ private async checkAndWarnExpiry() {
+ const remainingMs = this.expiresAt ? this.expiresAt.getTime() - Date.now() : undefined;
+ if (!remainingMs) {
+ return;
+ }
+ if (remainingMs < CHECK_EXPIRY_MS) {
+ // Nearly expired
+ return;
+ }
+ if (remainingMs > WARN_AT_EXPIRY_MS) {
+ return;
+ }
+ if (await this.storage.getHasGenericHookWarnedExpiry(this.hookId)) {
+ return;
+ }
+ // Warn
+ const markdownStr = EXPIRY_NOTICE_MESSAGE.replace('%NAME', this.state.name).replace('%TIME', formatDuration({
+ hours: millisecondsToHours(remainingMs)
+ }));
+ await this.messageClient.sendMatrixMessage(this.roomId, {
+ msgtype: "m.notice",
+ body: markdownStr,
+ // render can output redundant trailing newlines, so trim it.
+ formatted_body: md.render(markdownStr).trim(),
+ format: "org.matrix.custom.html",
+ }, 'm.room.message', this.getUserId());
+ await this.storage.setHasGenericHookWarnedExpiry(this.hookId, true);
+ }
+
public toString() {
return `GenericHookConnection ${this.hookId}`;
}
diff --git a/src/Connections/GithubDiscussion.ts b/src/Connections/GithubDiscussion.ts
index 5529118ca..d3787d1d0 100644
--- a/src/Connections/GithubDiscussion.ts
+++ b/src/Connections/GithubDiscussion.ts
@@ -1,6 +1,6 @@
import { Connection, IConnection, InstantiateConnectionOpts } from "./IConnection";
import { Appservice, Intent, StateEvent } from "matrix-bot-sdk";
-import { UserTokenStore } from "../UserTokenStore";
+import { UserTokenStore } from "../tokens/UserTokenStore";
import { CommentProcessor } from "../CommentProcessor";
import { MessageSenderClient } from "../MatrixSender";
import { ensureUserIsInRoom, getIntentForUser } from "../IntentUtils";
diff --git a/src/Connections/GithubIssue.ts b/src/Connections/GithubIssue.ts
index 449349d4d..f5656e49a 100644
--- a/src/Connections/GithubIssue.ts
+++ b/src/Connections/GithubIssue.ts
@@ -2,7 +2,7 @@ import { Connection, IConnection, InstantiateConnectionOpts } from "./IConnectio
import { Appservice, Intent, StateEvent } from "matrix-bot-sdk";
import { MatrixMessageContent, MatrixEvent } from "../MatrixEvent";
import markdown from "markdown-it";
-import { UserTokenStore } from "../UserTokenStore";
+import { UserTokenStore } from "../tokens/UserTokenStore";
import { Logger } from "matrix-appservice-bridge";
import { CommentProcessor } from "../CommentProcessor";
import { MessageSenderClient } from "../MatrixSender";
@@ -20,7 +20,6 @@ export interface GitHubIssueConnectionState {
repo: string;
state: string;
issues: string[];
- // eslint-disable-next-line camelcase
comments_processed: number;
}
diff --git a/src/Connections/GithubProject.ts b/src/Connections/GithubProject.ts
index f9e99fe25..6d21db51b 100644
--- a/src/Connections/GithubProject.ts
+++ b/src/Connections/GithubProject.ts
@@ -7,7 +7,6 @@ import { ConfigGrantChecker, GrantChecker } from "../grants/GrantCheck";
import { BridgeConfig } from "../config/Config";
export interface GitHubProjectConnectionState {
- // eslint-disable-next-line camelcase
project_id: number;
state: "open"|"closed";
}
diff --git a/src/Connections/GithubRepo.ts b/src/Connections/GithubRepo.ts
index 6e77191db..bdd863b77 100644
--- a/src/Connections/GithubRepo.ts
+++ b/src/Connections/GithubRepo.ts
@@ -13,7 +13,7 @@ import { MatrixMessageContent, MatrixEvent, MatrixReactionContent } from "../Mat
import { MessageSenderClient } from "../MatrixSender";
import { CommandError, NotLoggedInError } from "../errors";
import { ReposGetResponseData } from "../github/Types";
-import { UserTokenStore } from "../UserTokenStore";
+import { UserTokenStore } from "../tokens/UserTokenStore";
import axios, { AxiosError } from "axios";
import { emojify } from "node-emoji";
import { Logger } from "matrix-appservice-bridge";
@@ -549,7 +549,7 @@ export class GitHubRepoConnection extends CommandConnection, timeout: NodeJS.Timeout}>();
- private readonly grantChecker = new GitHubGrantChecker(this.as, this.tokenStore);
+ private readonly grantChecker;
constructor(
roomId: string,
@@ -576,6 +576,7 @@ export class GitHubRepoConnection extends CommandConnection " + result.commentNotes.join("\n\n> ");
+ formatted = md.render(content);
+ } else {
+ formatted = md.renderInline(content);
}
const eventPromise = this.intent.sendEvent(this.roomId, {
msgtype: "m.notice",
body: content,
- formatted_body: md.renderInline(content),
+ formatted_body: formatted,
format: "org.matrix.custom.html",
...relation,
}).catch(ex => {
@@ -845,7 +863,7 @@ ${data.description}`;
if (this.hookFilter.shouldSkip('merge_request', 'merge_request.review', `merge_request.${event.object_attributes.action}`) || !this.matchesLabelFilter(event)) {
return;
}
- log.info(`onMergeRequestReviewed ${this.roomId} ${this.instance}/${this.path} ${event.object_attributes.iid}`);
+ log.info(`onMergeRequestReviewed ${this.roomId} ${this.instance}/${this.path} !${event.object_attributes.iid}`);
this.validateMREvent(event);
this.debounceMergeRequestReview(
event.user,
@@ -865,7 +883,7 @@ ${data.description}`;
return;
}
- log.info(`onMergeRequestReviewed ${this.roomId} ${this.instance}/${this.path} ${event.object_attributes.iid}`);
+ log.info(`onMergeRequestReviewed ${this.roomId} ${this.instance}/${this.path} !${event.object_attributes.iid}`);
this.validateMREvent(event);
this.debounceMergeRequestReview(
event.user,
@@ -883,7 +901,7 @@ ${data.description}`;
if (this.hookFilter.shouldSkip('merge_request', 'merge_request.review')) {
return;
}
- log.info(`onCommentCreated ${this.roomId} ${this.toString()} ${event.merge_request?.iid} ${event.object_attributes.id}`);
+ log.info(`onCommentCreated ${this.roomId} ${this.toString()} !${event.merge_request?.iid} ${event.object_attributes.id}`);
if (!event.merge_request || event.object_attributes.noteable_type !== "MergeRequest") {
// Not a MR comment
return;
diff --git a/src/Connections/HoundConnection.ts b/src/Connections/HoundConnection.ts
new file mode 100644
index 000000000..a70c2e444
--- /dev/null
+++ b/src/Connections/HoundConnection.ts
@@ -0,0 +1,226 @@
+import { Intent, StateEvent } from "matrix-bot-sdk";
+import markdownit from "markdown-it";
+import { BaseConnection } from "./BaseConnection";
+import { IConnection, IConnectionState } from ".";
+import { Connection, InstantiateConnectionOpts, ProvisionConnectionOpts } from "./IConnection";
+import { CommandError } from "../errors";
+import { IBridgeStorageProvider } from "../Stores/StorageProvider";
+import { Logger } from "matrix-appservice-bridge";
+export interface HoundConnectionState extends IConnectionState {
+ challengeId: string;
+}
+
+export interface HoundPayload {
+ activity: HoundActivity,
+ challengeId: string,
+}
+
+/**
+ * @url https://documenter.getpostman.com/view/22349866/UzXLzJUV#0913e0b9-9cb5-440e-9d8d-bf6430285ee9
+ */
+export interface HoundActivity {
+ userId: string,
+ activityId: string,
+ participant: string,
+ /**
+ * @example "07/26/2022"
+ */
+ date: string,
+ /**
+ * @example "2022-07-26T13:49:22Z"
+ */
+ datetime: string,
+ name: string,
+ type: string,
+ /**
+ * @example strava
+ */
+ app: string,
+ durationSeconds: number,
+ /**
+ * @example "1.39"
+ */
+ distanceKilometers: string,
+ /**
+ * @example "0.86"
+ */
+ distanceMiles: string,
+ /**
+ * @example "0.86"
+ */
+ elevationMeters: string,
+ /**
+ * @example "0.86"
+ */
+ elevationFeet: string,
+}
+
+export interface IChallenge {
+ id: string;
+ distance: number;
+ duration: number;
+ elevaion: number;
+}
+
+export interface ILeader {
+ id: string;
+ fullname: string;
+ duration: number;
+ distance: number;
+ elevation: number;
+}
+
+function getEmojiForType(type: string) {
+ switch (type) {
+ case "run":
+ return "🏃";
+ case "virtualrun":
+ return "👨💻🏃";
+ case "ride":
+ case "cycle":
+ case "cycling":
+ return "🚴";
+ case "mountainbikeride":
+ return "⛰️🚴";
+ case "virtualride":
+ return "👨💻🚴";
+ case "walk":
+ case "hike":
+ return "🚶";
+ case "skateboard":
+ return "🛹";
+ case "virtualwalk":
+ case "virtualhike":
+ return "👨💻🚶";
+ case "alpineski":
+ return "⛷️";
+ case "swim":
+ return "🏊";
+ default:
+ return "🕴️";
+ }
+}
+
+const log = new Logger("HoundConnection");
+const md = markdownit();
+@Connection
+export class HoundConnection extends BaseConnection implements IConnection {
+ static readonly CanonicalEventType = "uk.half-shot.matrix-hookshot.challengehound.activity";
+ static readonly LegacyEventType = "uk.half-shot.matrix-challenger.activity"; // Magically import from matrix-challenger
+
+ static readonly EventTypes = [
+ HoundConnection.CanonicalEventType,
+ HoundConnection.LegacyEventType,
+ ];
+ static readonly ServiceCategory = "challengehound";
+
+ public static getIdFromURL(url: string): string {
+ const parts = new URL(url).pathname.split('/');
+ return parts[parts.length-1];
+ }
+
+ public static validateState(data: Record): HoundConnectionState {
+ // Convert URL to ID.
+ if (!data.challengeId && data.url && typeof data.url === "string") {
+ data.challengeId = this.getIdFromURL(data.url);
+ }
+
+ // Test for v1 uuid.
+ if (!data.challengeId || typeof data.challengeId !== "string" || !/^\w{8}(?:-\w{4}){3}-\w{12}$/.test(data.challengeId)) {
+ throw Error('Missing or invalid id');
+ }
+
+ return {
+ challengeId: data.challengeId
+ }
+ }
+
+ public static createConnectionForState(roomId: string, event: StateEvent>, {config, intent, storage}: InstantiateConnectionOpts) {
+ if (!config.challengeHound) {
+ throw Error('Challenge hound is not configured');
+ }
+ return new HoundConnection(roomId, event.stateKey, this.validateState(event.content), intent, storage);
+ }
+
+ static async provisionConnection(roomId: string, _userId: string, data: Record = {}, {intent, config, storage}: ProvisionConnectionOpts) {
+ if (!config.challengeHound) {
+ throw Error('Challenge hound is not configured');
+ }
+ const validState = this.validateState(data);
+ // Check the event actually exists.
+ const statusDataRequest = await fetch(`https://api.challengehound.com/challenges/${validState.challengeId}/status`);
+ if (!statusDataRequest.ok) {
+ throw new CommandError(`Fetch failed, status ${statusDataRequest.status}`, "Challenge could not be found. Is it active?");
+ }
+ const { challengeName } = await statusDataRequest.json() as {challengeName: string};
+ const connection = new HoundConnection(roomId, validState.challengeId, validState, intent, storage);
+ await intent.underlyingClient.sendStateEvent(roomId, HoundConnection.CanonicalEventType, validState.challengeId, validState);
+ return {
+ connection,
+ stateEventContent: validState,
+ challengeName,
+ };
+ }
+
+ constructor(
+ roomId: string,
+ stateKey: string,
+ private state: HoundConnectionState,
+ private readonly intent: Intent,
+ private readonly storage: IBridgeStorageProvider) {
+ super(roomId, stateKey, HoundConnection.CanonicalEventType)
+ }
+
+ public isInterestedInStateEvent() {
+ return false; // We don't support state-updates...yet.
+ }
+
+ public get challengeId() {
+ return this.state.challengeId;
+ }
+
+ public get priority(): number {
+ return this.state.priority || super.priority;
+ }
+
+ public async handleNewActivity(activity: HoundActivity) {
+ log.info(`New activity recorded ${activity.activityId}`);
+ const existingActivityEventId = await this.storage.getHoundActivity(this.challengeId, activity.activityId);
+ const distance = parseFloat(activity.distanceKilometers);
+ const distanceUnits = `${(distance).toFixed(2)}km`;
+ const emoji = getEmojiForType(activity.type);
+ const body = `🎉 **${activity.participant}** completed a ${distanceUnits} ${emoji} ${activity.type} (${activity.name})`;
+ let content: any = {
+ body,
+ format: "org.matrix.custom.html",
+ formatted_body: md.renderInline(body),
+ };
+ content["msgtype"] = "m.notice";
+ content["uk.half-shot.matrix-challenger.activity.id"] = activity.activityId;
+ content["uk.half-shot.matrix-challenger.activity.distance"] = Math.round(distance * 1000);
+ content["uk.half-shot.matrix-challenger.activity.elevation"] = Math.round(parseFloat(activity.elevationMeters));
+ content["uk.half-shot.matrix-challenger.activity.duration"] = Math.round(activity.durationSeconds);
+ content["uk.half-shot.matrix-challenger.activity.user"] = {
+ "name": activity.participant,
+ id: activity.userId,
+ };
+ if (existingActivityEventId) {
+ log.debug(`Updating existing activity ${activity.activityId} ${existingActivityEventId}`);
+ content = {
+ body: `* ${content.body}`,
+ msgtype: "m.notice",
+ "m.new_content": content,
+ "m.relates_to": {
+ "event_id": existingActivityEventId,
+ "rel_type": "m.replace"
+ },
+ };
+ }
+ const eventId = await this.intent.underlyingClient.sendMessage(this.roomId, content);
+ await this.storage.storeHoundActivityEvent(this.challengeId, activity.activityId, eventId);
+ }
+
+ public toString() {
+ return `HoundConnection ${this.challengeId}`;
+ }
+}
diff --git a/src/Connections/IConnection.ts b/src/Connections/IConnection.ts
index 9fe63a9a3..c01c8878d 100644
--- a/src/Connections/IConnection.ts
+++ b/src/Connections/IConnection.ts
@@ -3,7 +3,7 @@ import { IssuesOpenedEvent, IssuesEditedEvent } from "@octokit/webhooks-types";
import { ConnectionWarning, GetConnectionsResponseItem } from "../provisioning/api";
import { Appservice, Intent, IRichReplyMetadata, StateEvent } from "matrix-bot-sdk";
import { BridgeConfig, BridgePermissionLevel } from "../config/Config";
-import { UserTokenStore } from "../UserTokenStore";
+import { UserTokenStore } from "../tokens/UserTokenStore";
import { CommentProcessor } from "../CommentProcessor";
import { MessageSenderClient } from "../MatrixSender";
import { IBridgeStorageProvider } from "../Stores/StorageProvider";
diff --git a/src/Connections/JiraProject.ts b/src/Connections/JiraProject.ts
index a1aa2acd3..05aabec1d 100644
--- a/src/Connections/JiraProject.ts
+++ b/src/Connections/JiraProject.ts
@@ -9,7 +9,7 @@ import { JiraProject, JiraVersion } from "../jira/Types";
import { botCommand, BotCommands, compileBotCommands } from "../BotCommands";
import { MatrixMessageContent } from "../MatrixEvent";
import { CommandConnection } from "./CommandConnection";
-import { UserTokenStore } from "../UserTokenStore";
+import { UserTokenStore } from "../tokens/UserTokenStore";
import { CommandError, NotLoggedInError } from "../errors";
import { ApiError, ErrCode } from "../api";
import JiraApi from "jira-client";
diff --git a/src/Connections/OutboundHook.ts b/src/Connections/OutboundHook.ts
new file mode 100644
index 000000000..a6c4301b2
--- /dev/null
+++ b/src/Connections/OutboundHook.ts
@@ -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;
+
+
+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): 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>, {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 = {}, {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): Promise<{blob: Blob, event: MatrixEvent}|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): Promise {
+ // 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) {
+ 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}`;
+ }
+}
\ No newline at end of file
diff --git a/src/Connections/SetupConnection.ts b/src/Connections/SetupConnection.ts
index 9f4893f3a..176a11b79 100644
--- a/src/Connections/SetupConnection.ts
+++ b/src/Connections/SetupConnection.ts
@@ -1,7 +1,6 @@
-// We need to instantiate some functions which are not directly called, which confuses typescript.
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";
@@ -15,9 +14,13 @@ import { IConnection, IConnectionState, ProvisionConnectionOpts } from "./IConne
import { ApiError, Logger } from "matrix-appservice-bridge";
import { Intent } from "matrix-bot-sdk";
import YAML from 'yaml';
+import parseDuration from 'parse-duration';
+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.
@@ -72,13 +75,13 @@ export class SetupConnection extends CommandConnection {
this.includeTitlesInHelp = false;
}
- @botCommand("github repo", { help: "Create a connection for a GitHub repository. (You must be logged in with GitHub to do this.)", requiredArgs: ["url"], includeUserId: true, category: "github"})
+ @botCommand("github repo", { help: "Create a connection for a GitHub repository. (You must be logged in with GitHub to do this.)", requiredArgs: ["url"], includeUserId: true, category: GitHubRepoConnection.ServiceCategory})
public async onGitHubRepo(userId: string, url: string) {
if (!this.provisionOpts.github || !this.config.github) {
throw new CommandError("not-configured", "The bridge is not configured to support GitHub.");
}
- await this.checkUserPermissions(userId, "github", GitHubRepoConnection.CanonicalEventType);
+ await this.checkUserPermissions(userId, GitHubRepoConnection.ServiceCategory, GitHubRepoConnection.CanonicalEventType);
const octokit = await this.provisionOpts.tokenStore.getOctokitForUser(userId);
if (!octokit) {
throw new CommandError("User not logged in", "You are not logged into GitHub. Start a DM with this bot and use the command `github login`.");
@@ -93,13 +96,13 @@ export class SetupConnection extends CommandConnection {
await this.client.sendNotice(this.roomId, `Room configured to bridge ${connection.org}/${connection.repo}`);
}
- @botCommand("gitlab project", { help: "Create a connection for a GitHub project. (You must be logged in with GitLab to do this.)", requiredArgs: ["url"], includeUserId: true, category: "gitlab"})
+ @botCommand("gitlab project", { help: "Create a connection for a GitHub project. (You must be logged in with GitLab to do this.)", requiredArgs: ["url"], includeUserId: true, category: GitLabRepoConnection.ServiceCategory})
public async onGitLabRepo(userId: string, url: string) {
if (!this.config.gitlab) {
throw new CommandError("not-configured", "The bridge is not configured to support GitLab.");
}
- await this.checkUserPermissions(userId, "gitlab", GitLabRepoConnection.CanonicalEventType);
+ await this.checkUserPermissions(userId, GitLabRepoConnection.ServiceCategory, GitLabRepoConnection.CanonicalEventType);
const {name, instance} = this.config.gitlab.getInstanceByProjectUrl(url) || {};
if (!instance || !name) {
@@ -126,7 +129,7 @@ export class SetupConnection extends CommandConnection {
}
}
- private async getJiraProjectSafeUrl(userId: string, urlStr: string) {
+ private async getJiraProjectSafeUrl(urlStr: string) {
const url = new URL(urlStr);
const urlParts = /\/projects\/(\w+)\/?(\w+\/?)*$/.exec(url.pathname);
const projectKey = urlParts?.[1] || url.searchParams.get('projectKey');
@@ -136,22 +139,22 @@ export class SetupConnection extends CommandConnection {
return `https://${url.host}/projects/${projectKey}`;
}
- @botCommand("jira project", { help: "Create a connection for a JIRA project. (You must be logged in with JIRA to do this.)", requiredArgs: ["url"], includeUserId: true, category: "jira"})
+ @botCommand("jira project", { help: "Create a connection for a JIRA project. (You must be logged in with JIRA to do this.)", requiredArgs: ["url"], includeUserId: true, category: JiraProjectConnection.ServiceCategory})
public async onJiraProject(userId: string, urlStr: string) {
if (!this.config.jira) {
throw new CommandError("not-configured", "The bridge is not configured to support Jira.");
}
- await this.checkUserPermissions(userId, "jira", JiraProjectConnection.CanonicalEventType);
+ await this.checkUserPermissions(userId, JiraProjectConnection.ServiceCategory, JiraProjectConnection.CanonicalEventType);
await this.checkJiraLogin(userId, urlStr);
- const safeUrl = await this.getJiraProjectSafeUrl(userId, urlStr);
+ const safeUrl = await this.getJiraProjectSafeUrl(urlStr);
const res = await JiraProjectConnection.provisionConnection(this.roomId, userId, { url: safeUrl }, this.provisionOpts);
this.pushConnections(res.connection);
await this.client.sendNotice(this.roomId, `Room configured to bridge Jira project ${res.connection.projectKey}.`);
}
- @botCommand("jira list project", { help: "Show JIRA projects currently connected to.", category: "jira"})
+ @botCommand("jira list project", { help: "Show JIRA projects currently connected to.", category: JiraProjectConnection.ServiceCategory})
public async onJiraListProject() {
const projects: JiraProjectConnectionState[] = await this.client.getRoomState(this.roomId).catch((err: any) => {
if (err.body.errcode === 'M_NOT_FOUND') {
@@ -177,11 +180,11 @@ export class SetupConnection extends CommandConnection {
}
}
- @botCommand("jira remove project", { help: "Remove a connection for a JIRA project.", requiredArgs: ["url"], includeUserId: true, category: "jira"})
+ @botCommand("jira remove project", { help: "Remove a connection for a JIRA project.", requiredArgs: ["url"], includeUserId: true, category: JiraProjectConnection.ServiceCategory})
public async onJiraRemoveProject(userId: string, urlStr: string) {
- await this.checkUserPermissions(userId, "jira", JiraProjectConnection.CanonicalEventType);
+ await this.checkUserPermissions(userId, JiraProjectConnection.ServiceCategory, JiraProjectConnection.CanonicalEventType);
await this.checkJiraLogin(userId, urlStr);
- const safeUrl = await this.getJiraProjectSafeUrl(userId, urlStr);
+ const safeUrl = await this.getJiraProjectSafeUrl(urlStr);
const eventTypes = [
JiraProjectConnection.CanonicalEventType,
@@ -207,18 +210,27 @@ export class SetupConnection extends CommandConnection {
return this.client.sendHtmlNotice(this.roomId, md.renderInline(`Room no longer bridged to Jira project \`${safeUrl}\`.`));
}
- @botCommand("webhook", { help: "Create an inbound webhook.", requiredArgs: ["name"], includeUserId: true, category: "generic"})
- public async onWebhook(userId: string, name: string) {
+ @botCommand("webhook", { help: "Create an inbound webhook. The liveDuration must be specified as a duration string (e.g. 30d).", requiredArgs: ["name"], includeUserId: true, optionalArgs: ['liveDuration'], category: GenericHookConnection.ServiceCategory})
+ public async onWebhook(userId: string, name: string, liveDuration?: string) {
if (!this.config.generic?.enabled) {
throw new CommandError("not-configured", "The bridge is not configured to support webhooks.");
}
+ let expirationDate: string|undefined = undefined;
+ if (liveDuration) {
+ const expirationDuration = parseDuration(liveDuration);
+ if (!expirationDuration) {
+ throw new CommandError("Bad webhook duration", "A webhook name must be between 3-64 characters.");
+ }
+ expirationDate = new Date(expirationDuration + Date.now()).toISOString();
+ }
+
await this.checkUserPermissions(userId, "webhooks", GitHubRepoConnection.CanonicalEventType);
if (!name || name.length < 3 || name.length > 64) {
throw new CommandError("Bad webhook name", "A webhook name must be between 3-64 characters.");
}
- const c = await GenericHookConnection.provisionConnection(this.roomId, userId, {name}, this.provisionOpts);
+ const c = await GenericHookConnection.provisionConnection(this.roomId, userId, {name, expirationDate}, this.provisionOpts);
this.pushConnections(c.connection);
const url = new URL(c.connection.hookId, this.config.generic.parsedUrlPrefix);
const adminRoom = await this.getOrCreateAdminRoom(this.intent, userId);
@@ -234,7 +246,7 @@ export class SetupConnection extends CommandConnection {
- @botCommand("webhook list", { help: "Show webhooks currently configured.", category: "generic"})
+ @botCommand("webhook list", { help: "Show webhooks currently configured.", category: GenericHookConnection.ServiceCategory})
public async onWebhookList() {
const webhooks: GenericHookConnectionState[] = await this.client.getRoomState(this.roomId).catch((err: any) => {
if (err.body.errcode === 'M_NOT_FOUND') {
@@ -263,9 +275,9 @@ export class SetupConnection extends CommandConnection {
}
}
- @botCommand("webhook remove", { help: "Remove a webhook from the room.", requiredArgs: ["name"], includeUserId: true, category: "generic"})
+ @botCommand("webhook remove", { help: "Remove a webhook from the room.", requiredArgs: ["name"], includeUserId: true, category: GenericHookConnection.ServiceCategory})
public async onWebhookRemove(userId: string, name: string) {
- await this.checkUserPermissions(userId, "generic", GenericHookConnection.CanonicalEventType);
+ await this.checkUserPermissions(userId, GenericHookConnection.ServiceCategory, GenericHookConnection.CanonicalEventType);
const event = await this.client.getRoomStateEvent(this.roomId, GenericHookConnection.CanonicalEventType, name).catch((err: any) => {
if (err.body.errcode === 'M_NOT_FOUND') {
@@ -284,13 +296,42 @@ export class SetupConnection extends CommandConnection {
return this.client.sendHtmlNotice(this.roomId, md.renderInline(`Removed webhook \`${name}\``));
}
- @botCommand("figma file", { help: "Bridge a Figma file to the room.", requiredArgs: ["url"], includeUserId: true, category: "figma"})
+
+
+ @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) {
throw new CommandError("not-configured", "The bridge is not configured to support Figma.");
}
- await this.checkUserPermissions(userId, "figma", FigmaFileConnection.CanonicalEventType);
+ await this.checkUserPermissions(userId, FigmaFileConnection.ServiceCategory, FigmaFileConnection.CanonicalEventType);
const res = /https:\/\/www\.figma\.com\/file\/(\w+).+/.exec(url);
if (!res) {
@@ -302,13 +343,13 @@ export class SetupConnection extends CommandConnection {
return this.client.sendHtmlNotice(this.roomId, md.renderInline(`Room configured to bridge Figma file.`));
}
- @botCommand("feed", { help: "Bridge an RSS/Atom feed to the room.", requiredArgs: ["url"], optionalArgs: ["label"], includeUserId: true, category: "feeds"})
+ @botCommand("feed", { help: "Bridge an RSS/Atom feed to the room.", requiredArgs: ["url"], optionalArgs: ["label"], includeUserId: true, category: FeedConnection.ServiceCategory})
public async onFeed(userId: string, url: string, label?: string) {
if (!this.config.feeds?.enabled) {
throw new CommandError("not-configured", "The bridge is not configured to support feeds.");
}
- await this.checkUserPermissions(userId, "feed", FeedConnection.CanonicalEventType);
+ await this.checkUserPermissions(userId,FeedConnection.ServiceCategory, FeedConnection.CanonicalEventType);
// provisionConnection will check it again, but won't give us a nice CommandError on failure
try {
@@ -327,7 +368,7 @@ export class SetupConnection extends CommandConnection {
return this.client.sendHtmlNotice(this.roomId, md.renderInline(`Room configured to bridge \`${url}\``));
}
- @botCommand("feed list", { help: "Show feeds currently subscribed to. Supported formats `json` and `yaml`.", optionalArgs: ["format"], category: "feeds"})
+ @botCommand("feed list", { help: "Show feeds currently subscribed to. Supported formats `json` and `yaml`.", optionalArgs: ["format"], category: FeedConnection.ServiceCategory})
public async onFeedList(format?: string) {
const useJsonFormat = format?.toLowerCase() === 'json';
const useYamlFormat = format?.toLowerCase() === 'yaml';
@@ -373,7 +414,7 @@ export class SetupConnection extends CommandConnection {
@botCommand("feed remove", { help: "Unsubscribe from an RSS/Atom feed.", requiredArgs: ["url"], includeUserId: true, category: "feeds"})
public async onFeedRemove(userId: string, url: string) {
- await this.checkUserPermissions(userId, "feed", FeedConnection.CanonicalEventType);
+ await this.checkUserPermissions(userId, FeedConnection.ServiceCategory, FeedConnection.CanonicalEventType);
const event = await this.client.getRoomStateEvent(this.roomId, FeedConnection.CanonicalEventType, url).catch((err: any) => {
if (err.body.errcode === 'M_NOT_FOUND') {
@@ -389,6 +430,36 @@ export class SetupConnection extends CommandConnection {
return this.client.sendHtmlNotice(this.roomId, md.renderInline(`Unsubscribed from \`${url}\``));
}
+ @botCommand("challenghound add", { help: "Bridge a ChallengeHound challenge to the room.", requiredArgs: ["url"], includeUserId: true, category: "challengehound"})
+ public async onChallengeHoundAdd(userId: string, url: string) {
+ if (!this.config.challengeHound) {
+ throw new CommandError("not-configured", "The bridge is not configured to support challengeHound.");
+ }
+
+ await this.checkUserPermissions(userId, HoundConnection.ServiceCategory, HoundConnection.CanonicalEventType);
+ const {connection, challengeName} = await HoundConnection.provisionConnection(this.roomId, userId, { url }, this.provisionOpts);
+ this.pushConnections(connection);
+ return this.client.sendHtmlNotice(this.roomId, md.renderInline(`Room configured to bridge ${challengeName}. Good luck!`));
+ }
+
+ @botCommand("challenghound remove", { help: "Unbridge a ChallengeHound challenge.", requiredArgs: ["urlOrId"], includeUserId: true, category: HoundConnection.ServiceCategory})
+ public async onChallengeHoundRemove(userId: string, urlOrId: string) {
+ await this.checkUserPermissions(userId, HoundConnection.ServiceCategory, HoundConnection.CanonicalEventType);
+ const id = urlOrId.startsWith('http') ? HoundConnection.getIdFromURL(urlOrId) : urlOrId;
+ const event = await this.client.getRoomStateEvent(this.roomId, HoundConnection.CanonicalEventType, id).catch((err: any) => {
+ if (err.body.errcode === 'M_NOT_FOUND') {
+ return null; // not an error to us
+ }
+ throw err;
+ });
+ if (!event || Object.keys(event).length === 0) {
+ throw new CommandError("Invalid feed URL", `Challenge "${id}" is not currently bridged to this room`);
+ }
+
+ await this.client.sendStateEvent(this.roomId, FeedConnection.CanonicalEventType, id, {});
+ return this.client.sendHtmlNotice(this.roomId, md.renderInline(`Unsubscribed from challenge`));
+ }
+
@botCommand("setup-widget", {category: "widget", help: "Open the setup widget in the room"})
public async onSetupWidget() {
if (this.config.widgets?.roomSetupWidget === undefined) {
diff --git a/src/Connections/index.ts b/src/Connections/index.ts
index d56143c98..c06b97cbe 100644
--- a/src/Connections/index.ts
+++ b/src/Connections/index.ts
@@ -10,4 +10,5 @@ export * from "./GitlabRepo";
export * from "./IConnection";
export * from "./JiraProject";
export * from "./FigmaFileConnection";
-export * from "./FeedConnection";
\ No newline at end of file
+export * from "./FeedConnection";
+export * from "./OutboundHook";
\ No newline at end of file
diff --git a/src/Gitlab/GrantChecker.ts b/src/Gitlab/GrantChecker.ts
index 66bd7bea7..de759fc6e 100644
--- a/src/Gitlab/GrantChecker.ts
+++ b/src/Gitlab/GrantChecker.ts
@@ -3,7 +3,7 @@ import { Appservice } from "matrix-bot-sdk";
import { BridgeConfigGitLab } from "../config/Config";
import { GitLabRepoConnection } from "../Connections";
import { GrantChecker } from "../grants/GrantCheck";
-import { UserTokenStore } from "../UserTokenStore";
+import { UserTokenStore } from "../tokens/UserTokenStore";
const log = new Logger('GitLabGrantChecker');
diff --git a/src/Gitlab/Types.ts b/src/Gitlab/Types.ts
index b7a29198b..956b8a823 100644
--- a/src/Gitlab/Types.ts
+++ b/src/Gitlab/Types.ts
@@ -1,4 +1,3 @@
-/* eslint-disable camelcase */
export interface GitLabAuthor {
id: number;
name: string;
diff --git a/src/Gitlab/WebhookTypes.ts b/src/Gitlab/WebhookTypes.ts
index c721367a6..c331df03d 100644
--- a/src/Gitlab/WebhookTypes.ts
+++ b/src/Gitlab/WebhookTypes.ts
@@ -1,5 +1,3 @@
-/* eslint-disable camelcase */
-
export interface IGitLabWebhookEvent {
object_kind: string;
}
@@ -64,9 +62,9 @@ export interface IGitLabWebhookMREvent {
object_attributes: IGitLabMergeRequestObjectAttributes;
labels: IGitLabLabel[];
changes: {
- [key: string]: {
- before: string;
- after: string;
+ draft?: {
+ previous: boolean;
+ current: boolean;
}
}
}
diff --git a/src/ListenerService.ts b/src/ListenerService.ts
index 32ef379f6..62f2168ec 100644
--- a/src/ListenerService.ts
+++ b/src/ListenerService.ts
@@ -30,6 +30,7 @@ export class ListenerService {
}
for (const listenerConfig of config) {
const app = expressApp();
+ app.set('x-powered-by', false);
app.use(Handlers.requestHandler());
this.listeners.push({
config: listenerConfig,
diff --git a/src/Managers/BotUsersManager.ts b/src/Managers/BotUsersManager.ts
index ab7ab871c..5927cd117 100644
--- a/src/Managers/BotUsersManager.ts
+++ b/src/Managers/BotUsersManager.ts
@@ -171,13 +171,10 @@ export default class BotUsersManager {
// Determine if an avatar update is needed
if (profile.avatar_url) {
try {
- const res = await axios.get(
- botUser.intent.underlyingClient.mxcToHttp(profile.avatar_url),
- { responseType: "arraybuffer" },
- );
+ const res = await botUser.intent.underlyingClient.downloadContent(profile.avatar_url);
const currentAvatarImage = {
- image: Buffer.from(res.data),
- contentType: res.headers["content-type"],
+ image: res.data,
+ contentType: res.contentType,
};
if (
currentAvatarImage.image.equals(avatarImage.image)
diff --git a/src/MatrixEvent.ts b/src/MatrixEvent.ts
index 5fa42599a..67bb5e90b 100644
--- a/src/MatrixEvent.ts
+++ b/src/MatrixEvent.ts
@@ -1,4 +1,3 @@
-/* eslint-disable camelcase */
export interface MatrixEvent {
content: T;
event_id: string;
@@ -8,10 +7,7 @@ export interface MatrixEvent {
type: string;
}
-// eslint-disable-next-line @typescript-eslint/no-empty-interface
-export interface MatrixEventContent {
-
-}
+type MatrixEventContent = object;
export interface MatrixMemberContent extends MatrixEventContent {
avatar_url: string|null;
diff --git a/src/MatrixSender.ts b/src/MatrixSender.ts
index 924d1f9e8..36b59e127 100644
--- a/src/MatrixSender.ts
+++ b/src/MatrixSender.ts
@@ -34,7 +34,7 @@ export class MatrixSender {
try {
await this.sendMatrixMessage(msg.messageId || randomUUID(), msg.data);
} catch (ex) {
- log.error(`Failed to send message (${msg.data.roomId}, ${msg.data.sender}, ${msg.data.type})`);
+ log.error(`Failed to send message (${msg.data.roomId}, ${msg.data.sender}, ${msg.data.type})`, ex);
}
});
}
diff --git a/src/MessageQueue/MessageQueue.ts b/src/MessageQueue/MessageQueue.ts
index 476e6d862..03dc1d17f 100644
--- a/src/MessageQueue/MessageQueue.ts
+++ b/src/MessageQueue/MessageQueue.ts
@@ -1,4 +1,4 @@
-import { BridgeConfigQueue } from "../config/Config";
+import { BridgeConfigQueue } from "../config/sections";
import { LocalMQ } from "./LocalMQ";
import { RedisMQ } from "./RedisQueue";
import { MessageQueue } from "./Types";
@@ -6,8 +6,8 @@ import { MessageQueue } from "./Types";
const staticLocalMq = new LocalMQ();
let staticRedisMq: RedisMQ|null = null;
-export function createMessageQueue(config: BridgeConfigQueue): MessageQueue {
- if (config.monolithic) {
+export function createMessageQueue(config?: BridgeConfigQueue): MessageQueue {
+ if (!config) {
return staticLocalMq;
}
if (staticRedisMq === null) {
diff --git a/src/MessageQueue/RedisQueue.ts b/src/MessageQueue/RedisQueue.ts
index 2fbda2f3a..0800da894 100644
--- a/src/MessageQueue/RedisQueue.ts
+++ b/src/MessageQueue/RedisQueue.ts
@@ -1,7 +1,7 @@
import { MessageQueue, MessageQueueMessage, DEFAULT_RES_TIMEOUT, MessageQueueMessageOut } from "./Types";
import { Redis, default as redis } from "ioredis";
-import { BridgeConfigQueue } from "../config/Config";
+import { BridgeConfigQueue } from "../config/sections/queue";
import { EventEmitter } from "events";
import { Logger } from "matrix-appservice-bridge";
import { randomUUID } from 'node:crypto';
@@ -22,9 +22,10 @@ export class RedisMQ extends EventEmitter implements MessageQueue {
private myUuid: string;
constructor(config: BridgeConfigQueue) {
super();
- this.redisSub = new redis(config.port ?? 6379, config.host ?? "localhost");
- this.redisPub = new redis(config.port ?? 6379, config.host ?? "localhost");
- this.redis = new redis(config.port ?? 6379, config.host ?? "localhost");
+ const uri = 'redisUri' in config ? config.redisUri : `redis://${config.host ?? 'localhost'}:${config.port ?? 6379}`;
+ this.redisSub = new redis(uri);
+ this.redisPub = new redis(uri);
+ this.redis = new redis(uri);
this.myUuid = randomUUID();
this.redisSub.on("pmessage", (_: string, channel: string, message: string) => {
const msg = JSON.parse(message) as MessageQueueMessageOut;
diff --git a/src/Notifications/UserNotificationWatcher.ts b/src/Notifications/UserNotificationWatcher.ts
index 4531c7b96..fbd47315e 100644
--- a/src/Notifications/UserNotificationWatcher.ts
+++ b/src/Notifications/UserNotificationWatcher.ts
@@ -48,7 +48,7 @@ export class UserNotificationWatcher {
[...this.userIntervals.values()].forEach((v) => {
v.stop();
});
- this.queue.stop ? this.queue.stop() : undefined;
+ this.queue.stop?.();
}
public removeUser(userId: string, type: "github"|"gitlab", instanceUrl?: string) {
diff --git a/src/NotificationsProcessor.ts b/src/NotificationsProcessor.ts
index 8b4ea8013..640e85cb8 100644
--- a/src/NotificationsProcessor.ts
+++ b/src/NotificationsProcessor.ts
@@ -10,7 +10,6 @@ import { GitHubUserNotification } from "./github/Types";
import { components } from "@octokit/openapi-types/types";
import { NotifFilter } from "./NotificationFilters";
-
const log = new Logger("NotificationProcessor");
const md = new markdown();
@@ -21,18 +20,15 @@ export interface IssueDiff {
merged: boolean;
mergedBy: null|{
login: string;
- // eslint-disable-next-line camelcase
html_url: string;
};
user: {
login: string;
- // eslint-disable-next-line camelcase
html_url: string;
};
}
export interface CachedReviewData {
- // eslint-disable-next-line camelcase
requested_reviewers: PullsListRequestedReviewersResponseData;
reviews: PullsListReviewsResponseData;
}
@@ -40,8 +36,6 @@ export interface CachedReviewData {
type PROrIssue = IssuesGetResponseData|PullGetResponseData;
export class NotificationProcessor {
-
- // eslint-disable-next-line camelcase
private static formatUser(user: {login: string, html_url: string}) {
return `**[${user.login}](${user.html_url})**`;
}
diff --git a/src/Stores/MemoryStorageProvider.ts b/src/Stores/MemoryStorageProvider.ts
index 52b5bf545..89f3bc58c 100644
--- a/src/Stores/MemoryStorageProvider.ts
+++ b/src/Stores/MemoryStorageProvider.ts
@@ -14,6 +14,9 @@ export class MemoryStorageProvider extends MSP implements IBridgeStorageProvider
private storedFiles = new QuickLRU({ maxSize: 128 });
private gitlabDiscussionThreads = new Map();
private feedGuids = new Map>();
+ private houndActivityIds = new Map>();
+ private houndActivityIdToEvent = new Map();
+ private hasGenericHookWarnedExpiry = new Set();
constructor() {
super();
@@ -108,4 +111,41 @@ export class MemoryStorageProvider extends MSP implements IBridgeStorageProvider
public async setGitlabDiscussionThreads(connectionId: string, value: SerializedGitlabDiscussionThreads): Promise {
this.gitlabDiscussionThreads.set(connectionId, value);
}
+
+ async storeHoundActivity(challengeId: string, ...activityIds: string[]): Promise {
+ let set = this.houndActivityIds.get(challengeId);
+ if (!set) {
+ set = []
+ this.houndActivityIds.set(challengeId, set);
+ }
+ set.unshift(...activityIds);
+ while (set.length > MAX_FEED_ITEMS) {
+ set.pop();
+ }
+ }
+
+ async hasSeenHoundActivity(challengeId: string, ...activityIds: string[]): Promise {
+ const existing = this.houndActivityIds.get(challengeId);
+ return existing ? activityIds.filter((existingGuid) => existing.includes(existingGuid)) : [];
+ }
+
+ public async hasSeenHoundChallenge(challengeId: string): Promise {
+ return this.houndActivityIds.has(challengeId);
+ }
+
+ public async storeHoundActivityEvent(challengeId: string, activityId: string, eventId: string): Promise {
+ this.houndActivityIdToEvent.set(`${challengeId}.${activityId}`, eventId);
+ }
+
+ public async getHoundActivity(challengeId: string, activityId: string): Promise {
+ return this.houndActivityIdToEvent.get(`${challengeId}.${activityId}`) ?? null;
+ }
+
+ public async getHasGenericHookWarnedExpiry(hookId: string): Promise {
+ return this.hasGenericHookWarnedExpiry.has(hookId);
+ }
+
+ public async setHasGenericHookWarnedExpiry(hookId: string, hasWarned: boolean): Promise {
+ this.hasGenericHookWarnedExpiry[hasWarned ? "add" : "delete"](hookId);
+ }
}
diff --git a/src/Stores/RedisStorageProvider.ts b/src/Stores/RedisStorageProvider.ts
index 12c42a798..54be0a1d6 100644
--- a/src/Stores/RedisStorageProvider.ts
+++ b/src/Stores/RedisStorageProvider.ts
@@ -6,6 +6,7 @@ import { IBridgeStorageProvider, MAX_FEED_ITEMS } from "./StorageProvider";
import { IFilterInfo, IStorageProvider } from "matrix-bot-sdk";
import { ProvisionSession } from "matrix-appservice-bridge";
import { SerializedGitlabDiscussionThreads } from "../Gitlab/Types";
+import { BridgeConfigCache } from "../config/sections";
const BOT_SYNC_TOKEN_KEY = "bot.sync_token.";
const BOT_FILTER_KEY = "bot.filter.";
@@ -22,12 +23,17 @@ const STORED_FILES_EXPIRE_AFTER = 24 * 60 * 60; // 24 hours
const COMPLETED_TRANSACTIONS_EXPIRE_AFTER = 24 * 60 * 60; // 24 hours
const ISSUES_EXPIRE_AFTER = 7 * 24 * 60 * 60; // 7 days
const ISSUES_LAST_COMMENT_EXPIRE_AFTER = 14 * 24 * 60 * 60; // 7 days
+const HOUND_EVENT_CACHE = 90 * 24 * 60 * 60; // 90 days
const WIDGET_TOKENS = "widgets.tokens.";
const WIDGET_USER_TOKENS = "widgets.user-tokens.";
const FEED_GUIDS = "feeds.guids.";
+const HOUND_GUIDS = "hound.guids.";
+const HOUND_EVENTS = "hound.events.";
+
+const GENERIC_HOOK_HAS_WARNED = "generichook.haswarned";
const log = new Logger("RedisASProvider");
@@ -68,8 +74,8 @@ export class RedisStorageContextualProvider implements IStorageProvider {
export class RedisStorageProvider extends RedisStorageContextualProvider implements IBridgeStorageProvider {
- constructor(host: string, port: number, contextSuffix = '') {
- super(new redis(port, host), contextSuffix);
+ constructor(cacheConfig: BridgeConfigCache, contextSuffix = '') {
+ super(new redis(cacheConfig.redisUri), contextSuffix);
this.redis.expire(COMPLETED_TRANSACTIONS_KEY, COMPLETED_TRANSACTIONS_EXPIRE_AFTER).catch((ex) => {
log.warn("Failed to set expiry time on as.completed_transactions", ex);
});
@@ -98,7 +104,7 @@ export class RedisStorageProvider extends RedisStorageContextualProvider impleme
}
public async addRegisteredUser(userId: string) {
- this.redis.sadd(REGISTERED_USERS_KEY, [userId]);
+ await this.redis.sadd(REGISTERED_USERS_KEY, [userId]);
}
public async isUserRegistered(userId: string): Promise {
@@ -106,7 +112,7 @@ export class RedisStorageProvider extends RedisStorageContextualProvider impleme
}
public async setTransactionCompleted(transactionId: string) {
- this.redis.sadd(COMPLETED_TRANSACTIONS_KEY, [transactionId]);
+ await this.redis.sadd(COMPLETED_TRANSACTIONS_KEY, [transactionId]);
}
public async isTransactionCompleted(transactionId: string): Promise {
@@ -239,4 +245,53 @@ export class RedisStorageProvider extends RedisStorageContextualProvider impleme
}
return guids.filter((_guid, index) => res[index][1] !== null);
}
+
+ public async storeHoundActivity(challengeId: string, ...activityHashes: string[]): Promise {
+ if (activityHashes.length === 0) {
+ return;
+ }
+ const key = `${HOUND_GUIDS}${challengeId}`;
+ await this.redis.lpush(key, ...activityHashes);
+ await this.redis.ltrim(key, 0, MAX_FEED_ITEMS);
+ }
+
+ public async hasSeenHoundChallenge(challengeId: string): Promise {
+ const key = `${HOUND_GUIDS}${challengeId}`;
+ return (await this.redis.exists(key)) === 1;
+ }
+
+ public async hasSeenHoundActivity(challengeId: string, ...activityHashes: string[]): Promise {
+ let multi = this.redis.multi();
+ const key = `${HOUND_GUIDS}${challengeId}`;
+
+ for (const guid of activityHashes) {
+ multi = multi.lpos(key, guid);
+ }
+ const res = await multi.exec();
+ if (res === null) {
+ // Just assume we've seen none.
+ return [];
+ }
+ return activityHashes.filter((_guid, index) => res[index][1] !== null);
+ }
+
+ public async storeHoundActivityEvent(challengeId: string, activityId: string, eventId: string): Promise {
+ const key = `${HOUND_EVENTS}${challengeId}.${activityId}`;
+ await this.redis.set(key, eventId);
+ this.redis.expire(key, HOUND_EVENT_CACHE).catch((ex) => {
+ log.warn(`Failed to set expiry time on ${key}`, ex);
+ });
+ }
+
+ public async getHoundActivity(challengeId: string, activityId: string): Promise {
+ return this.redis.get(`${HOUND_EVENTS}${challengeId}.${activityId}`);
+ }
+
+ public async getHasGenericHookWarnedExpiry(hookId: string): Promise {
+ return await this.redis.sismember(GENERIC_HOOK_HAS_WARNED, hookId) === 1;
+ }
+
+ public async setHasGenericHookWarnedExpiry(hookId: string, hasWarned: boolean): Promise {
+ await this.redis[hasWarned ? "sadd" : "srem"](GENERIC_HOOK_HAS_WARNED, hookId);
+ }
}
diff --git a/src/Stores/StorageProvider.ts b/src/Stores/StorageProvider.ts
index 50175d75e..0ec10dddd 100644
--- a/src/Stores/StorageProvider.ts
+++ b/src/Stores/StorageProvider.ts
@@ -9,6 +9,8 @@ import { SerializedGitlabDiscussionThreads } from "../Gitlab/Types";
// seen from this feed, up to a max of 10,000.
// Adopted from https://github.com/matrix-org/go-neb/blob/babb74fa729882d7265ff507b09080e732d060ae/services/rssbot/rssbot.go#L304
export const MAX_FEED_ITEMS = 10_000;
+export const MAX_HOUND_ITEMS = 100;
+
export interface IBridgeStorageProvider extends IAppserviceStorageProvider, IStorageProvider, ProvisioningStore {
connect?(): Promise;
@@ -28,4 +30,13 @@ export interface IBridgeStorageProvider extends IAppserviceStorageProvider, ISto
storeFeedGuids(url: string, ...guids: string[]): Promise;
hasSeenFeed(url: string): Promise;
hasSeenFeedGuids(url: string, ...guids: string[]): Promise;
+
+ storeHoundActivity(challengeId: string, ...activityHashes: string[]): Promise;
+ hasSeenHoundChallenge(challengeId: string): Promise;
+ hasSeenHoundActivity(challengeId: string, ...activityHashes: string[]): Promise;
+ storeHoundActivityEvent(challengeId: string, activityId: string, eventId: string): Promise;
+ getHoundActivity(challengeId: string, activityId: string): Promise;
+
+ getHasGenericHookWarnedExpiry(hookId: string): Promise;
+ setHasGenericHookWarnedExpiry(hookId: string, hasWarned: boolean): Promise;
}
\ No newline at end of file
diff --git a/src/Webhooks.ts b/src/Webhooks.ts
index 4597f7ca0..863d5ed12 100644
--- a/src/Webhooks.ts
+++ b/src/Webhooks.ts
@@ -8,7 +8,7 @@ import { ApiError, ErrCode, Logger } from "matrix-appservice-bridge";
import qs from "querystring";
import axios from "axios";
import { IGitLabWebhookEvent, IGitLabWebhookIssueStateEvent, IGitLabWebhookMREvent, IGitLabWebhookReleaseEvent } from "./Gitlab/WebhookTypes";
-import { EmitterWebhookEvent, EmitterWebhookEventName, Webhooks as OctokitWebhooks } from "@octokit/webhooks"
+import { EmitterWebhookEvent, Webhooks as OctokitWebhooks } from "@octokit/webhooks"
import { IJiraWebhookEvent } from "./jira/WebhookTypes";
import { JiraWebhooksRouter } from "./jira/Router";
import { OAuthRequest } from "./WebhookTypes";
@@ -18,6 +18,7 @@ import { FigmaWebhooksRouter } from "./figma/router";
import { GenericWebhooksRouter } from "./generic/Router";
import { GithubInstance } from "./github/GithubInstance";
import QuickLRU from "@alloc/quick-lru";
+import type { WebhookEventName } from "@octokit/webhooks-types";
const log = new Logger("Webhooks");
@@ -178,7 +179,7 @@ export class Webhooks extends EventEmitter {
}
this.ghWebhooks.verifyAndReceive({
id: githubGuid as string,
- name: req.headers["x-github-event"] as EmitterWebhookEventName,
+ name: req.headers["x-github-event"] as WebhookEventName,
payload: githubData.payload,
signature: githubData.signature,
}).catch((err) => {
diff --git a/src/Widgets/BridgeWidgetApi.ts b/src/Widgets/BridgeWidgetApi.ts
index 385557890..f17d62903 100644
--- a/src/Widgets/BridgeWidgetApi.ts
+++ b/src/Widgets/BridgeWidgetApi.ts
@@ -11,7 +11,7 @@ import BotUsersManager, {BotUser} from "../Managers/BotUsersManager";
import { assertUserPermissionsInRoom, GetConnectionsResponseItem } from "../provisioning/api";
import { Appservice, PowerLevelsEvent } from "matrix-bot-sdk";
import { GithubInstance } from '../github/GithubInstance';
-import { AllowedTokenTypes, TokenType, UserTokenStore } from '../UserTokenStore';
+import { AllowedTokenTypes, TokenType, UserTokenStore } from '../tokens/UserTokenStore';
const log = new Logger("BridgeWidgetApi");
@@ -100,14 +100,15 @@ 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,
});
}
- private async getServiceConfig(req: ProvisioningRequest, res: Response>) {
+ private async getServiceConfig(req: ProvisioningRequest, res: Response