diff --git a/.github/workflows/docker-hub-latest.yml b/.github/workflows/docker-hub-latest.yml
index dfcbf3cc..e7c3544f 100644
--- a/.github/workflows/docker-hub-latest.yml
+++ b/.github/workflows/docker-hub-latest.yml
@@ -4,6 +4,8 @@ name: "Docker Hub - Latest"
on:
push:
+ pull_request:
+ branches: [ main ]
env:
DOCKER_NAMESPACE: halfshot
@@ -23,6 +25,7 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Log in to Docker Hub
+ if: github.ref == 'refs/heads/main'
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f7a364c9..d29cec60 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,131 @@
+3.0.0 (2023-03-17)
+==================
+
+This release includes some new landmark improvements to support **public Hookshots**.
+
+One key feature is the new Go-NEB migrator. If you run a Go-NEB instance currently and are looking for a way to migrate GitHub and RSS feeds
+over to Hookshot, there is a nice fancy widget feature for this.
+
+The other feature is we have now implemented a "Grant" system for authorising new connections in rooms. Simply put when you create a new connection
+in a room to a remote service like GitHub, we now store the validity of that authorisation in the bridge. This is a new change where previously we
+would not persist this authorization between sessions, so it was possible for users (who were permitted in the config to `manageConnections`) to create
+connections to anywhere Hookshot was already configured to talk to. This piece of extra security means we can now be more confident about allowing Hookshot
+to be used in public spaces.
+
+Upgrading to 3.0.0 **is breaking**, as the new grant system will run against any of your previous connections. It is imperative that where you have
+created or edited a connection manually in the room state, that you are still authenticated to the service it is connected to. For instance, ensure
+you are logged into GitHub if you have created manual GitHub connections. You can check the logs for any information on which connections have not
+been granted.
+
+For any users who are not able to immediately update, but are nontheless worried about the consequeneces for this change: Do not panic. You can always
+update the permissions in your config to only allow `manageConnections` to users you trust.
+
+If you have any questions about this change, do not hesistate to reach out to `#hookshot:half-shot.uk`.
+
+Features
+--------
+
+- Add support from migrating go-neb services to Hookshot ([\#647](https://github.com/matrix-org/matrix-hookshot/issues/647))
+- Implement grant system to internally record all approved connections in hookshot. ([\#655](https://github.com/matrix-org/matrix-hookshot/issues/655))
+
+
+Bugfixes
+--------
+
+- `roomSetupWidget` in widget config does now allow an empty value ([\#657](https://github.com/matrix-org/matrix-hookshot/issues/657))
+- Fix service bots not being able to reject invites with a reason. ([\#659](https://github.com/matrix-org/matrix-hookshot/issues/659))
+- Fix Hookshot presenting room connections as editable if the user has a default-or-greater power levels. This was only a presentation bug, power levels were and are proeprly checked at creation/edit time. ([\#660](https://github.com/matrix-org/matrix-hookshot/issues/660))
+- Add support for logging into GitHub via OAuth from bridge widgets. ([\#661](https://github.com/matrix-org/matrix-hookshot/issues/661))
+
+
+Improved Documentation
+----------------------
+
+- Update docs and sample config for serviceBots. Thanks to @HarHarLinks. ([\#643](https://github.com/matrix-org/matrix-hookshot/issues/643))
+
+
+Internal Changes
+----------------
+
+- Replace `uuid` package with `crypto.randomUUID` function. ([\#640](https://github.com/matrix-org/matrix-hookshot/issues/640))
+- Minor improvements to widget UI styles. ([\#652](https://github.com/matrix-org/matrix-hookshot/issues/652))
+- Run docker-latest CI for incoming pull requests. ([\#662](https://github.com/matrix-org/matrix-hookshot/issues/662))
+
+
+2.7.0 (2023-01-20)
+==================
+
+Features
+--------
+
+- The room configuration widget now features an improved project search component, which now shows project avatars and descriptions. ([\#624](https://github.com/matrix-org/matrix-hookshot/issues/624))
+
+
+2.6.1 (2023-01-16)
+==================
+
+Features
+--------
+
+- The message in the admin room when creating a webhook now also shows the name and links to the room. ([\#620](https://github.com/matrix-org/matrix-hookshot/issues/620))
+
+
+Bugfixes
+--------
+
+- Fixed generic webhook 'user is already in the room' error ([\#627](https://github.com/matrix-org/matrix-hookshot/issues/627))
+- Hookshot now handles `uk.half-shot.matrix-hookshot.generic.hook` state event updates ([\#628](https://github.com/matrix-org/matrix-hookshot/issues/628))
+
+
+2.6.0 (2023-01-13)
+==================
+
+Features
+--------
+
+- Add support for end-to-bridge encryption via MSC3202. ([\#299](https://github.com/matrix-org/matrix-hookshot/issues/299))
+- Add support for additional bot users called "service bots" which handle a particular connection type, so that different services can be used through different bot users. ([\#573](https://github.com/matrix-org/matrix-hookshot/issues/573))
+- Add new GitHubRepo connection config setting `workflowRun.workflows` to filter run reports by workflow name. ([\#588](https://github.com/matrix-org/matrix-hookshot/issues/588))
+- The GitHub/GitLab connection state configuration has changed. The configuration option `ignoreHooks` is now deprecated, and new connections may not use this options.
+ Users should instead explicitly configure all the hooks they want to enable with the `enableHooks` option. Existing connections will continue to work with both options. ([\#592](https://github.com/matrix-org/matrix-hookshot/issues/592))
+- A11y: Add alt tags to all images. ([\#602](https://github.com/matrix-org/matrix-hookshot/issues/602))
+
+
+Bugfixes
+--------
+
+- Parent projects are now taken into account when calculating a user's access level to a GitLab project. ([\#539](https://github.com/matrix-org/matrix-hookshot/issues/539))
+- Ensure bridge treats published and drafted GitHub releases as different events. ([\#582](https://github.com/matrix-org/matrix-hookshot/issues/582))
+- Fix a bug where unknown keys in a connections state would be clobbered when updated via widget UI. ([\#587](https://github.com/matrix-org/matrix-hookshot/issues/587))
+- Improve webhook code editor performance. ([\#601](https://github.com/matrix-org/matrix-hookshot/issues/601))
+- Correctly apply CSS for recent RSS feed changes. ([\#604](https://github.com/matrix-org/matrix-hookshot/issues/604))
+- Improve startup stability by not loading all room state at once. ([\#614](https://github.com/matrix-org/matrix-hookshot/issues/614))
+- You can now add multiple GitLab connections to the same room with the same project path, if they are under different instances. ([\#617](https://github.com/matrix-org/matrix-hookshot/issues/617))
+
+
+Improved Documentation
+----------------------
+
+- Clarify GitLab setup docs ([\#350](https://github.com/matrix-org/matrix-hookshot/issues/350))
+- Change URL protocol in the ocumentation and sample configs to HTTPS. ([\#623](https://github.com/matrix-org/matrix-hookshot/issues/623))
+
+
+Deprecations and Removals
+-------------------------
+
+- Remove support for Pantalaimon-based encryption. ([\#299](https://github.com/matrix-org/matrix-hookshot/issues/299))
+
+
+Internal Changes
+----------------
+
+- RSS feed polling now uses cache headers sent by servers, which should mean we will be more conservative on resources. ([\#583](https://github.com/matrix-org/matrix-hookshot/issues/583))
+- Only build ARM images when merging or releasing, due to slow ARM build times. ([\#589](https://github.com/matrix-org/matrix-hookshot/issues/589))
+- Increase maximum size of incoming webhook payload from `100kb` to `10mb`. ([\#606](https://github.com/matrix-org/matrix-hookshot/issues/606))
+- Mark encryption feature as experimental (config option is now `experimentalEncryption`). ([\#610](https://github.com/matrix-org/matrix-hookshot/issues/610))
+- Cache yarn dependencies during Docker build. ([\#615](https://github.com/matrix-org/matrix-hookshot/issues/615))
+
+
2.5.0 (2022-12-02)
==================
diff --git a/changelog.d/299.feature b/changelog.d/299.feature
deleted file mode 100644
index 7c50d02d..00000000
--- a/changelog.d/299.feature
+++ /dev/null
@@ -1 +0,0 @@
-Add support for end-to-bridge encryption via MSC3202.
diff --git a/changelog.d/299.removal b/changelog.d/299.removal
deleted file mode 100644
index 2b8b2023..00000000
--- a/changelog.d/299.removal
+++ /dev/null
@@ -1 +0,0 @@
-Remove support for Pantalaimon-based encryption.
diff --git a/changelog.d/350.doc b/changelog.d/350.doc
deleted file mode 100644
index 74f877b2..00000000
--- a/changelog.d/350.doc
+++ /dev/null
@@ -1 +0,0 @@
-Clarify GitLab setup docs
diff --git a/changelog.d/539.bugfix b/changelog.d/539.bugfix
deleted file mode 100644
index dd75d805..00000000
--- a/changelog.d/539.bugfix
+++ /dev/null
@@ -1 +0,0 @@
-Parent projects are now taken into account when calculating a user's access level to a GitLab project.
\ No newline at end of file
diff --git a/changelog.d/582.bugfix b/changelog.d/582.bugfix
deleted file mode 100644
index 5772615e..00000000
--- a/changelog.d/582.bugfix
+++ /dev/null
@@ -1 +0,0 @@
-Ensure bridge treats published and drafted GitHub releases as different events.
\ No newline at end of file
diff --git a/changelog.d/583.misc b/changelog.d/583.misc
deleted file mode 100644
index 0b69b9ce..00000000
--- a/changelog.d/583.misc
+++ /dev/null
@@ -1 +0,0 @@
-RSS feed polling now uses cache headers sent by servers, which should mean we will be more conservative on resources.
\ No newline at end of file
diff --git a/changelog.d/587.bugfix b/changelog.d/587.bugfix
deleted file mode 100644
index dd6f4eff..00000000
--- a/changelog.d/587.bugfix
+++ /dev/null
@@ -1 +0,0 @@
-Fix a bug where unknown keys in a connections state would be clobbered when updated via widget UI.
\ No newline at end of file
diff --git a/changelog.d/588.feature b/changelog.d/588.feature
deleted file mode 100644
index 2a876596..00000000
--- a/changelog.d/588.feature
+++ /dev/null
@@ -1 +0,0 @@
-Add new GitHubRepo connection config setting `workflowRun.workflows` to filter run reports by workflow name.
\ No newline at end of file
diff --git a/changelog.d/589.misc b/changelog.d/589.misc
deleted file mode 100644
index a0029db7..00000000
--- a/changelog.d/589.misc
+++ /dev/null
@@ -1 +0,0 @@
-Only build ARM images when merging or releasing, due to slow ARM build times.
\ No newline at end of file
diff --git a/changelog.d/601.bugfix b/changelog.d/601.bugfix
deleted file mode 100644
index 17bd0e0c..00000000
--- a/changelog.d/601.bugfix
+++ /dev/null
@@ -1 +0,0 @@
-Improve webhook code editor performance.
\ No newline at end of file
diff --git a/changelog.d/602.feature b/changelog.d/602.feature
deleted file mode 100644
index d07f9255..00000000
--- a/changelog.d/602.feature
+++ /dev/null
@@ -1 +0,0 @@
-A11y: Add alt tags to all images.
diff --git a/changelog.d/606.misc b/changelog.d/606.misc
deleted file mode 100644
index 061e396f..00000000
--- a/changelog.d/606.misc
+++ /dev/null
@@ -1 +0,0 @@
-Increase maximum size of incoming webhook payload from `100kb` to `10mb`.
diff --git a/changelog.d/610.misc b/changelog.d/610.misc
deleted file mode 100644
index ebdcb896..00000000
--- a/changelog.d/610.misc
+++ /dev/null
@@ -1 +0,0 @@
-Mark encryption feature as experimental (config option is now `experimentalEncryption`).
\ No newline at end of file
diff --git a/changelog.d/614.bugfix b/changelog.d/614.bugfix
deleted file mode 100644
index d9d8a441..00000000
--- a/changelog.d/614.bugfix
+++ /dev/null
@@ -1 +0,0 @@
-Improve startup stability by not loading all room state at once.
\ No newline at end of file
diff --git a/changelog.d/615.misc b/changelog.d/615.misc
deleted file mode 100644
index ac59177e..00000000
--- a/changelog.d/615.misc
+++ /dev/null
@@ -1 +0,0 @@
-Cache yarn dependencies during Docker build.
\ No newline at end of file
diff --git a/changelog.d/617.bugfix b/changelog.d/617.bugfix
deleted file mode 100644
index d8d88539..00000000
--- a/changelog.d/617.bugfix
+++ /dev/null
@@ -1 +0,0 @@
-You can now add multiple GitLab connections to the same room with the same project path, if they are under different instances.
\ No newline at end of file
diff --git a/config.sample.yml b/config.sample.yml
index a16400c6..2cf86367 100644
--- a/config.sample.yml
+++ b/config.sample.yml
@@ -5,7 +5,7 @@ bridge:
#
domain: example.com
url: http://localhost:8008
- mediaUrl: http://example.com
+ mediaUrl: https://example.com
port: 9993
bindAddress: 127.0.0.1
github:
@@ -100,8 +100,16 @@ passFile:
bot:
# (Optional) Define profile information for the bot user
#
- displayname: GitHub Bot
+ displayname: Hookshot Bot
avatar: mxc://half-shot.uk/2876e89ccade4cb615e210c458e2a7a6883fe17d
+serviceBots:
+ # (Optional) Define additional bot users for specific services
+ #
+ - localpart: feeds
+ displayname: Feeds
+ avatar: mxc://half-shot.uk/2876e89ccade4cb615e210c458e2a7a6883fe17d
+ prefix: "!feeds"
+ service: feeds
metrics:
# (Optional) Prometheus metrics support
#
@@ -146,7 +154,7 @@ widgets:
- fec0::/10
roomSetupWidget:
addOnInvite: false
- publicUrl: http://example.com/widgetapi/v1/static/
+ publicUrl: https://example.com/widgetapi/v1/static/
branding:
widgetTitle: Hookshot Configuration
permissions:
diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md
index e2837e20..9f689f09 100644
--- a/docs/SUMMARY.md
+++ b/docs/SUMMARY.md
@@ -27,3 +27,4 @@
- [Workers](./advanced/workers.md)
- [🔒 Encryption](./advanced/encryption.md)
- [🪀 Widgets](./advanced/widgets.md)
+- [Service Bots](./advanced/service_bots.md)
diff --git a/docs/advanced/service_bots.md b/docs/advanced/service_bots.md
new file mode 100644
index 00000000..6336c648
--- /dev/null
+++ b/docs/advanced/service_bots.md
@@ -0,0 +1,36 @@
+# Service Bots
+
+Hookshot supports additional bot users called "service bots" which handle a particular connection type
+(in addition to the default bot user which can handle any connection type).
+These bots can coexist in a room, each handling a different service.
+
+## Configuration
+
+Service bots can be given a different localpart, display name, avatar, and command prefix.
+They will only handle connections for the specified service, which can be one of:
+* `feeds` - [Feeds](../setup/feeds.md)
+* `figma` - [Figma](../setup/figma.md)
+* `generic` - [Webhooks](../setup/webhooks.md)
+* `github` - [GitHub](../setup/github.md)
+* `gitlab` - [GitLab](../setup/gitlab.md)
+* `jira` - [Jira](../setup/jira.md)
+
+For example with this configuration:
+```yaml
+serviceBots:
+ - localpart: feeds
+ displayname: Feeds
+ avatar: mxc://half-shot.uk/2876e89ccade4cb615e210c458e2a7a6883fe17d
+ prefix: "!feeds"
+ service: feeds
+```
+
+There will be a bot user `@feeds:example.com` which responds to commands prefixed with `!feeds`, and only handles feeds connections.
+
+For the homeserver to allow hookshot control over users, they need to be added to the list of user namespaces in the `registration.yml` file provided to the homeserver.
+
+In the example above, you would need to add these lines:
+```yaml
+ - regex: "@feeds:example.com" # Where example.com is your homeserver's domain
+ exclusive: true
+```
diff --git a/docs/advanced/widgets.md b/docs/advanced/widgets.md
index ec3da2ff..73575b80 100644
--- a/docs/advanced/widgets.md
+++ b/docs/advanced/widgets.md
@@ -39,7 +39,7 @@ widgets:
# - 2001:db8::/32
# - ff00::/8
# - fec0::/10
- publicUrl: http://example.com/widgetapi/v1/static
+ publicUrl: https://example.com/widgetapi/v1/static
branding:
widgetTitle: Hookshot Configuration
openIdOverrides:
diff --git a/docs/setup/jira.md b/docs/setup/jira.md
index ceb91f1c..57f83e65 100644
--- a/docs/setup/jira.md
+++ b/docs/setup/jira.md
@@ -74,7 +74,7 @@ To begin, configure your `config.yml`:
```yaml
jira:
- url: http://yourjirainstance.com # The location of your jira instance.
+ url: https://yourjirainstance.com # The location of your jira instance.
webhook:
# A secret string generated by you.
secret: Ieph7iecheiThoo1othaineewieSh1koh2chainohtooyoh4waht1oetoaSoh6oh
@@ -98,7 +98,7 @@ To start with, set up your JIRA instance to support OAuth.
1. The Application Name can be anything, but for simplicty we usually use `matrix-hookshot`
2. The Application Type should be **Generic Application**
3. The Consumer key, and shared secret can be any string, they are not used.
- 4. The URLs can be any URL, they are not used (e.g. `http://example.com`)
+ 4. The URLs can be any URL, they are not used (e.g. `https://example.com`)
5. Ensure you enable **Create incoming link**
6. Click **Continue**
6. On the next step:
diff --git a/docs/usage/room_configuration/github_repo.md b/docs/usage/room_configuration/github_repo.md
index 53968ec1..8d2aa20d 100644
--- a/docs/usage/room_configuration/github_repo.md
+++ b/docs/usage/room_configuration/github_repo.md
@@ -27,8 +27,8 @@ This connection supports a few options which can be defined in the room state:
| Option | Description | Allowed values | Default |
|--------|-------------|----------------|---------|
-|enableHooks [^1]|Enable notifications for some event types|Array of: [Supported event types](#supported-event-types) |*empty*|
-|ignoreHooks [^1]|Choose to exclude notifications for some event types|Array of: [Supported event types](#supported-event-types) |*empty*|
+|enableHooks [^1]|Enable notifications for some event types|Array of: [Supported event types](#supported-event-types) |If not defined, defaults are mentioned below|
+|ignoreHooks [^1]|**deprecated** Choose to exclude notifications for some event types|Array of: [Supported event types](#supported-event-types) |*empty*|
|commandPrefix|Choose the prefix to use when sending commands to the bot|A string, ideally starts with "!"|`!gh`|
|showIssueRoomLink|When new issues are created, provide a Matrix alias link to the issue room|`true/false`|`false`|
|prDiff|Show a diff in the room when a PR is created, subject to limits|`{enabled: boolean, maxLines: number}`|`{enabled: false}`|
@@ -43,14 +43,16 @@ This connection supports a few options which can be defined in the room state:
|workflowRun.excludingWorkflows|Never report workflow runs with a matching workflow name.|Array of: String matching a workflow name|*empty*|
-[^1]: `ignoreHooks` takes precedence over `enableHooks`.
+[^1]: `ignoreHooks` is no longer accepted for new state events. Use `enableHooks` to explicitly state all events you want to see.
+
### Supported event types
This connection supports sending messages when the following actions happen on the repository.
-Note: Some of these event types are enabled by default (marked with a `*`)
+Note: Some of these event types are enabled by default (marked with a `*`). When `ignoreHooks` *is* defined,
+the events marked as default below will be enabled. Otherwise, this is ignored.
- issue *
- issue.created *
diff --git a/docs/usage/room_configuration/gitlab_project.md b/docs/usage/room_configuration/gitlab_project.md
index c8f91a65..c7612a9d 100644
--- a/docs/usage/room_configuration/gitlab_project.md
+++ b/docs/usage/room_configuration/gitlab_project.md
@@ -23,26 +23,33 @@ This connection supports a few options which can be defined in the room state:
| Option | Description | Allowed values | Default |
|--------|-------------|----------------|---------|
-|ignoreHooks|Choose to exclude notifications for some event types|Array of: [Supported event types](#supported-event-types) |*empty*|
|commandPrefix|Choose the prefix to use when sending commands to the bot|A string, ideally starts with "!"|`!gh`|
-|pushTagsRegex|Only mention pushed tags which match this regex|Regex string|*empty*|
-|includingLabels|Only notify on issues matching these label names|Array of: String matching a label name|*empty*|
+|enableHooks [^1]|Enable notifications for some event types|Array of: [Supported event types](#supported-event-types) |If not defined, defaults are mentioned below|
|excludingLabels|Never notify on issues matching these label names|Array of: String matching a label name|*empty*|
+|ignoreHooks [^1]|**deprecated** Choose to exclude notifications for some event types|Array of: [Supported event types](#supported-event-types) |*empty*|
|includeCommentBody|Include the body of a comment when notifying on merge requests|Boolean|false|
+|includingLabels|Only notify on issues matching these label names|Array of: String matching a label name|*empty*|
+|pushTagsRegex|Only mention pushed tags which match this regex|Regex string|*empty*|
+
+
+[^1]: `ignoreHooks` is no longer accepted for new state events. Use `enableHooks` to explicitly state all events you want to see.
### Supported event types
This connection supports sending messages when the following actions happen on the repository.
-- merge_request
- - merge_request.close
- - merge_request.merge
- - merge_request.open
- - merge_request.review.comments
- - merge_request.review
-- push
-- release
- - release.created
-- tag_push
-- wiki
+Note: Some of these event types are enabled by default (marked with a `*`). When `ignoreHooks` *is* defined,
+the events marked as default below will be enabled. Otherwise, this is ignored.
+
+- merge_request *
+ - merge_request.close *
+ - merge_request.merge *
+ - merge_request.open *
+ - merge_request.review.comments *
+ - merge_request.review *
+- push *
+- release *
+ - release.created *
+- tag_push *
+- wiki *
diff --git a/package.json b/package.json
index bec2298f..8b9701d0 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "matrix-hookshot",
- "version": "2.5.0",
+ "version": "3.0.0",
"description": "A bridge between Matrix and multiple project management services, such as GitHub, GitLab and JIRA.",
"main": "lib/app.js",
"repository": "https://github.com/matrix-org/matrix-hookshot",
@@ -67,7 +67,6 @@
"source-map-support": "^0.5.21",
"string-argv": "^0.3.1",
"tiny-typed-emitter": "^2.1.0",
- "uuid": "^8.3.2",
"vm2": "^3.9.11",
"winston": "^3.3.3",
"xml2js": "^0.4.23",
@@ -77,6 +76,7 @@
"@codemirror/lang-javascript": "^6.0.2",
"@napi-rs/cli": "^2.2.0",
"@preact/preset-vite": "^2.2.0",
+ "@tsconfig/node16": "^1.0.3",
"@types/ajv": "^1.0.0",
"@types/chai": "^4.2.22",
"@types/cors": "^2.8.12",
@@ -104,7 +104,7 @@
"sass": "^1.51.0",
"ts-node": "^10.4.0",
"typescript": "^4.5.2",
- "vite": "^2.9.13",
- "vite-svg-loader": "^3.4.0"
+ "vite": "^4.1.4",
+ "vite-svg-loader": "^4.0.0"
}
}
diff --git a/registration.sample.yml b/registration.sample.yml
index d7a89d62..e22fcf89 100644
--- a/registration.sample.yml
+++ b/registration.sample.yml
@@ -12,6 +12,8 @@ namespaces:
exclusive: true
- regex: "@_webhooks_.*:foobar" # Where _webhooks_ is set by userIdPrefix in config.yml
exclusive: true
+ - regex: "@feeds:foobar" # Matches the localpart of all serviceBots in config.yml
+ exclusive: true
aliases:
- regex: "#github_.+:foobar" # Where foobar is your homeserver's domain
exclusive: true
@@ -23,4 +25,4 @@ rate_limited: false
# If enabling encryption
de.sorunome.msc2409.push_ephemeral: true
push_ephemeral: true
-org.matrix.msc3202: true
\ No newline at end of file
+org.matrix.msc3202: true
diff --git a/src/App/BridgeApp.ts b/src/App/BridgeApp.ts
index 703fcde1..750e4b9b 100644
--- a/src/App/BridgeApp.ts
+++ b/src/App/BridgeApp.ts
@@ -8,6 +8,7 @@ import { ListenerService } from "../ListenerService";
import { Logger } from "matrix-appservice-bridge";
import { LogService } from "matrix-bot-sdk";
import { getAppservice } from "../appservice";
+import BotUsersManager from "../Managers/BotUsersManager";
Logger.configure({console: "info"});
const log = new Logger("App");
@@ -35,7 +36,9 @@ async function start() {
userNotificationWatcher.start();
}
- const bridgeApp = new Bridge(config, listener, appservice, storage);
+ const botUsersManager = new BotUsersManager(config, appservice);
+
+ const bridgeApp = new Bridge(config, listener, appservice, storage, botUsersManager);
process.once("SIGTERM", () => {
log.error("Got SIGTERM");
diff --git a/src/Bridge.ts b/src/Bridge.ts
index 97cf7077..7d544e56 100644
--- a/src/Bridge.ts
+++ b/src/Bridge.ts
@@ -1,6 +1,7 @@
import { AdminAccountData } from "./AdminRoomCommandHandler";
import { AdminRoom, BRIDGE_ROOM_TYPE, LEGACY_BRIDGE_ROOM_TYPE } from "./AdminRoom";
-import { Appservice, RichRepliesPreprocessor, IRichReplyMetadata, StateEvent, EventKind, PowerLevelsEvent } from "matrix-bot-sdk";
+import { Appservice, RichRepliesPreprocessor, IRichReplyMetadata, StateEvent, EventKind, PowerLevelsEvent, Intent } from "matrix-bot-sdk";
+import BotUsersManager from "./Managers/BotUsersManager";
import { BridgeConfig, BridgePermissionLevel, GitLabInstance } from "./Config/Config";
import { BridgeWidgetApi } from "./Widgets/BridgeWidgetApi";
import { CommentProcessor } from "./CommentProcessor";
@@ -50,6 +51,7 @@ export class Bridge {
private connectionManager?: ConnectionManager;
private github?: GithubInstance;
private adminRooms: Map = new Map();
+ private widgetApi?: BridgeWidgetApi;
private provisioningApi?: Provisioner;
private replyProcessor = new RichRepliesPreprocessor(true);
@@ -60,6 +62,7 @@ export class Bridge {
private readonly listener: ListenerService,
private readonly as: Appservice,
private readonly storage: IBridgeStorageProvider,
+ private readonly botUsersManager: BotUsersManager,
) {
this.queue = createMessageQueue(this.config.queue);
this.messageClient = new MessageSenderClient(this.queue);
@@ -67,6 +70,7 @@ export class Bridge {
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));
+
this.as.expressAppInstance.get("/live", (_, res) => res.send({ok: true}));
this.as.expressAppInstance.get("/ready", (_, res) => res.status(this.ready ? 200 : 500).send({ready: this.ready}));
}
@@ -82,20 +86,21 @@ export class Bridge {
await this.storage.connect?.();
await this.queue.connect?.();
- // Fetch all room state
- let joinedRooms: string[]|undefined;
- while(joinedRooms === undefined) {
+ log.info("Ensuring homeserver can be reached...");
+ let reached = false;
+ while (!reached) {
try {
- log.info("Connecting to homeserver and fetching joined rooms..");
- joinedRooms = await this.as.botIntent.getJoinedRooms();
- log.debug(`Bridge bot is joined to ${joinedRooms.length} rooms`);
- } catch (ex) {
- // This is our first interaction with the homeserver, so wait if it's not ready yet.
- log.warn("Failed to connect to homeserver, retrying in 5s", ex);
+ // Make a request to determine if we can reach the homeserver
+ await this.as.botIntent.getJoinedRooms();
+ reached = true;
+ } catch (e) {
+ log.warn("Failed to connect to homeserver, retrying in 5s", e);
await new Promise((r) => setTimeout(r, 5000));
}
}
-
+
+ await this.botUsersManager.start();
+
await this.config.prefillMembershipCache(this.as.botClient);
if (this.config.github) {
@@ -112,20 +117,28 @@ export class Bridge {
await ensureFigmaWebhooks(this.config.figma, this.as.botClient);
}
-
- const connManager = this.connectionManager = new ConnectionManager(this.as,
- this.config, this.tokenStore, this.commentProcessor, this.messageClient, this.storage, this.github);
+ const connManager = this.connectionManager = new ConnectionManager(
+ this.as,
+ this.config,
+ this.tokenStore,
+ this.commentProcessor,
+ this.messageClient,
+ this.storage,
+ this.botUsersManager,
+ this.github,
+ );
if (this.config.feeds?.enabled) {
new FeedReader(
this.config.feeds,
this.connectionManager,
this.queue,
+ // Use default bot when storing account data
this.as.botClient,
);
}
-
+
if (this.config.provisioning) {
const routers = [];
if (this.config.jira) {
@@ -145,7 +158,13 @@ export class Bridge {
if (this.config.generic) {
this.connectionManager.registerProvisioningConnection(GenericHookConnection);
}
- this.provisioningApi = new Provisioner(this.config.provisioning, this.connectionManager, this.as.botIntent, routers);
+ this.provisioningApi = new Provisioner(
+ this.config.provisioning,
+ this.connectionManager,
+ this.botUsersManager,
+ this.as,
+ routers,
+ );
}
this.as.on("query.room", async (roomAlias, cb) => {
@@ -266,114 +285,114 @@ export class Bridge {
this.bindHandlerToQueue(
"github.issues.unlabeled",
- (data) => connManager.getConnectionsForGithubRepo(data.repository.owner.login, data.repository.name),
+ (data) => connManager.getConnectionsForGithubRepo(data.repository.owner.login, data.repository.name),
(c, data) => c.onIssueUnlabeled(data),
);
this.bindHandlerToQueue(
"github.issues.labeled",
- (data) => connManager.getConnectionsForGithubRepo(data.repository.owner.login, data.repository.name),
+ (data) => connManager.getConnectionsForGithubRepo(data.repository.owner.login, data.repository.name),
(c, data) => c.onIssueLabeled(data),
);
this.bindHandlerToQueue(
"github.pull_request.opened",
- (data) => connManager.getConnectionsForGithubRepo(data.repository.owner.login, data.repository.name),
+ (data) => connManager.getConnectionsForGithubRepo(data.repository.owner.login, data.repository.name),
(c, data) => c.onPROpened(data),
);
this.bindHandlerToQueue(
"github.pull_request.closed",
- (data) => connManager.getConnectionsForGithubRepo(data.repository.owner.login, data.repository.name),
+ (data) => connManager.getConnectionsForGithubRepo(data.repository.owner.login, data.repository.name),
(c, data) => c.onPRClosed(data),
);
this.bindHandlerToQueue(
"github.pull_request.ready_for_review",
- (data) => connManager.getConnectionsForGithubRepo(data.repository.owner.login, data.repository.name),
+ (data) => connManager.getConnectionsForGithubRepo(data.repository.owner.login, data.repository.name),
(c, data) => c.onPRReadyForReview(data),
);
this.bindHandlerToQueue(
"github.pull_request_review.submitted",
- (data) => connManager.getConnectionsForGithubRepo(data.repository.owner.login, data.repository.name),
+ (data) => connManager.getConnectionsForGithubRepo(data.repository.owner.login, data.repository.name),
(c, data) => c.onPRReviewed(data),
);
this.bindHandlerToQueue(
"github.workflow_run.completed",
- (data) => connManager.getConnectionsForGithubRepo(data.repository.owner.login, data.repository.name),
+ (data) => connManager.getConnectionsForGithubRepo(data.repository.owner.login, data.repository.name),
(c, data) => c.onWorkflowCompleted(data),
);
this.bindHandlerToQueue(
"github.release.published",
- (data) => connManager.getConnectionsForGithubRepo(data.repository.owner.login, data.repository.name),
+ (data) => connManager.getConnectionsForGithubRepo(data.repository.owner.login, data.repository.name),
(c, data) => c.onReleaseCreated(data),
);
this.bindHandlerToQueue(
"github.release.created",
- (data) => connManager.getConnectionsForGithubRepo(data.repository.owner.login, data.repository.name),
+ (data) => connManager.getConnectionsForGithubRepo(data.repository.owner.login, data.repository.name),
(c, data) => c.onReleaseDrafted(data),
);
this.bindHandlerToQueue(
"gitlab.merge_request.open",
- (data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace),
+ (data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace),
(c, data) => c.onMergeRequestOpened(data),
);
this.bindHandlerToQueue(
"gitlab.merge_request.close",
- (data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace),
+ (data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace),
(c, data) => c.onMergeRequestClosed(data),
);
this.bindHandlerToQueue(
"gitlab.merge_request.merge",
- (data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace),
+ (data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace),
(c, data) => c.onMergeRequestMerged(data),
);
this.bindHandlerToQueue(
"gitlab.merge_request.approved",
- (data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace),
+ (data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace),
(c, data) => c.onMergeRequestReviewed(data),
);
this.bindHandlerToQueue(
"gitlab.merge_request.unapproved",
- (data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace),
+ (data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace),
(c, data) => c.onMergeRequestReviewed(data),
);
this.bindHandlerToQueue(
"gitlab.merge_request.update",
- (data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace),
+ (data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace),
(c, data) => c.onMergeRequestUpdate(data),
);
this.bindHandlerToQueue(
"gitlab.release.create",
- (data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace),
+ (data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace),
(c, data) => c.onRelease(data),
);
this.bindHandlerToQueue(
"gitlab.tag_push",
- (data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace),
+ (data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace),
(c, data) => c.onGitLabTagPush(data),
);
this.bindHandlerToQueue(
"gitlab.push",
- (data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace),
+ (data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace),
(c, data) => c.onGitLabPush(data),
);
this.bindHandlerToQueue(
"gitlab.wiki_page",
- (data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace),
+ (data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace),
(c, data) => c.onWikiPageEvent(data),
);
@@ -419,10 +438,10 @@ export class Bridge {
this.bindHandlerToQueue(
"gitlab.note.created",
- (data) => {
+ (data) => {
const iid = data.issue?.iid || data.merge_request?.iid;
return [
- ...( iid ? connManager.getConnectionsForGitLabIssueWebhook(data.repository.homepage, iid) : []),
+ ...( iid ? connManager.getConnectionsForGitLabIssueWebhook(data.repository.homepage, iid) : []),
...connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace),
]},
(c, data) => c.onCommentCreated(data),
@@ -430,19 +449,19 @@ export class Bridge {
this.bindHandlerToQueue(
"gitlab.issue.reopen",
- (data) => connManager.getConnectionsForGitLabIssueWebhook(data.repository.homepage, data.object_attributes.iid),
+ (data) => connManager.getConnectionsForGitLabIssueWebhook(data.repository.homepage, data.object_attributes.iid),
(c) => c.onIssueReopened(),
);
this.bindHandlerToQueue(
"gitlab.issue.close",
- (data) => connManager.getConnectionsForGitLabIssueWebhook(data.repository.homepage, data.object_attributes.iid),
+ (data) => connManager.getConnectionsForGitLabIssueWebhook(data.repository.homepage, data.object_attributes.iid),
(c) => c.onIssueClosed(),
);
this.bindHandlerToQueue(
"github.discussion_comment.created",
- (data) => connManager.getConnectionsForGithubDiscussion(data.repository.owner.login, data.repository.name, data.discussion.number),
+ (data) => connManager.getConnectionsForGithubDiscussion(data.repository.owner.login, data.repository.name, data.discussion.number),
(c, data) => c.onDiscussionCommentCreated(data),
);
@@ -458,10 +477,16 @@ export class Bridge {
}
let [discussionConnection] = connManager.getConnectionsForGithubDiscussion(data.repository.owner.login, data.repository.name, data.discussion.id);
if (!discussionConnection) {
+ const botUser = this.botUsersManager.getBotUserForService(GitHubDiscussionConnection.ServiceCategory);
+ if (!botUser) {
+ throw Error('Could not find a bot to handle this connection');
+ }
+
try {
// If we don't have an existing connection for this discussion (likely), then create one.
discussionConnection = await GitHubDiscussionConnection.createDiscussionRoom(
this.as,
+ botUser.intent,
null,
data.repository.owner.login,
data.repository.name,
@@ -469,7 +494,7 @@ export class Bridge {
this.tokenStore,
this.commentProcessor,
this.messageClient,
- this.config.github,
+ this.config,
);
connManager.push(discussionConnection);
} catch (ex) {
@@ -486,7 +511,7 @@ export class Bridge {
}
})
});
-
+
this.bindHandlerToQueue(
"jira.issue_created",
(data) => connManager.getConnectionsForJiraProject(data.issue.fields.project),
@@ -553,7 +578,7 @@ export class Bridge {
});
});
-
+
this.queue.on("generic-webhook.event", async (msg) => {
const { data, messageId } = msg;
const connections = connManager.getConnectionsForGenericWebhook(data.hookId);
@@ -635,30 +660,13 @@ export class Bridge {
(c, data) => c.handleFeedError(data),
);
- // Set the name and avatar of the bot
- if (this.config.bot) {
- // Ensure we are registered before we set a profile
- await this.as.botIntent.ensureRegistered();
- let profile;
- try {
- profile = await this.as.botClient.getUserProfile(this.as.botUserId);
- } catch {
- profile = {}
- }
- if (this.config.bot.avatar && profile.avatar_url !== this.config.bot.avatar) {
- log.info(`Setting avatar to ${this.config.bot.avatar}`);
- await this.as.botClient.setAvatarUrl(this.config.bot.avatar);
- }
- if (this.config.bot.displayname && profile.displayname !== this.config.bot.displayname) {
- log.info(`Setting displayname to ${this.config.bot.displayname}`);
- await this.as.botClient.setDisplayName(this.config.bot.displayname);
- }
- }
const queue = new PQueue({
concurrency: 2,
});
- queue.addAll(joinedRooms.map(((roomId) => async () => {
+ // Set up already joined rooms
+ await queue.addAll(this.botUsersManager.joinedRooms.map((roomId) => async () => {
log.debug("Fetching state for " + roomId);
+
try {
await connManager.createConnectionsForRoomId(roomId, false);
} catch (ex) {
@@ -666,13 +674,19 @@ export class Bridge {
return;
}
+ const botUser = this.botUsersManager.getBotUserInRoom(roomId);
+ if (!botUser) {
+ log.error(`Failed to find a bot in room '${roomId}' when setting up admin room`);
+ return;
+ }
+
// TODO: Refactor this to be a connection
try {
- let accountData = await this.as.botClient.getSafeRoomAccountData(
+ let accountData = await botUser.intent.underlyingClient.getSafeRoomAccountData(
BRIDGE_ROOM_TYPE, roomId,
);
if (!accountData) {
- accountData = await this.as.botClient.getSafeRoomAccountData(
+ accountData = await botUser.intent.underlyingClient.getSafeRoomAccountData(
LEGACY_BRIDGE_ROOM_TYPE, roomId,
);
if (!accountData) {
@@ -680,18 +694,18 @@ export class Bridge {
return;
} else {
// Upgrade the room
- await this.as.botClient.setRoomAccountData(BRIDGE_ROOM_TYPE, roomId, accountData);
+ await botUser.intent.underlyingClient.setRoomAccountData(BRIDGE_ROOM_TYPE, roomId, accountData);
}
}
let notifContent;
try {
- notifContent = await this.as.botClient.getRoomStateEvent(
+ notifContent = await botUser.intent.underlyingClient.getRoomStateEvent(
roomId, NotifFilter.StateType, "",
);
} catch (ex) {
try {
- notifContent = await this.as.botClient.getRoomStateEvent(
+ notifContent = await botUser.intent.underlyingClient.getRoomStateEvent(
roomId, NotifFilter.LegacyStateType, "",
);
}
@@ -699,14 +713,14 @@ export class Bridge {
// No state yet
}
}
- const adminRoom = await this.setUpAdminRoom(roomId, accountData, notifContent || NotifFilter.getDefaultContent());
+ 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);
}
- })));
+ }));
// Handle spaces
for (const discussion of connManager.getAllConnectionsOfType(GitHubDiscussionSpace)) {
@@ -719,16 +733,19 @@ export class Bridge {
const apps = this.listener.getApplicationsForResource('widgets');
if (apps.length > 1) {
throw Error('You may only bind `widgets` to one listener.');
- }
- new BridgeWidgetApi(
+ }
+ this.widgetApi = new BridgeWidgetApi(
this.adminRooms,
this.config,
this.storage,
apps[0],
this.connectionManager,
- this.as.botIntent,
+ this.botUsersManager,
+ this.as,
+ this.tokenStore,
+ this.github,
);
-
+
}
if (this.provisioningApi) {
this.listener.bindResource('provisioning', this.provisioningApi.expressRouter);
@@ -763,36 +780,69 @@ export class Bridge {
/* Do not handle invites from our users */
return;
}
- log.info(`Got invite roomId=${roomId} from=${event.sender} to=${event.state_key}`);
- // Room joins can fail over federation
- if (event.state_key !== this.as.botUserId) {
- return this.as.botClient.kickUser(event.state_key, roomId, "Bridge does not support DMing ghosts");
+ const invitedUserId = event.state_key;
+ if (!invitedUserId) {
+ return;
+ }
+ log.info(`Got invite roomId=${roomId} from=${event.sender} to=${invitedUserId}`);
+
+ const botUser = this.botUsersManager.getBotUser(invitedUserId);
+ if (!botUser) {
+ // We got an invite but it's not a configured bot user, must be for a ghost user
+ log.debug(`Rejecting invite to room ${roomId} for ghost user ${invitedUserId}`);
+ const client = this.as.getIntentForUserId(invitedUserId).underlyingClient;
+ return client.doRequest("POST", "/_matrix/client/v3/rooms/" + encodeURIComponent(roomId) + "/leave", null, {
+ reason: "Bridge does not support DMing ghosts"
+ });
}
// Don't accept invites from people who can't do anything
if (!this.config.checkPermissionAny(event.sender, BridgePermissionLevel.login)) {
- return this.as.botClient.kickUser(this.as.botUserId, roomId, "You do not have permission to invite this bot.");
+ return botUser.intent.underlyingClient.doRequest("POST", "/_matrix/client/v3/rooms/" + encodeURIComponent(roomId) + "/leave", null, {
+ reason: "You do not have permission to invite this bot."
+ });
}
- await retry(() => this.as.botIntent.joinRoom(roomId), 5);
+ if (event.content.is_direct && botUser.userId !== this.as.botUserId) {
+ // Service bots do not support direct messages (admin rooms)
+ log.debug(`Rejecting direct message (admin room) invite to room ${roomId} for service bot ${botUser.userId}`);
+ return botUser.intent.underlyingClient.doRequest("POST", "/_matrix/client/v3/rooms/" + encodeURIComponent(roomId) + "/leave", null, {
+ reason: "This bot does not support admin rooms."
+ });
+ }
+
+ // Accept the invite
+ await retry(() => botUser.intent.joinRoom(roomId), 5);
if (event.content.is_direct) {
- await this.as.botClient.setRoomAccountData(
+ await botUser.intent.underlyingClient.setRoomAccountData(
BRIDGE_ROOM_TYPE, roomId, {admin_user: event.sender},
);
}
}
- private async onRoomLeave(roomId: string, event: MatrixEvent) {
- if (event.state_key !== this.as.botUserId) {
- // Only interested in bot leaves.
+ private async onRoomLeave(roomId: string, matrixEvent: MatrixEvent) {
+ const userId = matrixEvent.state_key;
+ if (!userId) {
return;
}
- // If the bot has left the room, we want to vape all connections for that room.
- try {
- await this.connectionManager?.removeConnectionsForRoom(roomId);
- } catch (ex) {
- log.warn(`Failed to remove connections on leave for ${roomId}`);
+
+ const botUser = this.botUsersManager.getBotUser(userId);
+ if (!botUser) {
+ // Not for one of our bots
+ return;
+ }
+ this.botUsersManager.onRoomLeave(botUser, roomId);
+
+ if (!this.connectionManager) {
+ return;
+ }
+
+ // Remove all the connections for this room
+ await this.connectionManager.removeConnectionsForRoom(roomId);
+ if (this.botUsersManager.getBotUsersInRoom(roomId).length > 0) {
+ // If there are still bots in the room, recreate connections
+ await this.connectionManager.createConnectionsForRoomId(roomId, true);
}
}
@@ -838,13 +888,23 @@ export class Bridge {
}
if (!handled && this.config.checkPermissionAny(event.sender, BridgePermissionLevel.manageConnections)) {
// Divert to the setup room code if we didn't match any of these
- try {
- await (
- new SetupConnection(
+
+ const botUsersInRoom = this.botUsersManager.getBotUsersInRoom(roomId);
+ // Try each bot in the room until one handles the command
+ for (const botUser of botUsersInRoom) {
+ try {
+ const setupConnection = new SetupConnection(
roomId,
+ botUser.prefix,
+ botUser.services,
+ [
+ ...botUser.services,
+ this.config.widgets?.roomSetupWidget ? "widget" : "",
+ ],
{
config: this.config,
as: this.as,
+ intent: botUser.intent,
tokenStore: this.tokenStore,
commentProcessor: this.commentProcessor,
messageClient: this.messageClient,
@@ -852,12 +912,16 @@ export class Bridge {
github: this.github,
getAllConnectionsOfType: this.connectionManager.getAllConnectionsOfType.bind(this.connectionManager),
},
- this.getOrCreateAdminRoomForUser.bind(this),
+ this.getOrCreateAdminRoom.bind(this),
this.connectionManager.push.bind(this.connectionManager),
- )
- ).onMessageEvent(event, checkPermission);
- } catch (ex) {
- log.warn(`Setup connection failed to handle:`, ex);
+ );
+ const handled = await setupConnection.onMessageEvent(event, checkPermission);
+ if (handled) {
+ break;
+ }
+ } catch (ex) {
+ log.warn(`Setup connection failed to handle:`, ex);
+ }
}
}
return;
@@ -900,23 +964,30 @@ export class Bridge {
}
private async onRoomJoin(roomId: string, matrixEvent: MatrixEvent) {
- if (this.as.botUserId !== matrixEvent.sender) {
- // Only act on bot joins
+ const userId = matrixEvent.state_key;
+ if (!userId) {
return;
}
+ const botUser = this.botUsersManager.getBotUser(userId);
+ if (!botUser) {
+ // Not for one of our bots
+ return;
+ }
+ this.botUsersManager.onRoomJoin(botUser, roomId);
+
if (this.config.encryption) {
// Ensure crypto is aware of all members of this room before posting any messages,
// so that the bot can share room keys to all recipients first.
- await this.as.botClient.crypto.onRoomJoin(roomId);
+ await botUser.intent.underlyingClient.crypto.onRoomJoin(roomId);
}
- const adminAccountData = await this.as.botIntent.underlyingClient.getSafeRoomAccountData(
+ const adminAccountData = await botUser.intent.underlyingClient.getSafeRoomAccountData(
BRIDGE_ROOM_TYPE, roomId,
);
if (adminAccountData) {
- const room = await this.setUpAdminRoom(roomId, adminAccountData, NotifFilter.getDefaultContent());
- await this.as.botClient.setRoomAccountData(
+ const room = await this.setUpAdminRoom(botUser.intent, roomId, adminAccountData, NotifFilter.getDefaultContent());
+ await botUser.intent.underlyingClient.setRoomAccountData(
BRIDGE_ROOM_TYPE, roomId, room.accountData,
);
}
@@ -926,20 +997,28 @@ export class Bridge {
return;
}
- // Only fetch rooms we have no connections in yet.
- const roomHasConnection =
- this.connectionManager.isRoomConnected(roomId) ||
- await this.connectionManager.createConnectionsForRoomId(roomId, true);
+ // Recreate connections for the room
+ await this.connectionManager.removeConnectionsForRoom(roomId);
+ await this.connectionManager.createConnectionsForRoomId(roomId, true);
- // If room has connections or is an admin room, don't setup a wizard.
+ // Only fetch rooms we have no connections in yet.
+ const roomHasConnection = this.connectionManager.isRoomConnected(roomId);
+
+ // If room has connections or is an admin room, don't set up a wizard.
// Otherwise it's a new room
if (!roomHasConnection && !adminAccountData && this.config.widgets?.roomSetupWidget?.addOnInvite) {
try {
- if (await this.as.botClient.userHasPowerLevelFor(this.as.botUserId, roomId, "im.vector.modular.widgets", true) === false) {
- await this.as.botIntent.sendText(roomId, "Hello! To setup new integrations in this room, please promote me to a Moderator/Admin");
+ const hasPowerlevel = await botUser.intent.underlyingClient.userHasPowerLevelFor(
+ botUser.intent.userId,
+ roomId,
+ "im.vector.modular.widgets",
+ true,
+ );
+ if (!hasPowerlevel) {
+ await botUser.intent.sendText(roomId, "Hello! To set up new integrations in this room, please promote me to a Moderator/Admin.");
} else {
- // Setup the widget
- await SetupWidget.SetupRoomConfigWidget(roomId, this.as.botIntent, this.config.widgets);
+ // Set up the widget
+ await SetupWidget.SetupRoomConfigWidget(roomId, botUser.intent, this.config.widgets, botUser.services);
}
} catch (ex) {
log.error(`Failed to setup new widget for room`, ex);
@@ -988,28 +1067,31 @@ export class Bridge {
}
}
- // If it's a power level event for a new room, we might want to create the setup widget.
- if (this.config.widgets?.roomSetupWidget?.addOnInvite && event.type === "m.room.power_levels" && event.state_key === "" && !this.connectionManager.isRoomConnected(roomId)) {
- log.debug(`${roomId} got a new powerlevel change and isn't connected to any connections, testing to see if we should create a setup widget`)
- const plEvent = new PowerLevelsEvent(event);
- const currentPl = plEvent.content.users?.[this.as.botUserId] || plEvent.defaultUserLevel;
- const previousPl = plEvent.previousContent?.users?.[this.as.botUserId] || plEvent.previousContent?.users_default;
- const requiredPl = plEvent.content.events?.["im.vector.modular.widgets"] || plEvent.defaultStateEventLevel;
- if (currentPl !== previousPl && currentPl >= requiredPl) {
- // PL changed for bot user, check to see if the widget can be created.
- try {
- log.info(`Bot has powerlevel required to create a setup widget, attempting`);
- await SetupWidget.SetupRoomConfigWidget(roomId, this.as.botIntent, this.config.widgets);
- } catch (ex) {
- log.error(`Failed to create setup widget for ${roomId}`, ex);
+ const botUsersInRoom = this.botUsersManager.getBotUsersInRoom(roomId);
+ for (const botUser of botUsersInRoom) {
+ // If it's a power level event for a new room, we might want to create the setup widget.
+ if (this.config.widgets?.roomSetupWidget?.addOnInvite && event.type === "m.room.power_levels" && event.state_key === "" && !this.connectionManager.isRoomConnected(roomId)) {
+ log.debug(`${roomId} got a new powerlevel change and isn't connected to any connections, testing to see if we should create a setup widget`)
+ const plEvent = new PowerLevelsEvent(event);
+ const currentPl = plEvent.content.users?.[botUser.userId] || plEvent.defaultUserLevel;
+ const previousPl = plEvent.previousContent?.users?.[botUser.userId] || plEvent.previousContent?.users_default;
+ const requiredPl = plEvent.content.events?.["im.vector.modular.widgets"] || plEvent.defaultStateEventLevel;
+ if (currentPl !== previousPl && currentPl >= requiredPl) {
+ // PL changed for bot user, check to see if the widget can be created.
+ try {
+ log.info(`Bot has powerlevel required to create a setup widget, attempting`);
+ await SetupWidget.SetupRoomConfigWidget(roomId, botUser.intent, this.config.widgets, botUser.services);
+ } catch (ex) {
+ log.error(`Failed to create setup widget for ${roomId}`, ex);
+ }
}
}
- }
+ }
return;
}
// We still want to react to our own state events.
- if (event.sender === this.as.botUserId) {
+ if (this.botUsersManager.isBotUser(event.sender)) {
// It's us
return;
}
@@ -1169,16 +1251,16 @@ export class Bridge {
});
}
}
-
+
}
- private async getOrCreateAdminRoomForUser(userId: string): Promise {
+ private async getOrCreateAdminRoom(intent: Intent, userId: string): Promise {
const existingRoom = this.getAdminRoomForUser(userId);
if (existingRoom) {
return existingRoom;
}
- const roomId = await this.as.botClient.dms.getOrCreateDm(userId);
- const room = await this.setUpAdminRoom(roomId, {admin_user: userId}, NotifFilter.getDefaultContent());
+ const roomId = await intent.underlyingClient.dms.getOrCreateDm(userId);
+ const room = await this.setUpAdminRoom(intent, roomId, {admin_user: userId}, NotifFilter.getDefaultContent());
await this.as.botClient.setRoomAccountData(
BRIDGE_ROOM_TYPE, roomId, room.accountData,
);
@@ -1194,23 +1276,28 @@ export class Bridge {
return null;
}
- private async setUpAdminRoom(roomId: string, accountData: AdminAccountData, notifContent: NotificationFilterStateContent) {
+ private async setUpAdminRoom(
+ intent: Intent,
+ roomId: string,
+ accountData: AdminAccountData,
+ notifContent: NotificationFilterStateContent,
+ ) {
if (!this.connectionManager) {
throw Error('setUpAdminRoom() called before connectionManager was ready');
}
const adminRoom = new AdminRoom(
- roomId, accountData, notifContent, this.as.botIntent, this.tokenStore, this.config, this.connectionManager,
+ roomId, accountData, notifContent, intent, this.tokenStore, this.config, this.connectionManager,
);
adminRoom.on("settings.changed", this.onAdminRoomSettingsChanged.bind(this));
adminRoom.on("open.project", async (project: ProjectsGetResponseData) => {
const [connection] = this.connectionManager?.getForGitHubProject(project.id) || [];
if (!connection) {
- const connection = await GitHubProjectConnection.onOpenProject(project, this.as, adminRoom.userId);
+ const connection = await GitHubProjectConnection.onOpenProject(project, this.as, intent, this.config, adminRoom.userId);
this.connectionManager?.push(connection);
} else {
- await this.as.botClient.inviteUser(adminRoom.userId, connection.roomId);
+ await intent.underlyingClient.inviteUser(adminRoom.userId, connection.roomId);
}
});
adminRoom.on("open.gitlab-issue", async (issueInfo: GetIssueOpts, res: GetIssueResponse, instanceName: string, instance: GitLabInstance) => {
@@ -1219,25 +1306,26 @@ export class Bridge {
}
const [ connection ] = this.connectionManager?.getConnectionsForGitLabIssue(instance, issueInfo.projects, issueInfo.issue) || [];
if (connection) {
- return this.as.botClient.inviteUser(adminRoom.userId, connection.roomId);
- }
+ return intent.underlyingClient.inviteUser(adminRoom.userId, connection.roomId);
+ }
const newConnection = await GitLabIssueConnection.createRoomForIssue(
instanceName,
instance,
res,
issueInfo.projects,
this.as,
- this.tokenStore,
+ intent,
+ this.tokenStore,
this.commentProcessor,
this.messageClient,
- this.config.gitlab,
+ this.config,
);
this.connectionManager?.push(newConnection);
- return this.as.botClient.inviteUser(adminRoom.userId, newConnection.roomId);
+ return intent.underlyingClient.inviteUser(adminRoom.userId, newConnection.roomId);
});
this.adminRooms.set(roomId, adminRoom);
if (this.config.widgets?.addToAdminRooms) {
- await SetupWidget.SetupAdminRoomConfigWidget(roomId, this.as.botIntent, this.config.widgets);
+ await SetupWidget.SetupAdminRoomConfigWidget(roomId, intent, this.config.widgets);
}
log.debug(`Set up ${roomId} as an admin room for ${adminRoom.userId}`);
return adminRoom;
diff --git a/src/Config/Config.ts b/src/Config/Config.ts
index bd9aa4de..3d269b8f 100644
--- a/src/Config/Config.ts
+++ b/src/Config/Config.ts
@@ -8,7 +8,7 @@ import { GitHubRepoConnectionOptions } from "../Connections/GithubRepo";
import { BridgeConfigActorPermission, BridgePermissions } from "../libRs";
import { ConfigError } from "../errors";
import { ApiError, ErrCode } from "../api";
-import { GITHUB_CLOUD_URL } from "../Github/GithubInstance";
+import { GithubInstance, GITHUB_CLOUD_URL } from "../Github/GithubInstance";
import { Logger } from "matrix-appservice-bridge";
const log = new Logger("Config");
@@ -79,7 +79,7 @@ export class BridgeConfigGitHub {
@configKey("Prefix used when creating ghost users for GitHub accounts.", true)
readonly userIdPrefix: string;
-
+
@configKey("URL for enterprise deployments. Does not include /api/v3", true)
private enterpriseUrl?: string;
@@ -95,10 +95,10 @@ export class BridgeConfigGitHub {
this.baseUrl = yaml.enterpriseUrl ? new URL(yaml.enterpriseUrl) : GITHUB_CLOUD_URL;
}
- @hideKey()
- public get publicConfig() {
+ public publicConfig(githubInstance?: GithubInstance) {
return {
userIdPrefix: this.userIdPrefix,
+ newInstallationUrl: githubInstance?.newInstallationUrl?.toString(),
}
}
}
@@ -129,12 +129,12 @@ export interface BridgeConfigJiraYAML {
}
export class BridgeConfigJira implements BridgeConfigJiraYAML {
static CLOUD_INSTANCE_NAME = "api.atlassian.com";
-
+
@configKey("Webhook settings for JIRA")
readonly webhook: {
secret: string;
};
-
+
// To hide the undefined for now
@hideKey()
@configKey("URL for the instance if using on prem. Ignore if targetting cloud (atlassian.net)", true)
@@ -411,6 +411,14 @@ interface BridgeConfigEncryption {
storagePath: string;
}
+export interface BridgeConfigServiceBot {
+ localpart: string;
+ displayname?: string;
+ avatar?: string;
+ prefix: string;
+ service: string;
+}
+
export interface BridgeConfigProvisioning {
bindAddress?: string;
port?: number;
@@ -423,8 +431,14 @@ export interface BridgeConfigMetrics {
port?: number;
}
+export interface BridgeConfigGoNebMigrator {
+ apiUrl: string;
+ serviceIds?: string[];
+}
+
export interface BridgeConfigRoot {
bot?: BridgeConfigBot;
+ serviceBots?: BridgeConfigServiceBot[];
bridge: BridgeConfigBridge;
experimentalEncryption?: BridgeConfigEncryption;
figma?: BridgeConfigFigma;
@@ -442,6 +456,7 @@ export interface BridgeConfigRoot {
widgets?: BridgeWidgetConfigYAML;
metrics?: BridgeConfigMetrics;
listeners?: BridgeConfigListener[];
+ goNebMigrator?: BridgeConfigGoNebMigrator;
}
export class BridgeConfig {
@@ -478,6 +493,8 @@ export class BridgeConfig {
public readonly feeds?: BridgeConfigFeeds;
@configKey("Define profile information for the bot user", true)
public readonly bot?: BridgeConfigBot;
+ @configKey("Define additional bot users for specific services", true)
+ public readonly serviceBots?: BridgeConfigServiceBot[];
@configKey("EXPERIMENTAL support for complimentary widgets", true)
public readonly widgets?: BridgeWidgetConfig;
@configKey("Provisioning API for integration managers", true)
@@ -492,18 +509,21 @@ export class BridgeConfig {
'resources' may be any of ${ResourceTypeArray.join(', ')}`, true)
public readonly listeners: BridgeConfigListener[];
+ @configKey("go-neb migrator configuration", true)
+ public readonly goNebMigrator?: BridgeConfigGoNebMigrator;
+
@hideKey()
private readonly bridgePermissions: BridgePermissions;
- constructor(configData: BridgeConfigRoot, env: {[key: string]: string|undefined}) {
+ constructor(configData: BridgeConfigRoot, env?: {[key: string]: string|undefined}) {
this.bridge = configData.bridge;
assert.ok(this.bridge);
this.github = configData.github && new BridgeConfigGitHub(configData.github);
- if (this.github?.auth && env["GITHUB_PRIVATE_KEY_FILE"]) {
- this.github.auth.privateKeyFile = env["GITHUB_PRIVATE_KEY_FILE"];
+ if (this.github?.auth && env?.["GITHUB_PRIVATE_KEY_FILE"]) {
+ this.github.auth.privateKeyFile = env?.["GITHUB_PRIVATE_KEY_FILE"];
}
- if (this.github?.oauth && env["GITHUB_OAUTH_REDIRECT_URI"]) {
- this.github.oauth.redirect_uri = env["GITHUB_OAUTH_REDIRECT_URI"];
+ if (this.github?.oauth && env?.["GITHUB_OAUTH_REDIRECT_URI"]) {
+ this.github.oauth.redirect_uri = env?.["GITHUB_OAUTH_REDIRECT_URI"];
}
this.gitlab = configData.gitlab && new BridgeConfigGitLab(configData.gitlab);
this.figma = configData.figma;
@@ -513,6 +533,7 @@ export class BridgeConfig {
this.provisioning = configData.provisioning;
this.passFile = configData.passFile;
this.bot = configData.bot;
+ this.serviceBots = configData.serviceBots;
this.metrics = configData.metrics;
this.queue = configData.queue || {
monolithic: true,
@@ -525,7 +546,7 @@ export class BridgeConfig {
}
this.widgets = configData.widgets && new BridgeWidgetConfig(configData.widgets);
-
+
// To allow DEBUG as well as debug
this.logging.level = this.logging.level.toLowerCase() as "debug"|"info"|"warn"|"error"|"trace";
if (!ValidLogLevelStrings.includes(this.logging.level)) {
@@ -547,7 +568,7 @@ For more details, see https://github.com/matrix-org/matrix-hookshot/issues/594.
}];
this.bridgePermissions = new BridgePermissions(this.permissions);
- if (!configData.permissions) {
+ if (!configData.permissions) {
log.warn(`You have not configured any permissions for the bridge, which by default means all users on ${this.bridge.domain} have admin levels of control. Please adjust your config.`);
}
@@ -556,12 +577,14 @@ For more details, see https://github.com/matrix-org/matrix-hookshot/issues/594.
}
// TODO: Formalize env support
- if (env.CFG_QUEUE_MONOLITHIC && ["false", "off", "no"].includes(env.CFG_QUEUE_MONOLITHIC)) {
+ if (env?.CFG_QUEUE_MONOLITHIC && ["false", "off", "no"].includes(env.CFG_QUEUE_MONOLITHIC)) {
this.queue.monolithic = false;
- this.queue.host = env.CFG_QUEUE_HOST;
- this.queue.port = env.CFG_QUEUE_POST ? parseInt(env.CFG_QUEUE_POST, 10) : undefined;
+ this.queue.host = env?.CFG_QUEUE_HOST;
+ this.queue.port = env?.CFG_QUEUE_POST ? parseInt(env?.CFG_QUEUE_POST, 10) : undefined;
}
+ this.goNebMigrator = configData.goNebMigrator;
+
// Listeners is a bit special
this.listeners = configData.listeners || [];
@@ -574,7 +597,7 @@ For more details, see https://github.com/matrix-org/matrix-hookshot/issues/594.
});
log.warn("The `webhook` configuration still specifies a port/bindAddress. This should be moved to the `listeners` config.");
}
-
+
if (configData.widgets?.port) {
this.listeners.push({
resources: ['widgets'],
@@ -590,7 +613,7 @@ For more details, see https://github.com/matrix-org/matrix-hookshot/issues/594.
})
log.warn("The `provisioning` configuration still specifies a port/bindAddress. This should be moved to the `listeners` config.");
}
-
+
if (this.metrics?.port) {
this.listeners.push({
resources: ['metrics'],
@@ -599,7 +622,7 @@ For more details, see https://github.com/matrix-org/matrix-hookshot/issues/594.
})
log.warn("The `metrics` configuration still specifies a port/bindAddress. This should be moved to the `listeners` config.");
}
-
+
if (configData.widgets?.port) {
this.listeners.push({
resources: ['widgets'],
@@ -628,6 +651,10 @@ For more details, see https://github.com/matrix-org/matrix-hookshot/issues/594.
if (this.encryption && !this.queue.port) {
throw new ConfigError("queue.port", "You must enable redis support for encryption to work.");
}
+
+ if (this.figma?.overrideUserId) {
+ log.warn("The `figma.overrideUserId` config value is deprecated. A service bot should be configured instead.");
+ }
}
public async prefillMembershipCache(client: MatrixClient) {
@@ -637,7 +664,7 @@ For more details, see https://github.com/matrix-org/matrix-hookshot/issues/594.
const membership = await client.getJoinedRoomMembers(await client.resolveRoom(roomEntry));
membership.forEach(userId => this.bridgePermissions.addMemberToCache(roomEntry, userId));
log.debug(`Found ${membership.length} users for ${roomEntry}`);
- }
+ }
}
public addMemberToCache(roomId: string, userId: string) {
@@ -656,6 +683,29 @@ For more details, see https://github.com/matrix-org/matrix-hookshot/issues/594.
return this.bridgePermissions.checkAction(mxid, service, BridgePermissionLevel[permission]);
}
+ public get enabledServices(): string[] {
+ const services = [];
+ if (this.feeds && this.feeds.enabled) {
+ services.push("feeds");
+ }
+ if (this.figma) {
+ services.push("figma");
+ }
+ if (this.generic && this.generic.enabled) {
+ services.push("generic");
+ }
+ if (this.github) {
+ services.push("github");
+ }
+ if (this.gitlab) {
+ services.push("gitlab");
+ }
+ if (this.jira) {
+ services.push("jira");
+ }
+ return services;
+ }
+
public getPublicConfigForService(serviceName: string): Record {
let config: undefined|Record;
switch (serviceName) {
@@ -666,7 +716,7 @@ For more details, see https://github.com/matrix-org/matrix-hookshot/issues/594.
config = this.generic?.publicConfig;
break;
case "github":
- config = this.github?.publicConfig;
+ config = this.github?.publicConfig();
break;
case "gitlab":
config = this.gitlab?.publicConfig;
diff --git a/src/Config/Defaults.ts b/src/Config/Defaults.ts
index e4498329..5e166f31 100644
--- a/src/Config/Defaults.ts
+++ b/src/Config/Defaults.ts
@@ -1,17 +1,20 @@
-import { BridgeConfig } from "./Config";
+import { BridgeConfig, BridgeConfigRoot } from "./Config";
import YAML from "yaml";
import { getConfigKeyMetadata, keyIsHidden } from "./Decorators";
import { Node, YAMLSeq } from "yaml/types";
import { randomBytes } from "crypto";
import { DefaultDisallowedIpRanges } from "matrix-appservice-bridge";
-export const DefaultConfig = new BridgeConfig({
+const serverName = "example.com";
+const hookshotWebhooksUrl = "https://example.com";
+
+export const DefaultConfigRoot: BridgeConfigRoot = {
bridge: {
- domain: "example.com",
+ domain: serverName,
url: "http://localhost:8008",
- mediaUrl: "http://example.com",
+ mediaUrl: "https://example.com",
port: 9993,
- bindAddress: "127.0.0.1",
+ bindAddress: "127.0.0.1",
},
queue: {
monolithic: true,
@@ -25,7 +28,7 @@ export const DefaultConfig = new BridgeConfig({
timestampFormat: "HH:mm:ss:SSS",
},
permissions: [{
- actor: "example.com",
+ actor: serverName,
services: [{
service: "*",
level: "admin"
@@ -33,7 +36,7 @@ export const DefaultConfig = new BridgeConfig({
}],
passFile: "passkey.pem",
widgets: {
- publicUrl: "http://example.com/widgetapi/v1/static",
+ publicUrl: `${hookshotWebhooksUrl}/widgetapi/v1/static`,
addToAdminRooms: false,
roomSetupWidget: {
addOnInvite: false,
@@ -44,9 +47,18 @@ export const DefaultConfig = new BridgeConfig({
},
},
bot: {
- displayname: "GitHub Bot",
+ displayname: "Hookshot Bot",
avatar: "mxc://half-shot.uk/2876e89ccade4cb615e210c458e2a7a6883fe17d"
},
+ serviceBots: [
+ {
+ localpart: "feeds",
+ displayname: "Feeds",
+ avatar: "mxc://half-shot.uk/2876e89ccade4cb615e210c458e2a7a6883fe17d",
+ prefix: "!feeds",
+ service: "feeds",
+ },
+ ],
github: {
auth: {
id: 123,
@@ -55,7 +67,7 @@ export const DefaultConfig = new BridgeConfig({
oauth: {
client_id: "foo",
client_secret: "bar",
- redirect_uri: "https://example.com/bridge_oauth/",
+ redirect_uri: `${hookshotWebhooksUrl}/bridge_oauth/`,
},
webhook: {
secret: "secrettoken",
@@ -76,7 +88,7 @@ export const DefaultConfig = new BridgeConfig({
},
webhook: {
secret: "secrettoken",
- publicUrl: "https://example.com/hookshot/"
+ publicUrl: `${hookshotWebhooksUrl}/hookshot/`,
},
userIdPrefix: "_gitlab_",
},
@@ -87,19 +99,19 @@ export const DefaultConfig = new BridgeConfig({
oauth: {
client_id: "foo",
client_secret: "bar",
- redirect_uri: "https://example.com/bridge_oauth/",
+ redirect_uri: `${hookshotWebhooksUrl}/bridge_oauth/`,
},
},
generic: {
allowJsTransformationFunctions: false,
enabled: false,
enableHttpGet: false,
- urlPrefix: "https://example.com/webhook/",
+ urlPrefix: `${hookshotWebhooksUrl}/webhook/`,
userIdPrefix: "_webhooks_",
waitForComplete: false,
},
figma: {
- publicUrl: "https://example.com/hookshot/",
+ publicUrl: `${hookshotWebhooksUrl}/hookshot/`,
instances: {
"your-instance": {
teamId: "your-team-id",
@@ -135,7 +147,9 @@ export const DefaultConfig = new BridgeConfig({
resources: ['widgets'],
}
]
-}, {});
+};
+
+export const DefaultConfig = new BridgeConfig(DefaultConfigRoot);
function renderSection(doc: YAML.Document, obj: Record, parentNode?: YAMLSeq) {
const entries = Object.entries(obj);
diff --git a/src/ConnectionManager.ts b/src/ConnectionManager.ts
index b83b9990..b509c7e1 100644
--- a/src/ConnectionManager.ts
+++ b/src/ConnectionManager.ts
@@ -4,8 +4,8 @@
* Manages connections between Matrix rooms and the remote side.
*/
+import { Appservice, Intent, StateEvent } from "matrix-bot-sdk";
import { ApiError, ErrCode } from "./api";
-import { Appservice, StateEvent } from "matrix-bot-sdk";
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";
@@ -18,6 +18,7 @@ import { JiraProject, JiraVersion } from "./Jira/Types";
import { Logger } from "matrix-appservice-bridge";
import { MessageSenderClient } from "./MatrixSender";
import { UserTokenStore } from "./UserTokenStore";
+import BotUsersManager from "./Managers/BotUsersManager";
import { retry, retryMatrixErrorFilter } from "./PromiseUtil";
import Metrics from "./Metrics";
import EventEmitter from "events";
@@ -42,6 +43,7 @@ export class ConnectionManager extends EventEmitter {
private readonly commentProcessor: CommentProcessor,
private readonly messageClient: MessageSenderClient,
private readonly storage: IBridgeStorageProvider,
+ private readonly botUsersManager: BotUsersManager,
private readonly github?: GithubInstance
) {
super();
@@ -66,21 +68,29 @@ export class ConnectionManager extends EventEmitter {
/**
* Used by the provisioner API to create new connections on behalf of users.
+ *
* @param roomId The target Matrix room.
+ * @param intent Bot user intent to create the connection with.
* @param userId The requesting Matrix user.
- * @param type The type of room (corresponds to the event type of the connection)
+ * @param connectionType The connection declaration to provision.
* @param data The data corresponding to the connection state. This will be validated.
* @returns The resulting connection.
*/
- public async provisionConnection(roomId: string, userId: string, type: string, data: Record) {
- log.info(`Looking to provision connection for ${roomId} ${type} for ${userId} with data ${JSON.stringify(data)}`);
- const connectionType = ConnectionDeclarations.find(c => c.EventTypes.includes(type));
+ public async provisionConnection(
+ roomId: string,
+ intent: Intent,
+ userId: string,
+ connectionType: ConnectionDeclaration,
+ data: Record,
+ ) {
+ log.info(`Looking to provision connection for ${roomId} ${connectionType.ServiceCategory} for ${userId} with data ${JSON.stringify(data)}`);
if (connectionType?.provisionConnection) {
if (!this.config.checkPermission(userId, connectionType.ServiceCategory, BridgePermissionLevel.manageConnections)) {
throw new ApiError(`User is not permitted to provision connections for this type of service.`, ErrCode.ForbiddenUser);
}
const result = await connectionType.provisionConnection(roomId, userId, data, {
as: this.as,
+ intent: intent,
config: this.config,
tokenStore: this.tokenStore,
commentProcessor: this.commentProcessor,
@@ -99,14 +109,15 @@ export class ConnectionManager extends EventEmitter {
* Check if a state event is sent by a user who is allowed to configure the type of connection the state event covers.
* If it isn't, optionally revert the state to the last-known valid value, or redact it if that isn't possible.
* @param roomId The target Matrix room.
+ * @param intent The bot intent to use.
* @param state The state event for altering a connection in the room.
* @param serviceType The type of connection the state event is altering.
* @returns Whether the state event was allowed to be set. If not, the state will be reverted asynchronously.
*/
- public verifyStateEvent(roomId: string, state: StateEvent, serviceType: string, rollbackBadState: boolean) {
+ public verifyStateEvent(roomId: string, intent: Intent, state: StateEvent, serviceType: string, rollbackBadState: boolean) {
if (!this.isStateAllowed(roomId, state, serviceType)) {
if (rollbackBadState) {
- void this.tryRestoreState(roomId, state, serviceType);
+ void this.tryRestoreState(roomId, intent, state, serviceType);
}
log.error(`User ${state.sender} is disallowed to manage state for ${serviceType} in ${roomId}`);
return false;
@@ -121,50 +132,72 @@ export class ConnectionManager extends EventEmitter {
* @param state The state event for altering a connection in the room targeted by {@link connection}.
* @returns Whether the state event was allowed to be set. If not, the state will be reverted asynchronously.
*/
- public verifyStateEventForConnection(connection: IConnection, state: StateEvent, rollbackBadState: boolean) {
+ public verifyStateEventForConnection(connection: IConnection, state: StateEvent, rollbackBadState: boolean): boolean {
const cd: ConnectionDeclaration = Object.getPrototypeOf(connection).constructor;
- return !this.verifyStateEvent(connection.roomId, state, cd.ServiceCategory, rollbackBadState);
+ const botUser = this.botUsersManager.getBotUserInRoom(connection.roomId, cd.ServiceCategory);
+ if (!botUser) {
+ log.error(`Failed to find a bot in room '${connection.roomId}' for service type '${cd.ServiceCategory}' when verifying state for connection`);
+ throw Error('Could not find a bot to handle this connection');
+ }
+ return this.verifyStateEvent(connection.roomId, botUser.intent, state, cd.ServiceCategory, rollbackBadState);
}
private isStateAllowed(roomId: string, state: StateEvent, serviceType: string) {
- return state.sender === this.as.botUserId
+ return this.botUsersManager.isBotUser(state.sender)
|| this.config.checkPermission(state.sender, serviceType, BridgePermissionLevel.manageConnections);
}
- private async tryRestoreState(roomId: string, originalState: StateEvent, serviceType: string) {
+ private async tryRestoreState(roomId: string, intent: Intent, originalState: StateEvent, serviceType: string) {
let state = originalState;
let attemptsRemaining = 5;
try {
do {
if (state.unsigned.replaces_state) {
- state = new StateEvent(await this.as.botClient.getEvent(roomId, state.unsigned.replaces_state));
+ state = new StateEvent(await intent.underlyingClient.getEvent(roomId, state.unsigned.replaces_state));
} else {
- await this.as.botClient.redactEvent(roomId, originalState.eventId,
+ await intent.underlyingClient.redactEvent(roomId, originalState.eventId,
`User ${originalState.sender} is disallowed to manage state for ${serviceType} in ${roomId}`);
return;
}
} while (--attemptsRemaining > 0 && !this.isStateAllowed(roomId, state, serviceType));
- await this.as.botClient.sendStateEvent(roomId, state.type, state.stateKey, state.content);
+ await intent.underlyingClient.sendStateEvent(roomId, state.type, state.stateKey, state.content);
} catch (ex) {
log.warn(`Unable to undo state event from ${state.sender} for disallowed ${serviceType} connection management in ${roomId}`);
}
}
- public createConnectionForState(roomId: string, state: StateEvent, rollbackBadState: boolean) {
+ /**
+ * This is called ONLY when we spot new state in a room and want to create a connection for it.
+ * @param roomId
+ * @param state
+ * @param rollbackBadState
+ * @returns
+ */
+ public async createConnectionForState(roomId: string, state: StateEvent, rollbackBadState: boolean) {
// Empty object == redacted
if (state.content.disabled === true || Object.keys(state.content).length === 0) {
log.debug(`${roomId} has disabled state for ${state.type}`);
return;
}
- const connectionType = ConnectionDeclarations.find(c => c.EventTypes.includes(state.type));
+ const connectionType = this.getConnectionTypeForEventType(state.type);
if (!connectionType) {
return;
}
- if (!this.verifyStateEvent(roomId, state, connectionType.ServiceCategory, rollbackBadState)) {
+
+ // Get a bot user for the connection type
+ const botUser = this.botUsersManager.getBotUserInRoom(roomId, connectionType.ServiceCategory);
+ if (!botUser) {
+ log.error(`Failed to find a bot in room '${roomId}' for service type '${connectionType.ServiceCategory}' when creating connection for state`);
+ throw Error('Could not find a bot to handle this connection');
+ }
+
+ if (!this.verifyStateEvent(roomId, botUser.intent, state, connectionType.ServiceCategory, rollbackBadState)) {
return;
}
- return connectionType.createConnectionForState(roomId, state, {
+
+ const connection = await connectionType.createConnectionForState(roomId, state, {
as: this.as,
+ intent: botUser.intent,
config: this.config,
tokenStore: this.tokenStore,
commentProcessor: this.commentProcessor,
@@ -172,13 +205,29 @@ export class ConnectionManager extends EventEmitter {
storage: this.storage,
github: this.github,
});
+
+ // Finally, ensure the connection is allowed by us.
+ await connection.ensureGrant?.(state.sender);
+ return connection;
}
+ /**
+ * This is called when hookshot starts up, or a hookshot service bot has left
+ * and we need to recalculate the right bots for all the connections in a room.
+ * @param roomId
+ * @param rollbackBadState
+ * @returns
+ */
public async createConnectionsForRoomId(roomId: string, rollbackBadState: boolean) {
- let connectionCreated = false;
+ const botUser = this.botUsersManager.getBotUserInRoom(roomId);
+ if (!botUser) {
+ log.error(`Failed to find a bot in room '${roomId}' when creating connections`);
+ return;
+ }
+
// This endpoint can be heavy, wrap it in pillows.
const state = await retry(
- () => this.as.botClient.getRoomState(roomId),
+ () => botUser.intent.underlyingClient.getRoomState(roomId),
GET_STATE_ATTEMPTS,
GET_STATE_TIMEOUT_MS,
retryMatrixErrorFilter
@@ -190,13 +239,11 @@ export class ConnectionManager extends EventEmitter {
if (conn) {
log.debug(`Room ${roomId} is connected to: ${conn}`);
this.push(conn);
- connectionCreated = true;
}
} catch (ex) {
log.error(`Failed to create connection for ${roomId}:`, ex);
}
}
- return connectionCreated;
}
public getConnectionsForGithubIssue(org: string, repo: string, issueNumber: number): (GitHubIssueConnection|GitHubRepoConnection)[] {
@@ -289,12 +336,16 @@ export class ConnectionManager extends EventEmitter {
public getConnectionsForFeedUrl(url: string): FeedConnection[] {
return this.connections.filter(c => c instanceof FeedConnection && c.feedUrl === url) as FeedConnection[];
}
-
+
// 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[];
}
+ public getConnectionTypeForEventType(eventType: string): ConnectionDeclaration | undefined {
+ return ConnectionDeclarations.find(c => c.EventTypes.includes(eventType));
+ }
+
public isRoomConnected(roomId: string): boolean {
return !!this.connections.find(c => c.roomId === roomId);
}
@@ -344,7 +395,7 @@ export class ConnectionManager extends EventEmitter {
/**
* Removes connections for a room from memory. This does NOT remove the state
* event from the room.
- * @param roomId
+ * @param roomId
*/
public async removeConnectionsForRoom(roomId: string) {
log.info(`Removing all connections from ${roomId}`);
@@ -363,14 +414,14 @@ export class ConnectionManager extends EventEmitter {
/**
* Get a list of possible targets for a given connection type when provisioning
- * @param userId
- * @param type
+ * @param userId
+ * @param type
*/
async getConnectionTargets(userId: string, type: string, filters: Record = {}): Promise {
switch (type) {
case GitLabRepoConnection.CanonicalEventType: {
const configObject = this.validateConnectionTarget(userId, this.config.gitlab, "GitLab", "gitlab");
- return await GitLabRepoConnection.getConnectionTargets(userId, this.tokenStore, configObject, filters);
+ return await GitLabRepoConnection.getConnectionTargets(userId, configObject, filters, this.tokenStore, this.storage);
}
case GitHubRepoConnection.CanonicalEventType: {
this.validateConnectionTarget(userId, this.config.github, "GitHub", "github");
diff --git a/src/Connections/CommandConnection.ts b/src/Connections/CommandConnection.ts
index 7b511a76..4d31d046 100644
--- a/src/Connections/CommandConnection.ts
+++ b/src/Connections/CommandConnection.ts
@@ -10,17 +10,17 @@ const log = new Logger("CommandConnection");
* Connection class that handles commands for a given connection. Should be used
* by connections expecting to handle user input.
*/
-export abstract class CommandConnection extends BaseConnection {
- protected enabledHelpCategories?: string[];
+export abstract class CommandConnection extends BaseConnection {
protected includeTitlesInHelp?: boolean;
constructor(
roomId: string,
stateKey: string,
canonicalStateType: string,
- protected state: StateType,
+ protected state: ValidatedStateType,
private readonly botClient: MatrixClient,
private readonly botCommands: BotCommands,
private readonly helpMessage: HelpFunction,
+ protected readonly helpCategories: string[],
protected readonly defaultCommandPrefix: string,
protected readonly serviceName?: string,
) {
@@ -36,10 +36,10 @@ export abstract class CommandConnection) {
- this.state = this.validateConnectionState(stateEv.content);
+ this.state = await this.validateConnectionState(stateEv.content);
}
- protected abstract validateConnectionState(content: unknown): StateType;
+ protected abstract validateConnectionState(content: unknown): Promise|ValidatedStateType;
public async onMessageEvent(ev: MatrixEvent, checkPermission: PermissionCheckFn) {
const commandResult = await handleCommand(
@@ -80,6 +80,6 @@ export abstract class CommandConnection, {config, as, storage}: InstantiateConnectionOpts) {
+ public static createConnectionForState(roomId: string, event: StateEvent, {config, as, intent, storage}: InstantiateConnectionOpts) {
if (!config.feeds?.enabled) {
throw Error('RSS/Atom feeds are not configured');
}
- return new FeedConnection(roomId, event.stateKey, event.content, config.feeds, as, storage);
+ return new FeedConnection(roomId, event.stateKey, event.content, config.feeds, as, intent, storage);
}
static async validateUrl(url: string): Promise {
@@ -74,7 +74,7 @@ export class FeedConnection extends BaseConnection implements IConnection {
}
}
- static async provisionConnection(roomId: string, _userId: string, data: Record = {}, {as, config, storage}: ProvisionConnectionOpts) {
+ static async provisionConnection(roomId: string, _userId: string, data: Record = {}, {as, intent, config, storage}: ProvisionConnectionOpts) {
if (!config.feeds?.enabled) {
throw new ApiError('RSS/Atom feeds are not configured', ErrCode.DisabledFeature);
}
@@ -90,8 +90,8 @@ export class FeedConnection extends BaseConnection implements IConnection {
const state = { url, label: data.label };
- const connection = new FeedConnection(roomId, url, state, config.feeds, as, storage);
- await as.botClient.sendStateEvent(roomId, FeedConnection.CanonicalEventType, url, state);
+ const connection = new FeedConnection(roomId, url, state, config.feeds, as, intent, storage);
+ await intent.underlyingClient.sendStateEvent(roomId, FeedConnection.CanonicalEventType, url, state);
return {
connection,
@@ -104,14 +104,13 @@ export class FeedConnection extends BaseConnection implements IConnection {
service: "feeds",
eventType: FeedConnection.CanonicalEventType,
type: "Feed",
- // TODO: Add ability to configure the bot per connnection type.
botUserId: botUserId,
}
}
public getProvisionerDetails(): FeedResponseItem {
return {
- ...FeedConnection.getProvisionerDetails(this.as.botUserId),
+ ...FeedConnection.getProvisionerDetails(this.intent.userId),
id: this.connectionId,
config: {
url: this.feedUrl,
@@ -136,6 +135,7 @@ export class FeedConnection extends BaseConnection implements IConnection {
private state: FeedConnectionState,
private readonly config: BridgeConfigFeeds,
private readonly as: Appservice,
+ private readonly intent: Intent,
private readonly storage: IBridgeStorageProvider
) {
super(roomId, stateKey, FeedConnection.CanonicalEventType)
@@ -160,7 +160,7 @@ export class FeedConnection extends BaseConnection implements IConnection {
message += `: ${entryDetails}`;
}
- await this.as.botIntent.sendEvent(this.roomId, {
+ await this.intent.sendEvent(this.roomId, {
msgtype: 'm.notice',
format: "org.matrix.custom.html",
formatted_body: md.renderInline(message),
@@ -190,7 +190,7 @@ export class FeedConnection extends BaseConnection implements IConnection {
return;
}
if (!this.hasError) {
- await this.as.botIntent.sendEvent(this.roomId, {
+ await this.intent.sendEvent(this.roomId, {
msgtype: 'm.notice',
format: 'm.text',
body: `Error fetching ${this.feedUrl}: ${error.cause.message}`
@@ -202,7 +202,7 @@ export class FeedConnection extends BaseConnection implements IConnection {
// needed to ensure that the connection is removable
public async onRemove(): Promise {
log.info(`Removing connection ${this.connectionId}`);
- await this.as.botClient.sendStateEvent(this.roomId, FeedConnection.CanonicalEventType, this.feedUrl, {});
+ await this.intent.underlyingClient.sendStateEvent(this.roomId, FeedConnection.CanonicalEventType, this.feedUrl, {});
}
toString(): string {
diff --git a/src/Connections/FigmaFileConnection.ts b/src/Connections/FigmaFileConnection.ts
index c42f4bfd..f1e1fc21 100644
--- a/src/Connections/FigmaFileConnection.ts
+++ b/src/Connections/FigmaFileConnection.ts
@@ -1,12 +1,13 @@
-import { Appservice, StateEvent } from "matrix-bot-sdk";
+import { Appservice, Intent, StateEvent } from "matrix-bot-sdk";
import markdownit from "markdown-it";
import { FigmaPayload } from "../figma/types";
import { BaseConnection } from "./BaseConnection";
import { IConnection, IConnectionState } from ".";
import { Logger } from "matrix-appservice-bridge";
import { IBridgeStorageProvider } from "../Stores/StorageProvider";
-import { BridgeConfigFigma } from "../Config/Config";
+import { BridgeConfig } from "../Config/Config";
import { Connection, InstantiateConnectionOpts, ProvisionConnectionOpts } from "./IConnection";
+import { ConfigGrantChecker, GrantChecker } from "../grants/GrantCheck";
const log = new Logger("FigmaFileConnection");
@@ -29,7 +30,7 @@ export class FigmaFileConnection extends BaseConnection implements IConnection {
];
static readonly ServiceCategory = "figma";
-
+
public static validateState(data: Record): FigmaFileConnectionState {
if (!data.fileId || typeof data.fileId !== "string") {
throw Error('Missing or invalid fileId');
@@ -43,33 +44,36 @@ export class FigmaFileConnection extends BaseConnection implements IConnection {
}
}
- public static createConnectionForState(roomId: string, event: StateEvent, {config, as, storage}: InstantiateConnectionOpts) {
+ public static createConnectionForState(roomId: string, event: StateEvent, {config, as, intent, storage}: InstantiateConnectionOpts) {
if (!config.figma) {
throw Error('Figma is not configured');
}
- return new FigmaFileConnection(roomId, event.stateKey, event.content, config.figma, as, storage);
+ return new FigmaFileConnection(roomId, event.stateKey, event.content, config, as, intent, storage);
}
- static async provisionConnection(roomId: string, userId: string, data: Record = {}, {as, config, storage}: ProvisionConnectionOpts) {
+ static async provisionConnection(roomId: string, userId: string, data: Record = {}, {as, intent, config, storage}: ProvisionConnectionOpts) {
if (!config.figma) {
throw Error('Figma is not configured');
}
const validState = this.validateState(data);
- const connection = new FigmaFileConnection(roomId, validState.fileId, validState, config.figma, as, storage);
- await as.botClient.sendStateEvent(roomId, FigmaFileConnection.CanonicalEventType, validState.fileId, validState);
+ const connection = new FigmaFileConnection(roomId, validState.fileId, validState, config, as, intent, storage);
+ await new GrantChecker(as.botIntent, "figma").grantConnection(roomId, { fileId: validState.fileId, instanceName: validState.instanceName || "none"});
+ await intent.underlyingClient.sendStateEvent(roomId, FigmaFileConnection.CanonicalEventType, validState.fileId, validState);
return {
connection,
stateEventContent: validState,
}
}
+ private readonly grantChecker: GrantChecker<{fileId: string, instanceName: string}> = new ConfigGrantChecker("figma", this.as, this.config);
constructor(
roomId: string,
stateKey: string,
private state: FigmaFileConnectionState,
- private readonly config: BridgeConfigFigma,
+ private readonly config: BridgeConfig,
private readonly as: Appservice,
+ private readonly intent: Intent,
private readonly storage: IBridgeStorageProvider) {
super(roomId, stateKey, FigmaFileConnection.CanonicalEventType)
}
@@ -90,6 +94,14 @@ export class FigmaFileConnection extends BaseConnection implements IConnection {
return this.state.priority || super.priority;
}
+ public async ensureGrant(sender?: string) {
+ return this.grantChecker.assertConnectionGranted(this.roomId, { fileId: this.state.fileId, instanceName: this.state.instanceName || "none"}, sender);
+ }
+
+ public async onRemove() {
+ return this.grantChecker.ungrantConnection(this.roomId, { fileId: this.state.fileId, instanceName: this.state.instanceName || "none"});
+ }
+
public async handleNewComment(payload: FigmaPayload) {
// We need to check if the comment was actually new.
// There isn't a way to tell how the comment has changed, so for now check the timestamps
@@ -100,8 +112,13 @@ export class FigmaFileConnection extends BaseConnection implements IConnection {
return;
}
- const intent = this.as.getIntentForUserId(this.config.overrideUserId || this.as.botUserId);
-
+ let intent;
+ if (this.config.figma?.overrideUserId) {
+ intent = this.as.getIntentForUserId(this.config.figma.overrideUserId);
+ } else {
+ intent = this.intent;
+ }
+
const permalink = `https://www.figma.com/file/${payload.file_key}#${payload.comment_id}`;
const comment = payload.comment.map(({text}) => text).join("\n");
const empty = ""; // This contains an empty character to thwart the notification matcher.
@@ -109,7 +126,7 @@ export class FigmaFileConnection extends BaseConnection implements IConnection {
let content: Record|undefined = undefined;
const parentEventId = payload.parent_id && await this.storage.getFigmaCommentEventId(this.roomId, payload.parent_id);
if (parentEventId) {
- content = {
+ content = {
"m.relates_to": {
rel_type: THREAD_RELATION_TYPE,
event_id: parentEventId,
diff --git a/src/Connections/GenericHook.ts b/src/Connections/GenericHook.ts
index 211dcf87..173e805d 100644
--- a/src/Connections/GenericHook.ts
+++ b/src/Connections/GenericHook.ts
@@ -4,12 +4,13 @@ import { MessageSenderClient } from "../MatrixSender"
import markdownit from "markdown-it";
import { VMScript as Script, NodeVM } from "vm2";
import { MatrixEvent } from "../MatrixEvent";
-import { Appservice, StateEvent } from "matrix-bot-sdk";
-import { v4 as uuid} from "uuid";
+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 { ensureUserIsInRoom } from "../IntentUtils";
+import { randomUUID } from 'node:crypto';
export interface GenericHookConnectionState extends IConnectionState {
/**
@@ -68,14 +69,14 @@ export class GenericHookConnection extends BaseConnection implements IConnection
/**
* Ensures a JSON payload is compatible with Matrix JSON requirements, such
* as disallowing floating point values.
- *
+ *
* If the `depth` exceeds `SANITIZE_MAX_DEPTH`, the value of `data` will be immediately returned.
* If the object contains more than `SANITIZE_MAX_BREADTH` entries, the remaining entries will not be checked.
- *
+ *
* @param data The data to santise
* @param depth The depth of the `data` relative to the root.
* @param breadth The breadth of the `data` in the parent object.
- * @returns
+ * @returns
*/
static sanitiseObjectForMatrixJSON(data: unknown, depth = 0, breadth = 0): unknown {
// Floats
@@ -91,7 +92,7 @@ export class GenericHookConnection extends BaseConnection implements IConnection
if (depth > SANITIZE_MAX_DEPTH || breadth > SANITIZE_MAX_BREADTH) {
return JSON.stringify(data);
}
-
+
const newDepth = depth + 1;
if (Array.isArray(data)) {
return data.map((d, innerBreadth) => this.sanitiseObjectForMatrixJSON(d, newDepth, innerBreadth));
@@ -130,19 +131,19 @@ export class GenericHookConnection extends BaseConnection implements IConnection
};
}
- static async createConnectionForState(roomId: string, event: StateEvent>, {as, config, messageClient}: InstantiateConnectionOpts) {
+ static async createConnectionForState(roomId: string, event: StateEvent>, {as, intent, config, messageClient}: InstantiateConnectionOpts) {
if (!config.generic) {
throw Error('Generic webhooks are not configured');
}
// Generic hooks store the hookId in the account data
- const acctData = await as.botClient.getSafeRoomAccountData(GenericHookConnection.CanonicalEventType, roomId, {});
+ const acctData = await intent.underlyingClient.getSafeRoomAccountData(GenericHookConnection.CanonicalEventType, roomId, {});
const state = this.validateState(event.content);
// hookId => stateKey
let hookId = Object.entries(acctData).find(([, v]) => v === event.stateKey)?.[0];
if (!hookId) {
- hookId = uuid();
+ hookId = randomUUID();
log.warn(`hookId for ${roomId} not set in accountData, setting to ${hookId}`);
- await GenericHookConnection.ensureRoomAccountData(roomId, as, hookId, event.stateKey);
+ await GenericHookConnection.ensureRoomAccountData(roomId, intent, hookId, event.stateKey);
}
return new GenericHookConnection(
@@ -153,18 +154,19 @@ export class GenericHookConnection extends BaseConnection implements IConnection
messageClient,
config.generic,
as,
+ intent,
);
}
- static async provisionConnection(roomId: string, userId: string, data: Record = {}, {as, config, messageClient}: ProvisionConnectionOpts) {
+ static async provisionConnection(roomId: string, userId: string, data: Record = {}, {as, intent, config, messageClient}: ProvisionConnectionOpts) {
if (!config.generic) {
throw Error('Generic Webhooks are not configured');
}
- const hookId = uuid();
+ const hookId = randomUUID();
const validState = GenericHookConnection.validateState(data, config.generic.allowJsTransformationFunctions || false);
- await GenericHookConnection.ensureRoomAccountData(roomId, as, hookId, validState.name);
- await as.botClient.sendStateEvent(roomId, this.CanonicalEventType, validState.name, validState);
- const connection = new GenericHookConnection(roomId, validState, hookId, validState.name, messageClient, config.generic, as);
+ 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);
return {
connection,
stateEventContent: validState,
@@ -173,25 +175,22 @@ export class GenericHookConnection extends BaseConnection implements IConnection
/**
* This function ensures the account data for a room contains all the hookIds for the various state events.
- * @param roomId
- * @param as
- * @param connection
*/
- static async ensureRoomAccountData(roomId: string, as: Appservice, hookId: string, stateKey: string, remove = false) {
- const data = await as.botClient.getSafeRoomAccountData(GenericHookConnection.CanonicalEventType, roomId, {});
+ static async ensureRoomAccountData(roomId: string, intent: Intent, hookId: string, stateKey: string, remove = false) {
+ const data = await intent.underlyingClient.getSafeRoomAccountData(GenericHookConnection.CanonicalEventType, roomId, {});
if (remove && data[hookId] === stateKey) {
delete data[hookId];
- await as.botClient.setRoomAccountData(GenericHookConnection.CanonicalEventType, roomId, data);
+ await intent.underlyingClient.setRoomAccountData(GenericHookConnection.CanonicalEventType, roomId, data);
}
if (!remove && data[hookId] !== stateKey) {
data[hookId] = stateKey;
- await as.botClient.setRoomAccountData(GenericHookConnection.CanonicalEventType, roomId, data);
+ await intent.underlyingClient.setRoomAccountData(GenericHookConnection.CanonicalEventType, roomId, data);
}
}
static readonly CanonicalEventType = "uk.half-shot.matrix-hookshot.generic.hook";
static readonly LegacyCanonicalEventType = "uk.half-shot.matrix-github.generic.hook";
- static readonly ServiceCategory = "webhooks";
+ static readonly ServiceCategory = "generic";
static readonly EventTypes = [
GenericHookConnection.CanonicalEventType,
@@ -200,23 +199,25 @@ export class GenericHookConnection extends BaseConnection implements IConnection
private transformationFunction?: Script;
private cachedDisplayname?: string;
- constructor(roomId: string,
+ constructor(
+ roomId: string,
private state: GenericHookConnectionState,
public readonly hookId: string,
stateKey: string,
private readonly messageClient: MessageSenderClient,
private readonly config: BridgeConfigGenericWebhooks,
- private readonly as: Appservice) {
- super(roomId, stateKey, GenericHookConnection.CanonicalEventType);
- if (state.transformationFunction && config.allowJsTransformationFunctions) {
- this.transformationFunction = new Script(state.transformationFunction);
- }
- }
-
- public get priority(): number {
- return this.state.priority || super.priority;
+ private readonly as: Appservice,
+ private readonly intent: Intent,
+ ) {
+ super(roomId, stateKey, GenericHookConnection.CanonicalEventType);
+ if (state.transformationFunction && config.allowJsTransformationFunctions) {
+ this.transformationFunction = new Script(state.transformationFunction);
}
+ }
+ public get priority(): number {
+ return this.state.priority || super.priority;
+ }
public isInterestedInStateEvent(eventType: string, stateKey: string) {
return GenericHookConnection.EventTypes.includes(eventType) && this.stateKey === stateKey;
@@ -224,32 +225,31 @@ export class GenericHookConnection extends BaseConnection implements IConnection
public getUserId() {
if (!this.config.userIdPrefix) {
- return this.as.botUserId;
+ return this.intent.userId;
}
- const [, domain] = this.as.botUserId.split(':');
+ const [, domain] = this.intent.userId.split(':');
const name = this.state.name &&
this.state.name.replace(/[A-Z]/g, (s) => s.toLowerCase()).replace(/([^a-z0-9\-.=_]+)/g, '');
return `@${this.config.userIdPrefix}${name || 'bot'}:${domain}`;
}
- public async ensureDisplayname(sender: string) {
+ public async ensureDisplayname(intent: Intent) {
if (!this.state.name) {
return;
}
- if (sender === this.as.botUserId) {
- // Don't set the global displayname for the bot.
- return;
+ if (this.intent.userId === intent.userId) {
+ // Don't set a displayname on the root bot user.
+ return;
}
- const intent = this.as.getIntentForUserId(sender);
+ await intent.ensureRegistered();
const expectedDisplayname = `${this.state.name} (Webhook)`;
try {
if (this.cachedDisplayname !== expectedDisplayname) {
- this.cachedDisplayname = (await intent.underlyingClient.getUserProfile(sender)).displayname;
+ this.cachedDisplayname = (await intent.underlyingClient.getUserProfile(this.intent.userId)).displayname;
}
} catch (ex) {
// Couldn't fetch, probably not set.
- await intent.ensureRegistered();
this.cachedDisplayname = undefined;
}
if (this.cachedDisplayname !== expectedDisplayname) {
@@ -264,7 +264,7 @@ export class GenericHookConnection extends BaseConnection implements IConnection
try {
this.transformationFunction = new Script(validatedConfig.transformationFunction);
} catch (ex) {
- await this.messageClient.sendMatrixText(this.roomId, 'Could not compile transformation function:' + ex);
+ await this.messageClient.sendMatrixText(this.roomId, 'Could not compile transformation function:' + ex, "m.text", this.intent.userId);
}
} else {
this.transformationFunction = undefined;
@@ -352,7 +352,7 @@ export class GenericHookConnection extends BaseConnection implements IConnection
/**
* Processes an incoming generic hook
* @param data Structured data. This may either be a string, or an object.
- * @returns `true` if the webhook completed, or `false` if it failed to complete
+ * @returns `true` if the webhook completed, or `false` if it failed to complete
*/
public async onGenericHook(data: unknown): Promise {
log.info(`onGenericHook ${this.roomId} ${this.hookId}`);
@@ -376,11 +376,14 @@ export class GenericHookConnection extends BaseConnection implements IConnection
}
const sender = this.getUserId();
- await this.ensureDisplayname(sender);
+ const senderIntent = this.as.getIntentForUserId(sender);
+ await this.ensureDisplayname(senderIntent);
+
+ await ensureUserIsInRoom(senderIntent, this.intent.underlyingClient, this.roomId);
// Matrix cannot handle float data, so make sure we parse out any floats.
const safeData = GenericHookConnection.sanitiseObjectForMatrixJSON(data);
-
+
await this.messageClient.sendMatrixMessage(this.roomId, {
msgtype: content.msgtype || "m.notice",
body: content.plain,
@@ -405,7 +408,7 @@ export class GenericHookConnection extends BaseConnection implements IConnection
public getProvisionerDetails(showSecrets = false): GenericHookResponseItem {
return {
- ...GenericHookConnection.getProvisionerDetails(this.as.botUserId),
+ ...GenericHookConnection.getProvisionerDetails(this.intent.userId),
id: this.connectionId,
config: {
transformationFunction: this.state.transformationFunction,
@@ -422,20 +425,20 @@ export class GenericHookConnection extends BaseConnection implements IConnection
log.info(`Removing ${this.toString()} for ${this.roomId}`);
// Do a sanity check that the event exists.
try {
- await this.as.botClient.getRoomStateEvent(this.roomId, GenericHookConnection.CanonicalEventType, this.stateKey);
- await this.as.botClient.sendStateEvent(this.roomId, GenericHookConnection.CanonicalEventType, this.stateKey, { disabled: true });
+ await this.intent.underlyingClient.getRoomStateEvent(this.roomId, GenericHookConnection.CanonicalEventType, this.stateKey);
+ await this.intent.underlyingClient.sendStateEvent(this.roomId, GenericHookConnection.CanonicalEventType, this.stateKey, { disabled: true });
} catch (ex) {
- await this.as.botClient.getRoomStateEvent(this.roomId, GenericHookConnection.LegacyCanonicalEventType, this.stateKey);
- await this.as.botClient.sendStateEvent(this.roomId, GenericHookConnection.LegacyCanonicalEventType, this.stateKey, { disabled: true });
+ await this.intent.underlyingClient.getRoomStateEvent(this.roomId, GenericHookConnection.LegacyCanonicalEventType, this.stateKey);
+ await this.intent.underlyingClient.sendStateEvent(this.roomId, GenericHookConnection.LegacyCanonicalEventType, this.stateKey, { disabled: true });
}
- await GenericHookConnection.ensureRoomAccountData(this.roomId, this.as, this.hookId, this.stateKey, true);
+ await GenericHookConnection.ensureRoomAccountData(this.roomId, this.intent, this.hookId, this.stateKey, true);
}
public async provisionerUpdateConfig(userId: string, config: Record) {
// Apply previous state to the current config, as provisioners might not return "unknown" keys.
config = { ...this.state, ...config };
const validatedConfig = GenericHookConnection.validateState(config, this.config.allowJsTransformationFunctions || false);
- await this.as.botClient.sendStateEvent(this.roomId, GenericHookConnection.CanonicalEventType, this.stateKey,
+ await this.intent.underlyingClient.sendStateEvent(this.roomId, GenericHookConnection.CanonicalEventType, this.stateKey,
{
...validatedConfig,
hookId: this.hookId
diff --git a/src/Connections/GithubDiscussion.ts b/src/Connections/GithubDiscussion.ts
index 14bbc28d..44407245 100644
--- a/src/Connections/GithubDiscussion.ts
+++ b/src/Connections/GithubDiscussion.ts
@@ -1,9 +1,9 @@
import { Connection, IConnection, InstantiateConnectionOpts } from "./IConnection";
-import { Appservice, StateEvent } from "matrix-bot-sdk";
+import { Appservice, Intent, StateEvent } from "matrix-bot-sdk";
import { UserTokenStore } from "../UserTokenStore";
import { CommentProcessor } from "../CommentProcessor";
import { MessageSenderClient } from "../MatrixSender";
-import { getIntentForUser } from "../IntentUtils";
+import { ensureUserIsInRoom, getIntentForUser } from "../IntentUtils";
import { MatrixEvent, MatrixMessageContent } from "../MatrixEvent";
import { Discussion } from "@octokit/webhooks-types";
import emoji from "node-emoji";
@@ -12,7 +12,9 @@ import { DiscussionCommentCreatedEvent } from "@octokit/webhooks-types";
import { GithubGraphQLClient } from "../Github/GithubInstance";
import { Logger } from "matrix-appservice-bridge";
import { BaseConnection } from "./BaseConnection";
-import { BridgeConfigGitHub } from "../Config/Config";
+import { BridgeConfig, BridgeConfigGitHub } from "../Config/Config";
+import { ConfigGrantChecker, GrantChecker } from "../grants/GrantCheck";
+import QuickLRU from "@alloc/quick-lru";
export interface GitHubDiscussionConnectionState {
owner: string;
repo: string;
@@ -42,27 +44,26 @@ export class GitHubDiscussionConnection extends BaseConnection implements IConne
static readonly ServiceCategory = "github";
public static createConnectionForState(roomId: string, event: StateEvent, {
- github, config, as, tokenStore, commentProcessor, messageClient}: InstantiateConnectionOpts) {
+ github, config, as, intent, tokenStore, commentProcessor, messageClient}: InstantiateConnectionOpts) {
if (!github || !config.github) {
throw Error('GitHub is not configured');
}
return new GitHubDiscussionConnection(
- roomId, as, event.content, event.stateKey, tokenStore, commentProcessor,
- messageClient, config.github,
+ roomId, as, intent, event.content, event.stateKey, tokenStore, commentProcessor,
+ messageClient, config,
);
}
- readonly sentEvents = new Set(); //TODO: Set some reasonable limits
public static async createDiscussionRoom(
- as: Appservice, userId: string|null, owner: string, repo: string, discussion: Discussion,
+ as: Appservice, intent: Intent, userId: string|null, owner: string, repo: string, discussion: Discussion,
tokenStore: UserTokenStore, commentProcessor: CommentProcessor, messageClient: MessageSenderClient,
- config: BridgeConfigGitHub,
+ config: BridgeConfig,
) {
const commentIntent = await getIntentForUser({
login: discussion.user.login,
avatarUrl: discussion.user.avatar_url,
- }, as, config.userIdPrefix);
+ }, as, config.github?.userIdPrefix);
const state: GitHubDiscussionConnectionState = {
owner,
repo,
@@ -71,7 +72,7 @@ export class GitHubDiscussionConnection extends BaseConnection implements IConne
discussion: discussion.number,
category: discussion.category.id,
};
- const invite = [as.botUserId];
+ const invite = [intent.userId];
if (userId) {
invite.push(userId);
}
@@ -93,20 +94,38 @@ export class GitHubDiscussionConnection extends BaseConnection implements IConne
formatted_body: md.render(discussion.body),
format: 'org.matrix.custom.html',
});
- await as.botIntent.ensureJoined(roomId);
- return new GitHubDiscussionConnection(roomId, as, state, '', tokenStore, commentProcessor, messageClient, config);
+ await intent.ensureJoined(roomId);
+
+ return new GitHubDiscussionConnection(roomId, as, intent, state, '', tokenStore, commentProcessor, messageClient, config);
}
- constructor(roomId: string,
+ private static grantKey(state: GitHubDiscussionConnectionState) {
+ return `${this.CanonicalEventType}/${state.owner}/${state.repo}`;
+ }
+
+ private readonly sentEvents = new QuickLRU({ maxSize: 128 });
+ private readonly grantChecker: GrantChecker;
+
+ private readonly config: BridgeConfigGitHub;
+
+ constructor(
+ roomId: string,
private readonly as: Appservice,
+ private readonly intent: Intent,
private readonly state: GitHubDiscussionConnectionState,
stateKey: string,
private readonly tokenStore: UserTokenStore,
private readonly commentProcessor: CommentProcessor,
private readonly messageClient: MessageSenderClient,
- private readonly config: BridgeConfigGitHub) {
- super(roomId, stateKey, GitHubDiscussionConnection.CanonicalEventType);
+ bridgeConfig: BridgeConfig,
+ ) {
+ super(roomId, stateKey, GitHubDiscussionConnection.CanonicalEventType);
+ if (!bridgeConfig.github) {
+ throw Error('Expected github to be enabled in config');
}
+ this.config = bridgeConfig.github;
+ this.grantChecker = new ConfigGrantChecker("github", this.as, bridgeConfig);
+ }
public isInterestedInStateEvent(eventType: string, stateKey: string) {
return GitHubDiscussionConnection.EventTypes.includes(eventType) && this.stateKey === stateKey;
@@ -116,13 +135,13 @@ export class GitHubDiscussionConnection extends BaseConnection implements IConne
const octokit = await this.tokenStore.getOctokitForUser(ev.sender);
if (octokit === null) {
// TODO: Use Reply - Also mention user.
- await this.as.botClient.sendNotice(this.roomId, `${ev.sender}: Cannot send comment, you are not logged into GitHub`);
+ await this.intent.underlyingClient.sendNotice(this.roomId, `${ev.sender}: Cannot send comment, you are not logged into GitHub`);
return true;
}
const qlClient = new GithubGraphQLClient(octokit);
const commentId = await qlClient.addDiscussionComment(this.state.internalId, ev.content.body);
log.info(`Sent ${commentId} for ${ev.event_id} (${ev.sender})`);
- this.sentEvents.add(commentId);
+ this.sentEvents.set(commentId, undefined);
return true;
}
@@ -147,6 +166,7 @@ export class GitHubDiscussionConnection extends BaseConnection implements IConne
return;
}
const intent = await getIntentForUser(data.comment.user, this.as, this.config.userIdPrefix);
+ await ensureUserIsInRoom(intent, this.intent.underlyingClient, this.roomId);
await this.messageClient.sendMatrixMessage(this.roomId, {
body: data.comment.body,
formatted_body: md.render(data.comment.body),
@@ -158,13 +178,18 @@ export class GitHubDiscussionConnection extends BaseConnection implements IConne
public async onRemove() {
log.info(`Removing ${this.toString()} for ${this.roomId}`);
+ await this.grantChecker.ungrantConnection(this.roomId, GitHubDiscussionConnection.grantKey(this.state));
// Do a sanity check that the event exists.
try {
- await this.as.botClient.getRoomStateEvent(this.roomId, GitHubDiscussionConnection.CanonicalEventType, this.stateKey);
- await this.as.botClient.sendStateEvent(this.roomId, GitHubDiscussionConnection.CanonicalEventType, this.stateKey, { disabled: true });
+ await this.intent.underlyingClient.getRoomStateEvent(this.roomId, GitHubDiscussionConnection.CanonicalEventType, this.stateKey);
+ await this.intent.underlyingClient.sendStateEvent(this.roomId, GitHubDiscussionConnection.CanonicalEventType, this.stateKey, { disabled: true });
} catch (ex) {
- await this.as.botClient.getRoomStateEvent(this.roomId, GitHubDiscussionConnection.LegacyCanonicalEventType, this.stateKey);
- await this.as.botClient.sendStateEvent(this.roomId, GitHubDiscussionConnection.LegacyCanonicalEventType, this.stateKey, { disabled: true });
+ await this.intent.underlyingClient.getRoomStateEvent(this.roomId, GitHubDiscussionConnection.LegacyCanonicalEventType, this.stateKey);
+ await this.intent.underlyingClient.sendStateEvent(this.roomId, GitHubDiscussionConnection.LegacyCanonicalEventType, this.stateKey, { disabled: true });
}
}
-}
\ No newline at end of file
+
+ public async ensureGrant(sender?: string) {
+ await this.grantChecker.assertConnectionGranted(this.roomId, GitHubDiscussionConnection.grantKey(this.state), sender);
+ }
+}
diff --git a/src/Connections/GithubDiscussionSpace.ts b/src/Connections/GithubDiscussionSpace.ts
index 30617ff8..4cc432d1 100644
--- a/src/Connections/GithubDiscussionSpace.ts
+++ b/src/Connections/GithubDiscussionSpace.ts
@@ -6,6 +6,8 @@ import axios from "axios";
import { GitHubDiscussionConnection } from "./GithubDiscussion";
import { GithubInstance } from "../Github/GithubInstance";
import { BaseConnection } from "./BaseConnection";
+import { ConfigGrantChecker, GrantChecker } from "../grants/GrantCheck";
+import { BridgeConfig } from "../Config/Config";
const log = new Logger("GitHubDiscussionSpace");
@@ -31,16 +33,17 @@ export class GitHubDiscussionSpace extends BaseConnection implements IConnection
static readonly ServiceCategory = "github";
public static async createConnectionForState(roomId: string, event: StateEvent, {
- github, config, as}: InstantiateConnectionOpts) {
+ github, config, as, intent}: InstantiateConnectionOpts) {
if (!github || !config.github) {
throw Error('GitHub is not configured');
}
+ await new GrantChecker(as.botIntent, 'github').grantConnection(roomId, this.grantKey(event.content));
return new GitHubDiscussionSpace(
- await as.botClient.getSpace(roomId), event.content, event.stateKey
+ as, config, await intent.underlyingClient.getSpace(roomId), event.content, event.stateKey
);
}
- static async onQueryRoom(result: RegExpExecArray, opts: {githubInstance: GithubInstance, as: Appservice}): Promise> {
+ public static async onQueryRoom(result: RegExpExecArray, opts: {githubInstance: GithubInstance, as: Appservice}): Promise> {
if (!result || result.length < 2) {
log.error(`Invalid alias pattern '${result}'`);
throw Error("Could not find issue");
@@ -108,7 +111,7 @@ export class GitHubDiscussionSpace extends BaseConnection implements IConnection
preset: 'public_chat',
room_alias_name: `github_disc_${owner.toLowerCase()}_${repo.toLowerCase()}`,
initial_state: [
-
+
{
type: this.CanonicalEventType,
content: state,
@@ -141,10 +144,19 @@ export class GitHubDiscussionSpace extends BaseConnection implements IConnection
};
}
- constructor(public readonly space: Space,
+ private static grantKey(state: GitHubDiscussionSpaceConnectionState) {
+ return `${this.CanonicalEventType}/${state.owner}/${state.repo}`;
+ }
+
+ private readonly grantChecker: GrantChecker;
+
+ constructor(as: Appservice,
+ config: BridgeConfig,
+ public readonly space: Space,
private state: GitHubDiscussionSpaceConnectionState,
stateKey: string) {
super(space.roomId, stateKey, GitHubDiscussionSpace.CanonicalEventType)
+ this.grantChecker = new ConfigGrantChecker("github", as, config);
}
public isInterestedInStateEvent(eventType: string, stateKey: string) {
@@ -167,12 +179,18 @@ export class GitHubDiscussionSpace extends BaseConnection implements IConnection
log.info(`Adding connection to ${this.toString()}`);
await this.space.addChildRoom(discussion.roomId);
}
+
+
+ public async ensureGrant(sender?: string) {
+ await this.grantChecker.assertConnectionGranted(this.roomId, GitHubDiscussionSpace.grantKey(this.state), sender);
+ }
public async onRemove() {
log.info(`Removing ${this.toString()} for ${this.roomId}`);
+ this.grantChecker.ungrantConnection(this.roomId, GitHubDiscussionSpace.grantKey(this.state));
// Do a sanity check that the event exists.
try {
-
+
await this.space.client.getRoomStateEvent(this.roomId, GitHubDiscussionSpace.CanonicalEventType, this.stateKey);
await this.space.client.sendStateEvent(this.roomId, GitHubDiscussionSpace.CanonicalEventType, this.stateKey, { disabled: true });
} catch (ex) {
@@ -180,4 +198,4 @@ export class GitHubDiscussionSpace extends BaseConnection implements IConnection
await this.space.client.sendStateEvent(this.roomId, GitHubDiscussionSpace.LegacyCanonicalEventType, this.stateKey, { disabled: true });
}
}
-}
\ No newline at end of file
+}
diff --git a/src/Connections/GithubIssue.ts b/src/Connections/GithubIssue.ts
index 46cafe27..e4ba7f4e 100644
--- a/src/Connections/GithubIssue.ts
+++ b/src/Connections/GithubIssue.ts
@@ -1,12 +1,12 @@
import { Connection, IConnection, InstantiateConnectionOpts } from "./IConnection";
-import { Appservice, StateEvent } from "matrix-bot-sdk";
+import { Appservice, Intent, StateEvent } from "matrix-bot-sdk";
import { MatrixMessageContent, MatrixEvent } from "../MatrixEvent";
import markdown from "markdown-it";
import { UserTokenStore } from "../UserTokenStore";
import { Logger } from "matrix-appservice-bridge";
import { CommentProcessor } from "../CommentProcessor";
import { MessageSenderClient } from "../MatrixSender";
-import { getIntentForUser } from "../IntentUtils";
+import { ensureUserIsInRoom, getIntentForUser } from "../IntentUtils";
import { FormatUtil } from "../FormatUtil";
import axios from "axios";
import { GithubInstance } from "../Github/GithubInstance";
@@ -56,12 +56,12 @@ export class GitHubIssueConnection extends BaseConnection implements IConnection
}
public static async createConnectionForState(roomId: string, event: StateEvent, {
- github, config, as, tokenStore, commentProcessor, messageClient}: InstantiateConnectionOpts) {
+ github, config, as, intent, tokenStore, commentProcessor, messageClient}: InstantiateConnectionOpts) {
if (!github || !config.github) {
throw Error('GitHub is not configured');
}
const issue = new GitHubIssueConnection(
- roomId, as, event.content, event.stateKey || "", tokenStore,
+ roomId, as, intent, event.content, event.stateKey || "", tokenStore,
commentProcessor, messageClient, github, config.github,
);
await issue.syncIssueState();
@@ -154,17 +154,20 @@ export class GitHubIssueConnection extends BaseConnection implements IConnection
};
}
- constructor(roomId: string,
+ constructor(
+ roomId: string,
private readonly as: Appservice,
+ private readonly intent: Intent,
private state: GitHubIssueConnectionState,
stateKey: string,
private tokenStore: UserTokenStore,
private commentProcessor: CommentProcessor,
private messageClient: MessageSenderClient,
private github: GithubInstance,
- private config: BridgeConfigGitHub,) {
- super(roomId, stateKey, GitHubIssueConnection.CanonicalEventType);
- }
+ private config: BridgeConfigGitHub,
+ ) {
+ super(roomId, stateKey, GitHubIssueConnection.CanonicalEventType);
+ }
public isInterestedInStateEvent(eventType: string, stateKey: string) {
return GitHubIssueConnection.EventTypes.includes(eventType) && this.stateKey === stateKey;
@@ -214,13 +217,14 @@ export class GitHubIssueConnection extends BaseConnection implements IConnection
const matrixEvent = await this.commentProcessor.getEventBodyForGitHubComment(comment, event.repository, event.issue);
// Comment body may be blank
if (matrixEvent) {
+ await ensureUserIsInRoom(commentIntent, this.intent.underlyingClient, this.roomId);
await this.messageClient.sendMatrixMessage(this.roomId, matrixEvent, "m.room.message", commentIntent.userId);
}
if (!updateState) {
return;
}
this.state.comments_processed++;
- await this.as.botIntent.underlyingClient.sendStateEvent(
+ await this.intent.underlyingClient.sendStateEvent(
this.roomId,
GitHubIssueConnection.CanonicalEventType,
this.stateKey,
@@ -245,6 +249,7 @@ export class GitHubIssueConnection extends BaseConnection implements IConnection
}, this.as, this.config.userIdPrefix);
// We've not sent any messages into the room yet, let's do it!
if (issue.data.body) {
+ await ensureUserIsInRoom(creator, this.intent.underlyingClient, this.roomId);
await this.messageClient.sendMatrixMessage(this.roomId, {
msgtype: "m.text",
external_url: issue.data.html_url,
@@ -282,6 +287,11 @@ export class GitHubIssueConnection extends BaseConnection implements IConnection
if (issue.data.state === "closed") {
// TODO: Fix
const closedUserId = this.as.getUserIdForSuffix(issue.data.closed_by?.login as string);
+ await ensureUserIsInRoom(
+ this.as.getIntentForUserId(closedUserId),
+ this.intent.underlyingClient,
+ this.roomId
+ );
await this.messageClient.sendMatrixMessage(this.roomId, {
msgtype: "m.notice",
body: `closed the ${issue.data.pull_request ? "pull request" : "issue"} at ${issue.data.closed_at}`,
@@ -289,14 +299,14 @@ export class GitHubIssueConnection extends BaseConnection implements IConnection
}, "m.room.message", closedUserId);
}
- await this.as.botIntent.underlyingClient.sendStateEvent(this.roomId, "m.room.topic", "", {
+ await this.intent.underlyingClient.sendStateEvent(this.roomId, "m.room.topic", "", {
topic: FormatUtil.formatRoomTopic(issue.data),
});
this.state.state = issue.data.state;
}
- await this.as.botIntent.underlyingClient.sendStateEvent(
+ await this.intent.underlyingClient.sendStateEvent(
this.roomId,
GitHubIssueConnection.CanonicalEventType,
this.stateKey,
@@ -308,7 +318,7 @@ export class GitHubIssueConnection extends BaseConnection implements IConnection
public async onMatrixIssueComment(event: MatrixEvent, allowEcho = false) {
const clientKit = await this.tokenStore.getOctokitForUser(event.sender);
if (clientKit === null) {
- await this.as.botClient.sendEvent(this.roomId, "m.reaction", {
+ await this.intent.underlyingClient.sendEvent(this.roomId, "m.reaction", {
"m.relates_to": {
rel_type: "m.annotation",
event_id: event.event_id,
@@ -339,7 +349,7 @@ export class GitHubIssueConnection extends BaseConnection implements IConnection
// TODO: Fix types
if (event.issue && event.changes.title) {
- await this.as.botIntent.underlyingClient.sendStateEvent(this.roomId, "m.room.name", "", {
+ await this.intent.underlyingClient.sendStateEvent(this.roomId, "m.room.name", "", {
name: FormatUtil.formatIssueRoomName(event.issue, event.repository),
});
}
@@ -349,11 +359,11 @@ export class GitHubIssueConnection extends BaseConnection implements IConnection
log.info(`Removing ${this.toString()} for ${this.roomId}`);
// Do a sanity check that the event exists.
try {
- await this.as.botClient.getRoomStateEvent(this.roomId, GitHubIssueConnection.CanonicalEventType, this.stateKey);
- await this.as.botClient.sendStateEvent(this.roomId, GitHubIssueConnection.CanonicalEventType, this.stateKey, { disabled: true });
+ await this.intent.underlyingClient.getRoomStateEvent(this.roomId, GitHubIssueConnection.CanonicalEventType, this.stateKey);
+ await this.intent.underlyingClient.sendStateEvent(this.roomId, GitHubIssueConnection.CanonicalEventType, this.stateKey, { disabled: true });
} catch (ex) {
- await this.as.botClient.getRoomStateEvent(this.roomId, GitHubIssueConnection.LegacyCanonicalEventType, this.stateKey);
- await this.as.botClient.sendStateEvent(this.roomId, GitHubIssueConnection.LegacyCanonicalEventType, this.stateKey, { disabled: true });
+ await this.intent.underlyingClient.getRoomStateEvent(this.roomId, GitHubIssueConnection.LegacyCanonicalEventType, this.stateKey);
+ await this.intent.underlyingClient.sendStateEvent(this.roomId, GitHubIssueConnection.LegacyCanonicalEventType, this.stateKey, { disabled: true });
}
}
@@ -374,4 +384,4 @@ export class GitHubIssueConnection extends BaseConnection implements IConnection
public toString() {
return `GitHubIssue ${this.state.org}/${this.state.repo}#${this.state.issues.join(",")}`;
}
-}
\ No newline at end of file
+}
diff --git a/src/Connections/GithubProject.ts b/src/Connections/GithubProject.ts
index 92bee4af..690cc70f 100644
--- a/src/Connections/GithubProject.ts
+++ b/src/Connections/GithubProject.ts
@@ -1,8 +1,10 @@
import { Connection, IConnection, InstantiateConnectionOpts } from "./IConnection";
-import { Appservice, StateEvent } from "matrix-bot-sdk";
+import { Appservice, Intent, StateEvent } from "matrix-bot-sdk";
import { Logger } from "matrix-appservice-bridge";
import { ProjectsGetResponseData } from "../Github/Types";
import { BaseConnection } from "./BaseConnection";
+import { ConfigGrantChecker, GrantChecker } from "../grants/GrantCheck";
+import { BridgeConfig } from "../Config/Config";
export interface GitHubProjectConnectionState {
// eslint-disable-next-line camelcase
@@ -24,14 +26,18 @@ export class GitHubProjectConnection extends BaseConnection implements IConnecti
GitHubProjectConnection.LegacyCanonicalEventType,
];
- public static createConnectionForState(roomId: string, event: StateEvent, {config, as}: InstantiateConnectionOpts) {
+ public static createConnectionForState(roomId: string, event: StateEvent, {config, as, intent}: InstantiateConnectionOpts) {
if (!config.github) {
throw Error('GitHub is not configured');
}
- return new GitHubProjectConnection(roomId, as, event.content, event.stateKey);
+ return new GitHubProjectConnection(roomId, as, intent, config, event.content, event.stateKey);
}
- static async onOpenProject(project: ProjectsGetResponseData, as: Appservice, inviteUser: string): Promise {
+ public static getGrantKey(projectId: number) {
+ return `${this.CanonicalEventType}/${projectId}`;
+ }
+
+ static async onOpenProject(project: ProjectsGetResponseData, as: Appservice, intent: Intent, config: BridgeConfig, inviteUser: string): Promise {
log.info(`Fetching ${project.name} ${project.id}`);
// URL hack so we don't need to fetch the repo itself.
@@ -41,7 +47,7 @@ export class GitHubProjectConnection extends BaseConnection implements IConnecti
state: project.state as "open"|"closed",
};
- const roomId = await as.botClient.createRoom({
+ const roomId = await intent.underlyingClient.createRoom({
visibility: "private",
name: `${project.name}`,
topic: project.body || undefined,
@@ -55,20 +61,32 @@ export class GitHubProjectConnection extends BaseConnection implements IConnecti
},
],
});
-
- return new GitHubProjectConnection(roomId, as, state, project.url)
+ await new GrantChecker(as.botIntent, 'github').grantConnection(roomId, this.getGrantKey(project.id));
+
+ return new GitHubProjectConnection(roomId, as, intent, config, state, project.url)
}
get projectId() {
return this.state.project_id;
}
- constructor(public readonly roomId: string,
+ private readonly grantChecker: GrantChecker;
+
+ constructor(
+ public readonly roomId: string,
as: Appservice,
+ intent: Intent,
+ config: BridgeConfig,
private state: GitHubProjectConnectionState,
- stateKey: string) {
- super(roomId, stateKey, GitHubProjectConnection.CanonicalEventType);
- }
+ stateKey: string,
+ ) {
+ super(roomId, stateKey, GitHubProjectConnection.CanonicalEventType);
+ this.grantChecker = new ConfigGrantChecker("github", as, config);
+ }
+
+ public ensureGrant(sender?: string) {
+ return this.grantChecker.assertConnectionGranted(this.roomId, GitHubProjectConnection.getGrantKey(this.state.project_id), sender);
+ }
public isInterestedInStateEvent(eventType: string, stateKey: string) {
return GitHubProjectConnection.EventTypes.includes(eventType) && this.stateKey === stateKey;
@@ -77,4 +95,4 @@ export class GitHubProjectConnection extends BaseConnection implements IConnecti
public toString() {
return `GitHubProjectConnection ${this.state.project_id}}`;
}
-}
\ No newline at end of file
+}
diff --git a/src/Connections/GithubRepo.ts b/src/Connections/GithubRepo.ts
index d161a4a4..f36707e4 100644
--- a/src/Connections/GithubRepo.ts
+++ b/src/Connections/GithubRepo.ts
@@ -1,8 +1,9 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
-import { Appservice, IRichReplyMetadata, StateEvent } from "matrix-bot-sdk";
+import { Appservice, Intent, IRichReplyMetadata, StateEvent } from "matrix-bot-sdk";
import { BotCommands, botCommand, compileBotCommands, HelpFunction } from "../BotCommands";
import { CommentProcessor } from "../CommentProcessor";
import { FormatUtil } from "../FormatUtil";
+import { Octokit } from "@octokit/rest";
import { Connection, IConnection, IConnectionState, InstantiateConnectionOpts, ProvisionConnectionOpts } from "./IConnection";
import { GetConnectionsResponseItem } from "../provisioning/api";
import { IssuesOpenedEvent, IssuesReopenedEvent, IssuesEditedEvent, PullRequestOpenedEvent, IssuesClosedEvent, PullRequestClosedEvent,
@@ -27,6 +28,8 @@ import { PermissionCheckFn } from ".";
import { MinimalGitHubIssue, MinimalGitHubRepo } from "../libRs";
import Ajv, { JSONSchemaType } from "ajv";
import { HookFilter } from "../HookFilter";
+import { GrantChecker } from "../grants/GrantCheck";
+import { GitHubGrantChecker } from "../Github/GrantChecker";
const log = new Logger("GitHubRepoConnection");
const md = new markdown();
@@ -40,8 +43,12 @@ interface IQueryRoomOpts {
}
export interface GitHubRepoConnectionOptions extends IConnectionState {
- enableHooks?: AllowedEventsNames[],
+ /**
+ * Do not use. Use `enableHooks`.
+ * @deprecated
+ */
ignoreHooks?: AllowedEventsNames[],
+ enableHooks?: AllowedEventsNames[],
showIssueRoomLink?: boolean;
prDiff?: {
enabled: boolean;
@@ -61,11 +68,17 @@ export interface GitHubRepoConnectionOptions extends IConnectionState {
excludingWorkflows?: string[];
}
}
+
export interface GitHubRepoConnectionState extends GitHubRepoConnectionOptions {
org: string;
repo: string;
}
+interface ConnectionValidatedState extends GitHubRepoConnectionState {
+ ignoreHooks: undefined,
+ enableHooks: AllowedEventsNames[],
+}
+
export interface GitHubRepoConnectionOrgTarget {
name: string;
@@ -73,6 +86,8 @@ export interface GitHubRepoConnectionOrgTarget {
export interface GitHubRepoConnectionRepoTarget {
state: GitHubRepoConnectionState;
name: string;
+ description?: string;
+ avatar?: string;
}
export type GitHubRepoConnectionTarget = GitHubRepoConnectionOrgTarget|GitHubRepoConnectionRepoTarget;
@@ -81,12 +96,12 @@ export type GitHubRepoConnectionTarget = GitHubRepoConnectionOrgTarget|GitHubRep
export type GitHubRepoResponseItem = GetConnectionsResponseItem;
-type AllowedEventsNames =
+export type AllowedEventsNames =
"issue.changed" |
"issue.created" |
"issue.edited" |
"issue.labeled" |
- "issue" |
+ "issue" |
"pull_request.closed" |
"pull_request.merged" |
"pull_request.opened" |
@@ -97,7 +112,7 @@ type AllowedEventsNames =
"release.drafted" |
"release" |
"workflow" |
- "workflow.run" |
+ "workflow.run" |
"workflow.run.success" |
"workflow.run.failure" |
"workflow.run.neutral" |
@@ -106,7 +121,7 @@ type AllowedEventsNames =
"workflow.run.action_required" |
"workflow.run.stale";
-const AllowedEvents: AllowedEventsNames[] = [
+export const AllowedEvents: AllowedEventsNames[] = [
"issue.changed" ,
"issue.created" ,
"issue.edited" ,
@@ -136,8 +151,17 @@ const AllowedEvents: AllowedEventsNames[] = [
* These hooks are enabled by default, unless they are
* specifed in the ignoreHooks option.
*/
-const AllowHookByDefault: AllowedEventsNames[] = [
+const DefaultHooks: AllowedEventsNames[] = [
+ "issue.changed",
+ "issue.created",
+ "issue.edited",
+ "issue.labeled",
"issue",
+ "pull_request.closed",
+ "pull_request.merged",
+ "pull_request.opened",
+ "pull_request.ready_for_review",
+ "pull_request.reviewed",
"pull_request",
"release.created"
];
@@ -151,6 +175,10 @@ const ConnectionStateSchema = {
},
org: {type: "string"},
repo: {type: "string"},
+ /**
+ * Legacy state.
+ * @deprecated
+ */
ignoreHooks: {
type: "array",
items: {
@@ -171,7 +199,7 @@ const ConnectionStateSchema = {
nullable: true,
maxLength: 24,
},
- showIssueRoomLink: {
+ showIssueRoomLink: {
type: "boolean",
nullable: true,
},
@@ -249,7 +277,7 @@ const ConnectionStateSchema = {
additionalProperties: true
} as JSONSchemaType;
-type ReactionOptions =
+type ReactionOptions =
| "+1"
| "-1"
| "laugh"
@@ -295,43 +323,45 @@ const WORKFLOW_CONCLUSION_TO_NOTICE: Record implements IConnection {
-
- static validateState(state: unknown, isExistingState = false): GitHubRepoConnectionState {
+export class GitHubRepoConnection extends CommandConnection implements IConnection {
+ static validateState(state: unknown, isExistingState = false): ConnectionValidatedState {
const validator = new Ajv({ allowUnionTypes: true }).compile(ConnectionStateSchema);
if (validator(state)) {
- // Validate ignoreHooks IF this is an incoming update (we can be less strict for existing state)
- if (!isExistingState && state.ignoreHooks && !state.ignoreHooks.every(h => AllowedEvents.includes(h))) {
- throw new ApiError('`ignoreHooks` must only contain allowed values', ErrCode.BadValue);
- }
if (!isExistingState && state.enableHooks && !state.enableHooks.every(h => AllowedEvents.includes(h))) {
throw new ApiError('`enableHooks` must only contain allowed values', ErrCode.BadValue);
}
- return state;
+ if (state.ignoreHooks) {
+ if (!isExistingState) {
+ throw new ApiError('`ignoreHooks` cannot be used with new connections', ErrCode.BadValue);
+ }
+ log.warn(`Room has old state key 'ignoreHooks'. Converting to compatible enabledHooks filter`);
+ state.enableHooks = HookFilter.convertIgnoredHooksToEnabledHooks(state.enableHooks, state.ignoreHooks, DefaultHooks);
+ }
+ return {
+ ...state,
+ ignoreHooks: undefined,
+ enableHooks: state.enableHooks ?? [...DefaultHooks]
+ };
}
throw new ValidatorApiError(validator.errors);
}
- static async provisionConnection(roomId: string, userId: string, data: Record, {as, tokenStore, github, config}: ProvisionConnectionOpts) {
- if (!github || !config.github) {
- throw Error('GitHub is not configured');
- }
- const validData = this.validateState(data);
+ static async assertUserHasAccessToRepo(userId: string, org: string, repo: string, github: GithubInstance, tokenStore: UserTokenStore) {
const octokit = await tokenStore.getOctokitForUser(userId);
if (!octokit) {
throw new ApiError("User is not authenticated with GitHub", ErrCode.ForbiddenUser);
@@ -339,8 +369,8 @@ export class GitHubRepoConnection extends CommandConnection, {as, intent, tokenStore, github, config}: ProvisionConnectionOpts) {
+ if (!github || !config.github) {
+ throw Error('GitHub is not configured');
+ }
+ const validData = this.validateState(data);
+ await this.assertUserHasAccessToRepo(userId, validData.org, validData.repo, github, tokenStore);
const appOctokit = await github.getSafeOctokitForRepo(validData.org, validData.repo);
if (!appOctokit) {
throw new ApiError(
@@ -361,10 +399,11 @@ export class GitHubRepoConnection extends CommandConnection>, {as, tokenStore, github, config}: InstantiateConnectionOpts) {
+ static async createConnectionForState(roomId: string, state: StateEvent>, {as, intent, tokenStore, github, config}: InstantiateConnectionOpts) {
if (!github || !config.github) {
throw Error('GitHub is not configured');
}
- return new GitHubRepoConnection(roomId, as, this.validateState(state.content, true), tokenStore, state.stateKey, github, config.github);
+
+ const connectionState = this.validateState(state.content, true);
+
+ return new GitHubRepoConnection(roomId, as, intent, connectionState, tokenStore, state.stateKey, github, config.github);
}
static async onQueryRoom(result: RegExpExecArray, opts: IQueryRoomOpts): Promise {
@@ -469,29 +511,32 @@ export class GitHubRepoConnection extends CommandConnection, timeout: NodeJS.Timeout}>();
- constructor(roomId: string,
+ private readonly grantChecker = new GitHubGrantChecker(this.as, this.githubInstance, this.tokenStore);
+
+ constructor(
+ roomId: string,
private readonly as: Appservice,
- state: GitHubRepoConnectionState,
+ private readonly intent: Intent,
+ state: ConnectionValidatedState,
private readonly tokenStore: UserTokenStore,
stateKey: string,
private readonly githubInstance: GithubInstance,
private readonly config: BridgeConfigGitHub,
- ) {
- super(
- roomId,
- stateKey,
- GitHubRepoConnection.CanonicalEventType,
- state,
- as.botClient,
- GitHubRepoConnection.botCommands,
- GitHubRepoConnection.helpMessage,
- "!gh",
- "github",
- );
+ ) {
+ super(
+ roomId,
+ stateKey,
+ GitHubRepoConnection.CanonicalEventType,
+ state,
+ intent.underlyingClient,
+ GitHubRepoConnection.botCommands,
+ GitHubRepoConnection.helpMessage,
+ ["github"],
+ "!gh",
+ "github",
+ );
this.hookFilter = new HookFilter(
- AllowHookByDefault,
state.enableHooks,
- state.ignoreHooks,
)
}
@@ -524,14 +569,20 @@ export class GitHubRepoConnection extends CommandConnection) {
await super.onStateUpdate(stateEv);
- this.hookFilter.enabledHooks = this.state.enableHooks ?? [];
- this.hookFilter.ignoredHooks = this.state.ignoreHooks ?? [];
+ this.hookFilter.enabledHooks = this.state.enableHooks;
}
public isInterestedInStateEvent(eventType: string, stateKey: string) {
@@ -581,7 +632,7 @@ export class GitHubRepoConnection extends CommandConnection w.name.toLowerCase().trim() === name.toLowerCase().trim());
if (!workflow) {
const workflowNames = workflows.data.workflows.map(w => w.name).join(', ');
- await this.as.botIntent.sendText(this.roomId, `Could not find a workflow by the name of "${name}". The workflows on this repository are ${workflowNames}.`, "m.notice");
+ await this.intent.sendText(this.roomId, `Could not find a workflow by the name of "${name}". The workflows on this repository are ${workflowNames}.`, "m.notice");
return;
}
try {
@@ -780,7 +831,7 @@ export class GitHubRepoConnection extends CommandConnection ({ name: l.name, description: l.description || undefined, color: l.color || undefined })));
- await this.as.botIntent.sendEvent(this.roomId, {
+ const labels = FormatUtil.formatLabels(event.issue.labels?.map(l => ({ name: l.name, description: l.description || undefined, color: l.color || undefined })));
+ await this.intent.sendEvent(this.roomId, {
msgtype: "m.notice",
body: content + (labels.plain.length > 0 ? ` with labels ${labels.plain}`: ""),
formatted_body: md.renderInline(content) + (labels.html.length > 0 ? ` with labels ${labels.html}`: ""),
@@ -854,7 +905,7 @@ export class GitHubRepoConnection extends CommandConnection {
const {labels} = this.debounceOnIssueLabeled.get(event.issue.id) || { labels: [] };
@@ -902,9 +953,9 @@ export class GitHubRepoConnection extends CommandConnection ({ name: l.name, description: l.description || undefined, color: l.color || undefined })));
+ const {plain, html} = FormatUtil.formatLabels(event.issue.labels?.map(l => ({ name: l.name, description: l.description || undefined, color: l.color || undefined })));
const content = `**${event.sender.login}** labeled issue [${orgRepoName}#${event.issue.number}](${event.issue.html_url}): "${emoji.emojify(event.issue.title)}"`;
- this.as.botIntent.sendEvent(this.roomId, {
+ this.intent.sendEvent(this.roomId, {
msgtype: "m.notice",
body: content + (plain.length > 0 ? ` with labels ${plain}`: ""),
formatted_body: md.renderInline(content) + (html.length > 0 ? ` with labels ${html}`: ""),
@@ -961,8 +1012,8 @@ export class GitHubRepoConnection extends CommandConnection ({ name: l.name, description: l.description || undefined, color: l.color || undefined })));
- await this.as.botIntent.sendEvent(this.roomId, {
+ const labels = FormatUtil.formatLabels(event.pull_request.labels?.map(l => ({ name: l.name, description: l.description || undefined, color: l.color || undefined })));
+ await this.intent.sendEvent(this.roomId, {
msgtype: "m.notice",
body: content + (labels.plain.length > 0 ? ` with labels ${labels}`: "") + diffContent,
formatted_body: md.renderInline(content) + (labels.html.length > 0 ? ` with labels ${labels.html}`: "") + diffContentHtml,
@@ -985,7 +1036,7 @@ export class GitHubRepoConnection extends CommandConnection r.full_name);
+ }
+
+ // Now, find all the repos that we have the ability to install.
+ const foundRepos = [];
+ let installationsCount = 0;
+ let totalCount = 0;
+ let page = 1;
+ do {
+ const { data } = await octokit.apps.listInstallationReposForAuthenticatedUser({
+ installation_id: installationId,
+ page,
+ per_page: 100,
+ });
+ // No results, so stop trying.
+ if (data.repositories.length === 0) {
+ break;
+ }
+ page++;
+ installationsCount += data.repositories.length;
+ totalCount = data.total_count;
+ // Find any repos that were in our search results. If a search term isn't defined, just return it.
+ foundRepos.push(...data.repositories.filter((installRepo) => searchRepos?.includes(installRepo.full_name) ?? true));
+ } while (
+ installationsCount < totalCount &&
+ foundRepos.length < (searchRepos?.length ?? MAX_RETURNED_TARGETS)
+ )
+ return foundRepos;
+ }
public static async getConnectionTargets(userId: string, tokenStore: UserTokenStore, githubInstance: GithubInstance, filters: GitHubTargetFilter = {}): Promise {
// Search for all repos under the user's control.
@@ -1262,37 +1355,25 @@ export class GitHubRepoConnection extends CommandConnection ({
state: {
org: filters.orgName,
repo: r.name,
},
name: r.name,
+ description: r.description,
+ avatar: r.owner?.avatar_url || r.organization?.avatar_url,
})) as GitHubRepoConnectionRepoTarget[];
} catch (ex) {
log.warn(`Failed to fetch accessible repos for ${filters.orgName} / ${userId}`, ex);
@@ -1304,21 +1385,21 @@ export class GitHubRepoConnection extends CommandConnection, {
- github, config, as}: InstantiateConnectionOpts) {
+ github, config, intent, as}: InstantiateConnectionOpts) {
if (!github || !config.github) {
throw Error('GitHub is not configured');
}
return new GitHubUserSpace(
- await as.botClient.getSpace(roomId), event.content, event.stateKey
+ as, config, await intent.underlyingClient.getSpace(roomId), event.content, event.stateKey
);
}
@@ -104,7 +110,7 @@ export class GitHubUserSpace extends BaseConnection implements IConnection {
preset: 'public_chat',
room_alias_name: `github_${state.username.toLowerCase()}`,
initial_state: [
-
+
{
type: this.CanonicalEventType,
content: state,
@@ -137,12 +143,21 @@ export class GitHubUserSpace extends BaseConnection implements IConnection {
};
}
- constructor(public readonly space: Space,
+ private readonly grantChecker: GrantChecker;
+
+ constructor(as: Appservice,
+ config: BridgeConfig,
+ public readonly space: Space,
private state: GitHubUserSpaceConnectionState,
stateKey: string) {
super(space.roomId, stateKey, GitHubUserSpace.CanonicalEventType);
+ this.grantChecker = new ConfigGrantChecker("github", as, config);
}
+ public ensureGrant(sender?: string) {
+ return this.grantChecker.assertConnectionGranted(this.roomId, GitHubUserSpace.grantKey(this.state), sender);
+ }
+
public isInterestedInStateEvent(eventType: string, stateKey: string) {
return GitHubUserSpace.EventTypes.includes(eventType) && this.stateKey === stateKey;
}
@@ -167,4 +182,4 @@ export class GitHubUserSpace extends BaseConnection implements IConnection {
await this.space.addChildRoom(discussion.roomId);
}
}
-}
\ No newline at end of file
+}
diff --git a/src/Connections/GitlabIssue.ts b/src/Connections/GitlabIssue.ts
index f8623493..742a6df8 100644
--- a/src/Connections/GitlabIssue.ts
+++ b/src/Connections/GitlabIssue.ts
@@ -1,15 +1,16 @@
import { Connection, IConnection, InstantiateConnectionOpts } from "./IConnection";
-import { Appservice, StateEvent } from "matrix-bot-sdk";
+import { Appservice, Intent, StateEvent } from "matrix-bot-sdk";
import { MatrixMessageContent, MatrixEvent } from "../MatrixEvent";
import { UserTokenStore } from "../UserTokenStore";
import { Logger } from "matrix-appservice-bridge";
import { CommentProcessor } from "../CommentProcessor";
import { MessageSenderClient } from "../MatrixSender";
-import { BridgeConfigGitLab, GitLabInstance } from "../Config/Config";
+import { BridgeConfig, BridgeConfigGitLab, GitLabInstance } from "../Config/Config";
import { GetIssueResponse } from "../Gitlab/Types";
import { IGitLabWebhookNoteEvent } from "../Gitlab/WebhookTypes";
-import { getIntentForUser } from "../IntentUtils";
+import { ensureUserIsInRoom, getIntentForUser } from "../IntentUtils";
import { BaseConnection } from "./BaseConnection";
+import { ConfigGrantChecker, GrantChecker } from "../grants/GrantCheck";
export interface GitLabIssueConnectionState {
instance: string;
@@ -48,8 +49,11 @@ export class GitLabIssueConnection extends BaseConnection implements IConnection
return `Author: ${authorName} | State: ${state === "closed" ? "closed" : "open"}`
}
- public static async createConnectionForState(roomId: string, event: StateEvent, {
- config, as, tokenStore, commentProcessor, messageClient}: InstantiateConnectionOpts) {
+ public static async createConnectionForState(
+ roomId: string,
+ event: StateEvent,
+ { config, as, intent, tokenStore, commentProcessor, messageClient}: InstantiateConnectionOpts,
+ ) {
if (!config.gitlab) {
throw Error('GitHub is not configured');
}
@@ -58,15 +62,31 @@ export class GitLabIssueConnection extends BaseConnection implements IConnection
throw Error('Instance name not recognised');
}
return new GitLabIssueConnection(
- roomId, as, event.content, event.stateKey || "", tokenStore,
- commentProcessor, messageClient, instance, config.gitlab,
+ roomId,
+ as,
+ intent,
+ event.content,
+ event.stateKey || "",
+ tokenStore,
+ commentProcessor,
+ messageClient,
+ instance,
+ config,
);
}
- public static async createRoomForIssue(instanceName: string, instance: GitLabInstance,
- issue: GetIssueResponse, projects: string[], as: Appservice,
- tokenStore: UserTokenStore, commentProcessor: CommentProcessor,
- messageSender: MessageSenderClient, config: BridgeConfigGitLab) {
+ public static async createRoomForIssue(
+ instanceName: string,
+ instance: GitLabInstance,
+ issue: GetIssueResponse,
+ projects: string[],
+ as: Appservice,
+ intent: Intent,
+ tokenStore: UserTokenStore,
+ commentProcessor: CommentProcessor,
+ messageSender: MessageSenderClient,
+ config: BridgeConfig,
+ ) {
const state: GitLabIssueConnectionState = {
projects,
state: issue.state,
@@ -76,7 +96,7 @@ export class GitLabIssueConnection extends BaseConnection implements IConnection
authorName: issue.author.name,
};
- const roomId = await as.botClient.createRoom({
+ const roomId = await intent.underlyingClient.createRoom({
visibility: "private",
name: `${issue.references.full}`,
topic: GitLabIssueConnection.getTopicString(issue.author.name, issue.state),
@@ -90,8 +110,13 @@ export class GitLabIssueConnection extends BaseConnection implements IConnection
},
],
});
+ await new GrantChecker(as.botIntent, "gitlab").grantConnection(roomId, {
+ instance: state.instance,
+ project: state.projects[0].toString(),
+ issue: state.iid.toString(),
+ });
- return new GitLabIssueConnection(roomId, as, state, issue.web_url, tokenStore, commentProcessor, messageSender, instance, config);
+ return new GitLabIssueConnection(roomId, as, intent, state, issue.web_url, tokenStore, commentProcessor, messageSender, instance, config);
}
public get projectPath() {
@@ -102,18 +127,37 @@ export class GitLabIssueConnection extends BaseConnection implements IConnection
return this.instance.url;
}
- constructor(roomId: string,
+ private readonly grantChecker: GrantChecker<{instance: string, project: string, issue: string}>;
+ private readonly config: BridgeConfigGitLab;
+
+ constructor(
+ roomId: string,
private readonly as: Appservice,
+ private readonly intent: Intent,
private state: GitLabIssueConnectionState,
stateKey: string,
private tokenStore: UserTokenStore,
private commentProcessor: CommentProcessor,
private messageClient: MessageSenderClient,
private instance: GitLabInstance,
- private config: BridgeConfigGitLab) {
- super(roomId, stateKey, GitLabIssueConnection.CanonicalEventType);
+ config: BridgeConfig,
+ ) {
+ super(roomId, stateKey, GitLabIssueConnection.CanonicalEventType);
+ this.grantChecker = new ConfigGrantChecker("gitlab", as, config);
+ if (!config.gitlab) {
+ throw Error('No gitlab config!');
}
-
+ this.config = config.gitlab;
+ }
+
+ public ensureGrant(sender?: string) {
+ return this.grantChecker.assertConnectionGranted(this.roomId, {
+ instance: this.state.instance,
+ project: this.state.projects[0],
+ issue: this.state.iid.toString(),
+ }, sender);
+ }
+
public isInterestedInStateEvent(eventType: string, stateKey: string) {
return GitLabIssueConnection.EventTypes.includes(eventType) && this.stateKey === stateKey;
}
@@ -140,14 +184,19 @@ export class GitLabIssueConnection extends BaseConnection implements IConnection
avatarUrl: event.user.avatar_url,
}, this.as, this.config.userIdPrefix);
const matrixEvent = await this.commentProcessor.getEventBodyForGitLabNote(event);
-
+ // Make sure ghost user is invited to the room
+ await ensureUserIsInRoom(
+ commentIntent,
+ this.intent.underlyingClient,
+ this.roomId
+ );
await this.messageClient.sendMatrixMessage(this.roomId, matrixEvent, "m.room.message", commentIntent.userId);
}
public async onMatrixIssueComment(event: MatrixEvent, allowEcho = false) {
const clientKit = await this.tokenStore.getGitLabForUser(event.sender, this.instanceUrl);
if (clientKit === null) {
- await this.as.botClient.sendEvent(this.roomId, "m.reaction", {
+ await this.intent.underlyingClient.sendEvent(this.roomId, "m.reaction", {
"m.relates_to": {
rel_type: "m.annotation",
event_id: event.event_id,
@@ -178,8 +227,8 @@ export class GitLabIssueConnection extends BaseConnection implements IConnection
public async onIssueReopened() {
// TODO: We don't store the author data.
this.state.state = "reopened";
- await this.as.botClient.sendStateEvent(this.roomId, GitLabIssueConnection.CanonicalEventType, this.stateKey, this.state);
- return this.as.botClient.sendStateEvent(this.roomId, "m.room.topic", "", {
+ await this.intent.underlyingClient.sendStateEvent(this.roomId, GitLabIssueConnection.CanonicalEventType, this.stateKey, this.state);
+ return this.intent.underlyingClient.sendStateEvent(this.roomId, "m.room.topic", "", {
topic: GitLabIssueConnection.getTopicString(this.state.authorName, this.state.state),
});
}
@@ -187,8 +236,8 @@ export class GitLabIssueConnection extends BaseConnection implements IConnection
public async onIssueClosed() {
// TODO: We don't store the author data.
this.state.state = "closed";
- await this.as.botClient.sendStateEvent(this.roomId, GitLabIssueConnection.CanonicalEventType, this.stateKey , this.state);
- return this.as.botClient.sendStateEvent(this.roomId, "m.room.topic", "", {
+ await this.intent.underlyingClient.sendStateEvent(this.roomId, GitLabIssueConnection.CanonicalEventType, this.stateKey , this.state);
+ return this.intent.underlyingClient.sendStateEvent(this.roomId, "m.room.topic", "", {
topic: GitLabIssueConnection.getTopicString(this.state.authorName, this.state.state),
});
}
@@ -206,4 +255,4 @@ export class GitLabIssueConnection extends BaseConnection implements IConnection
public toString() {
return `GitLabIssue ${this.instanceUrl}/${this.projectPath}#${this.issueNumber}`;
}
-}
\ No newline at end of file
+}
diff --git a/src/Connections/GitlabRepo.ts b/src/Connections/GitlabRepo.ts
index 09111cd7..9f5c0078 100644
--- a/src/Connections/GitlabRepo.ts
+++ b/src/Connections/GitlabRepo.ts
@@ -1,7 +1,7 @@
// We need to instantiate some functions which are not directly called, which confuses typescript.
/* eslint-disable @typescript-eslint/ban-ts-comment */
import { UserTokenStore } from "../UserTokenStore";
-import { Appservice, StateEvent } from "matrix-bot-sdk";
+import { Appservice, Intent, StateEvent } from "matrix-bot-sdk";
import { BotCommands, botCommand, compileBotCommands } from "../BotCommands";
import { MatrixEvent, MatrixMessageContent } from "../MatrixEvent";
import markdown from "markdown-it";
@@ -17,10 +17,19 @@ import Ajv, { JSONSchemaType } from "ajv";
import { CommandError } from "../errors";
import QuickLRU from "@alloc/quick-lru";
import { HookFilter } from "../HookFilter";
+import { GitLabClient } from "../Gitlab/Client";
+import { IBridgeStorageProvider } from "../Stores/StorageProvider";
+import axios from "axios";
+import { GitLabGrantChecker } from "../Gitlab/GrantChecker";
export interface GitLabRepoConnectionState extends IConnectionState {
instance: string;
path: string;
+ enableHooks?: AllowedEventsNames[],
+ /**
+ * Do not use. Use `enableHooks`
+ * @deprecated
+ */
ignoreHooks?: AllowedEventsNames[],
includeCommentBody?: boolean;
pushTagsRegex?: string,
@@ -28,6 +37,11 @@ export interface GitLabRepoConnectionState extends IConnectionState {
excludingLabels?: string[];
}
+interface ConnectionStateValidated extends GitLabRepoConnectionState {
+ ignoreHooks: undefined,
+ enableHooks: AllowedEventsNames[],
+}
+
export interface GitLabRepoConnectionInstanceTarget {
name: string;
@@ -35,6 +49,8 @@ export interface GitLabRepoConnectionInstanceTarget {
export interface GitLabRepoConnectionProjectTarget {
state: GitLabRepoConnectionState;
name: string;
+ avatar_url?: string;
+ description?: string;
}
export type GitLabRepoConnectionTarget = GitLabRepoConnectionInstanceTarget|GitLabRepoConnectionProjectTarget;
@@ -49,7 +65,7 @@ const MRRCOMMENT_DEBOUNCE_MS = 5000;
export type GitLabRepoResponseItem = GetConnectionsResponseItem;
-type AllowedEventsNames =
+type AllowedEventsNames =
"merge_request.open" |
"merge_request.close" |
"merge_request.merge" |
@@ -58,7 +74,7 @@ type AllowedEventsNames =
"merge_request.review.comments" |
`merge_request.${string}` |
"merge_request" |
- "tag_push" |
+ "tag_push" |
"push" |
"wiki" |
`wiki.${string}` |
@@ -80,6 +96,8 @@ const AllowedEvents: AllowedEventsNames[] = [
"release.created",
];
+const DefaultHooks = AllowedEvents;
+
const ConnectionStateSchema = {
type: "object",
properties: {
@@ -89,6 +107,10 @@ const ConnectionStateSchema = {
},
instance: { type: "string" },
path: { type: "string" },
+ /**
+ * Do not use. Use `enableHooks`
+ * @deprecated
+ */
ignoreHooks: {
type: "array",
items: {
@@ -96,6 +118,13 @@ const ConnectionStateSchema = {
},
nullable: true,
},
+ enableHooks: {
+ type: "array",
+ items: {
+ type: "string",
+ },
+ nullable: true,
+ },
commandPrefix: {
type: "string",
minLength: 2,
@@ -131,7 +160,6 @@ const ConnectionStateSchema = {
export interface GitLabTargetFilter {
instance?: string;
parent?: string;
- after?: string;
search?: string;
}
@@ -139,7 +167,7 @@ export interface GitLabTargetFilter {
* Handles rooms connected to a GitLab repo.
*/
@Connection
-export class GitLabRepoConnection extends CommandConnection implements IConnection {
+export class GitLabRepoConnection extends CommandConnection implements IConnection {
static readonly CanonicalEventType = "uk.half-shot.matrix-hookshot.gitlab.repository";
static readonly LegacyCanonicalEventType = "uk.half-shot.matrix-github.gitlab.repository";
@@ -147,24 +175,35 @@ export class GitLabRepoConnection extends CommandConnection MatrixMessageContent;
static ServiceCategory = "gitlab";
- static validateState(state: unknown, isExistingState = false): GitLabRepoConnectionState {
+ static validateState(state: unknown, isExistingState = false): ConnectionStateValidated {
const validator = new Ajv({ strict: false }).compile(ConnectionStateSchema);
if (validator(state)) {
- // Validate ignoreHooks IF this is an incoming update (we can be less strict for existing state)
- if (!isExistingState && state.ignoreHooks && !state.ignoreHooks.every(h => AllowedEvents.includes(h))) {
- throw new ApiError('`ignoreHooks` must only contain allowed values', ErrCode.BadValue);
+ // Validate enableHooks IF this is an incoming update (we can be less strict for existing state)
+ if (!isExistingState && state.enableHooks && !state.enableHooks.every(h => AllowedEvents.includes(h))) {
+ throw new ApiError('`enableHooks` must only contain allowed values', ErrCode.BadValue);
}
- return state;
+ if (state.ignoreHooks) {
+ if (!isExistingState) {
+ throw new ApiError('`ignoreHooks` cannot be used with new connections', ErrCode.BadValue);
+ }
+ log.warn(`Room has old state key 'ignoreHooks'. Converting to compatible enabledHooks filter`);
+ state.enableHooks = HookFilter.convertIgnoredHooksToEnabledHooks(state.enableHooks, state.ignoreHooks, AllowedEvents);
+ }
+ return {
+ ...state,
+ enableHooks: state.enableHooks ?? AllowedEvents,
+ ignoreHooks: undefined,
+ };
}
throw new ValidatorApiError(validator.errors);
}
- static async createConnectionForState(roomId: string, event: StateEvent>, {as, tokenStore, config}: InstantiateConnectionOpts) {
+ static async createConnectionForState(roomId: string, event: StateEvent>, {as, intent, tokenStore, config}: InstantiateConnectionOpts) {
if (!config.gitlab) {
throw Error('GitLab is not configured');
}
@@ -173,18 +212,16 @@ export class GitLabRepoConnection extends CommandConnection, { config, as, tokenStore, getAllConnectionsOfType }: ProvisionConnectionOpts) {
- if (!config.gitlab) {
- throw Error('GitLab is not configured');
- }
- const gitlabConfig = config.gitlab;
- const validData = this.validateState(data);
- const instance = gitlabConfig.instances[validData.instance];
+ public static async assertUserHasAccessToProject(
+ instanceName: string, path: string, requester: string,
+ tokenStore: UserTokenStore, config: BridgeConfigGitLab
+ ) {
+ const instance = config.instances[instanceName];
if (!instance) {
- throw Error(`provisionConnection provided an instanceName of ${validData.instance} but the instance does not exist`);
+ throw Error(`provisionConnection provided an instanceName of ${instanceName} but the instance does not exist`);
}
const client = await tokenStore.getGitLabForUser(requester, instance.url);
if (!client) {
@@ -192,7 +229,7 @@ export class GitLabRepoConnection extends CommandConnection, { as, config, intent, tokenStore, getAllConnectionsOfType }: ProvisionConnectionOpts) {
+ if (!config.gitlab) {
+ throw Error('GitLab is not configured');
+ }
+ const validData = this.validateState(data);
+ const gitlabConfig = config.gitlab;
+ const instance = gitlabConfig.instances[validData.instance];
+ if (!instance) {
+ throw Error(`provisionConnection provided an instanceName of ${validData.instance} but the instance does not exist`);
+ }
+ const permissionLevel = await this.assertUserHasAccessToProject(validData.instance, validData.path, requester, tokenStore, gitlabConfig);
+ const client = await tokenStore.getGitLabForUser(requester, instance.url);
+ if (!client) {
+ throw new ApiError("User is not authenticated with GitLab", ErrCode.ForbiddenUser);
+ }
+
+ const project = await client.projects.get(validData.path);
const stateEventKey = `${validData.instance}/${validData.path}`;
- const connection = new GitLabRepoConnection(roomId, stateEventKey, as, validData, tokenStore, instance);
+ const connection = new GitLabRepoConnection(roomId, stateEventKey, as, gitlabConfig, intent, validData, tokenStore, instance);
const existingConnections = getAllConnectionsOfType(GitLabRepoConnection);
const existing = existingConnections.find(c => c.roomId === roomId && c.instance.url === connection.instance.url && c.path === connection.path);
@@ -244,7 +298,8 @@ export class GitLabRepoConnection extends CommandConnection {
+ public static async getBase64Avatar(avatarUrl: string, client: GitLabClient, storage: IBridgeStorageProvider): Promise {
+ try {
+ const existingFile = await storage.getStoredTempFile(avatarUrl);
+ if (existingFile) {
+ return existingFile;
+ }
+ const res = await client.get(avatarUrl);
+ if (res.status !== 200) {
+ return null;
+ }
+ const contentType = res.headers["content-type"];
+ if (!contentType?.startsWith("image/")) {
+ return null;
+ }
+ const data = res.data as Buffer;
+ const url = `data:${contentType};base64,${data.toString('base64')}`;
+ await storage.setStoredTempFile(avatarUrl, url);
+ return url;
+ } catch (ex) {
+ if (axios.isAxiosError(ex)) {
+ if (ex.response?.status === 401) {
+ // 401 means that the project is Private and GitLab haven't fixed
+ // the auth issues, just ignore this one.
+ // https://gitlab.com/gitlab-org/gitlab/-/issues/25498
+ return null;
+ }
+ }
+ log.warn(`Could not transform data from ${avatarUrl} into base64`, ex);
+ return null;
+ }
+ }
+
+ public static async getConnectionTargets(userId: string, config: BridgeConfigGitLab, filters: GitLabTargetFilter = {}, tokenStore: UserTokenStore, storage: IBridgeStorageProvider): Promise {
// Search for all repos under the user's control.
if (!filters.instance) {
@@ -278,15 +365,16 @@ export class GitLabRepoConnection extends CommandConnection ({
+ const allProjects = await client.projects.list(AccessLevel.Developer, filters.parent, undefined, filters.search);
+ return await Promise.all(allProjects.map(async p => ({
state: {
instance: filters.instance,
path: p.path_with_namespace,
},
name: p.name,
- })) as GitLabRepoConnectionProjectTarget[];
+ avatar_url: p.avatar_url && await this.getBase64Avatar(p.avatar_url, client, storage),
+ description: p.description,
+ }))) as GitLabRepoConnectionProjectTarget[];
}
private readonly debounceMRComments = new Map({ maxSize: 100 });
private readonly hookFilter: HookFilter;
- constructor(roomId: string,
+ private readonly grantChecker;
+
+ constructor(
+ roomId: string,
stateKey: string,
- private readonly as: Appservice,
- state: GitLabRepoConnectionState,
+ as: Appservice,
+ config: BridgeConfigGitLab,
+ private readonly intent: Intent,
+ state: ConnectionStateValidated,
private readonly tokenStore: UserTokenStore,
- private readonly instance: GitLabInstance) {
- super(
- roomId,
- stateKey,
- GitLabRepoConnection.CanonicalEventType,
- state,
- as.botClient,
- GitLabRepoConnection.botCommands,
- GitLabRepoConnection.helpMessage,
- "!gl",
- "gitlab",
- )
- if (!state.path || !state.instance) {
- throw Error('Invalid state, missing `path` or `instance`');
- }
- this.hookFilter = new HookFilter(
- // GitLab allows all events by default
- AllowedEvents,
- [],
- state.ignoreHooks,
- );
- }
+ private readonly instance: GitLabInstance,
+ ) {
+ super(
+ roomId,
+ stateKey,
+ GitLabRepoConnection.CanonicalEventType,
+ state,
+ intent.underlyingClient,
+ GitLabRepoConnection.botCommands,
+ GitLabRepoConnection.helpMessage,
+ ["gitlab"],
+ "!gl",
+ "gitlab",
+ )
+ this.grantChecker = new GitLabGrantChecker(as, config, tokenStore);
+ if (!state.path || !state.instance) {
+ throw Error('Invalid state, missing `path` or `instance`');
+ }
+ this.hookFilter = new HookFilter(
+ state.enableHooks ?? DefaultHooks,
+ );
+}
public get path() {
return this.state.path.toLowerCase();
@@ -360,13 +453,18 @@ export class GitLabRepoConnection extends CommandConnection) {
+ const validatedState = GitLabRepoConnection.validateState(stateEv.content);
+ await this.grantChecker.assertConnectionGranted(this.roomId, {
+ instance: validatedState.instance,
+ path: validatedState.path,
+ } , stateEv.sender);
await super.onStateUpdate(stateEv);
- this.hookFilter.ignoredHooks = this.state.ignoreHooks ?? [];
+ this.hookFilter.enabledHooks = this.state.enableHooks;
}
public getProvisionerDetails(): GitLabRepoResponseItem {
return {
- ...GitLabRepoConnection.getProvisionerDetails(this.as.botUserId),
+ ...GitLabRepoConnection.getProvisionerDetails(this.intent.userId),
id: this.connectionId,
config: {
...this.state,
@@ -393,7 +491,7 @@ export class GitLabRepoConnection extends CommandConnection PUSH_MAX_COMMITS;
const displayedCommits = tooManyCommits ? 1 : Math.min(event.total_commits_count, PUSH_MAX_COMMITS);
-
+
// Take the top 5 commits. The array is ordered in reverse.
const commits = event.commits.reverse().slice(0,displayedCommits).map(commit => {
return `[\`${commit.id.slice(0,8)}\`](${event.project.homepage}/-/commit/${commit.id}) ${commit.title}${shouldName ? ` by ${commit.author.name}` : ""}`;
@@ -571,14 +669,14 @@ export class GitLabRepoConnection extends CommandConnection " + result.commentNotes.join("\n\n> ");
}
- this.as.botIntent.sendEvent(this.roomId, {
+ this.intent.sendEvent(this.roomId, {
msgtype: "m.notice",
body: content,
formatted_body: md.renderInline(content),
@@ -714,7 +812,7 @@ ${data.description}`;
}
this.debounceMergeRequestReview(
event.user,
- event.object_attributes,
+ event.object_attributes,
event.project,
{
commentCount: 0,
@@ -778,20 +876,21 @@ ${data.description}`;
// Apply previous state to the current config, as provisioners might not return "unknown" keys.
config = { ...this.state, ...config };
const validatedConfig = GitLabRepoConnection.validateState(config);
- await this.as.botClient.sendStateEvent(this.roomId, GitLabRepoConnection.CanonicalEventType, this.stateKey, validatedConfig);
+ await this.intent.underlyingClient.sendStateEvent(this.roomId, GitLabRepoConnection.CanonicalEventType, this.stateKey, validatedConfig);
this.state = validatedConfig;
- this.hookFilter.ignoredHooks = this.state.ignoreHooks ?? [];
+ this.hookFilter.enabledHooks = this.state.enableHooks;
}
public async onRemove() {
log.info(`Removing ${this.toString()} for ${this.roomId}`);
+ await this.grantChecker.ungrantConnection(this.roomId, { instance: this.state.instance, path: this.path });
// Do a sanity check that the event exists.
try {
- await this.as.botClient.getRoomStateEvent(this.roomId, GitLabRepoConnection.CanonicalEventType, this.stateKey);
- await this.as.botClient.sendStateEvent(this.roomId, GitLabRepoConnection.CanonicalEventType, this.stateKey, { disabled: true });
+ await this.intent.underlyingClient.getRoomStateEvent(this.roomId, GitLabRepoConnection.CanonicalEventType, this.stateKey);
+ await this.intent.underlyingClient.sendStateEvent(this.roomId, GitLabRepoConnection.CanonicalEventType, this.stateKey, { disabled: true });
} catch (ex) {
- await this.as.botClient.getRoomStateEvent(this.roomId, GitLabRepoConnection.LegacyCanonicalEventType, this.stateKey);
- await this.as.botClient.sendStateEvent(this.roomId, GitLabRepoConnection.LegacyCanonicalEventType, this.stateKey, { disabled: true });
+ await this.intent.underlyingClient.getRoomStateEvent(this.roomId, GitLabRepoConnection.LegacyCanonicalEventType, this.stateKey);
+ await this.intent.underlyingClient.sendStateEvent(this.roomId, GitLabRepoConnection.LegacyCanonicalEventType, this.stateKey, { disabled: true });
}
// TODO: Clean up webhooks
}
diff --git a/src/Connections/IConnection.ts b/src/Connections/IConnection.ts
index 2905a285..b1f81157 100644
--- a/src/Connections/IConnection.ts
+++ b/src/Connections/IConnection.ts
@@ -1,7 +1,7 @@
import { MatrixEvent, MatrixMessageContent } from "../MatrixEvent";
import { IssuesOpenedEvent, IssuesEditedEvent } from "@octokit/webhooks-types";
import { ConnectionWarning, GetConnectionsResponseItem } from "../provisioning/api";
-import { Appservice, IRichReplyMetadata, StateEvent } from "matrix-bot-sdk";
+import { Appservice, Intent, IRichReplyMetadata, StateEvent } from "matrix-bot-sdk";
import { BridgeConfig, BridgePermissionLevel } from "../Config/Config";
import { UserTokenStore } from "../UserTokenStore";
import { CommentProcessor } from "../CommentProcessor";
@@ -25,6 +25,19 @@ export interface IConnection {
priority: number;
+ /**
+ * Ensures that the current state loaded into the connection has been granted by
+ * the remote service. I.e. If the room is bridged into a GitHub repository,
+ * check that the *sender* has permission to bridge it.
+ *
+ * If a grant cannot be found, it may be determined by doing an API lookup against
+ * the remote service.
+ *
+ * @param sender The matrix ID of the sender of the event.
+ * @throws If the grant cannot be found, and cannot be detetermined, this will throw.
+ */
+ ensureGrant?: (sender?: string) => void;
+
/**
* The unique connection ID. This is a opaque hash of the roomId, connection type and state key.
*/
@@ -81,13 +94,14 @@ export interface ConnectionDeclaration {
EventTypes: string[];
ServiceCategory: string;
provisionConnection?: (roomId: string, userId: string, data: Record, opts: ProvisionConnectionOpts) => Promise<{connection: C, warning?: ConnectionWarning}>;
- createConnectionForState: (roomId: string, state: StateEvent>, opts: InstantiateConnectionOpts) => C|Promise
+ createConnectionForState: (roomId: string, state: StateEvent>, opts: InstantiateConnectionOpts) => C|Promise;
}
export const ConnectionDeclarations: Array = [];
export interface InstantiateConnectionOpts {
as: Appservice,
+ intent: Intent,
config: BridgeConfig,
tokenStore: UserTokenStore,
commentProcessor: CommentProcessor,
diff --git a/src/Connections/JiraProject.ts b/src/Connections/JiraProject.ts
index 37fe3a23..9d9a68cf 100644
--- a/src/Connections/JiraProject.ts
+++ b/src/Connections/JiraProject.ts
@@ -1,5 +1,5 @@
import { Connection, IConnection, IConnectionState, InstantiateConnectionOpts, ProvisionConnectionOpts } from "./IConnection";
-import { Appservice, StateEvent } from "matrix-bot-sdk";
+import { Appservice, Intent, StateEvent } from "matrix-bot-sdk";
import { Logger } from "matrix-appservice-bridge";
import { JiraIssueEvent, JiraIssueUpdatedEvent, JiraVersionEvent } from "../Jira/WebhookTypes";
import { FormatUtil } from "../FormatUtil";
@@ -16,6 +16,8 @@ import JiraApi from "jira-client";
import { GetConnectionsResponseItem } from "../provisioning/api";
import { BridgeConfigJira } from "../Config/Config";
import { HookshotJiraApi } from "../Jira/Client";
+import { GrantChecker } from "../grants/GrantCheck";
+import { JiraGrantChecker } from "../Jira/GrantChecker";
type JiraAllowedEventsNames =
"issue_created" |
@@ -54,6 +56,7 @@ export type JiraProjectConnectionTarget = JiraProjectConnectionInstanceTarget|Ji
export interface JiraTargetFilter {
instanceName?: string;
+ search?: string;
}
@@ -93,8 +96,6 @@ const md = new markdownit();
*/
@Connection
export class JiraProjectConnection extends CommandConnection implements IConnection {
-
-
static readonly CanonicalEventType = "uk.half-shot.matrix-hookshot.jira.project";
static readonly LegacyCanonicalEventType = "uk.half-shot.matrix-github.jira.project";
@@ -106,49 +107,55 @@ export class JiraProjectConnection extends CommandConnection MatrixMessageContent;
- static async provisionConnection(roomId: string, userId: string, data: Record, {getAllConnectionsOfType, as, tokenStore, config}: ProvisionConnectionOpts) {
+ static async assertUserHasAccessToProject(tokenStore: UserTokenStore, userId: string, urlStr: string) {
+ const url = new URL(urlStr);
+ const jiraClient = await tokenStore.getJiraForUser(userId, url.toString());
+ if (!jiraClient) {
+ throw new ApiError("User is not authenticated with JIRA", ErrCode.ForbiddenUser);
+ }
+ const jiraResourceClient = await jiraClient.getClientForUrl(url);
+ if (!jiraResourceClient) {
+ throw new ApiError("User is not authenticated with this JIRA instance", ErrCode.ForbiddenUser);
+ }
+ const projectKey = JiraProjectConnection.getProjectKeyForUrl(url);
+ if (!projectKey) {
+ throw new ApiError("URL did not contain a valid project key", ErrCode.BadValue);
+ }
+ try {
+ // Need to check that the user can access this.
+ const project = await jiraResourceClient.getProject(projectKey);
+ return project;
+ } catch (ex) {
+ throw new ApiError("Requested project was not found", ErrCode.ForbiddenUser);
+ }
+ }
+
+ static async provisionConnection(roomId: string, userId: string, data: Record, {as, intent, tokenStore, config}: ProvisionConnectionOpts) {
if (!config.jira) {
throw new ApiError('JIRA integration is not configured', ErrCode.DisabledFeature);
}
const validData = validateJiraConnectionState(data);
log.info(`Attempting to provisionConnection for ${roomId} ${validData.url} on behalf of ${userId}`);
- const jiraClient = await tokenStore.getJiraForUser(userId, validData.url);
- if (!jiraClient) {
- throw new ApiError("User is not authenticated with JIRA", ErrCode.ForbiddenUser);
+ const project = await this.assertUserHasAccessToProject(tokenStore, userId, validData.url);
+ const connection = new JiraProjectConnection(roomId, as, intent, validData, validData.url, tokenStore);
+ // Fetch the project's id now, to support events that identify projects by id instead of url
+ if (connection.state.id !== undefined && connection.state.id !== project.id) {
+ log.warn(`Updating ID of project ${connection.projectKey} from ${connection.state.id} to ${project.id}`);
+ connection.state.id = project.id;
}
- const jiraResourceClient = await jiraClient.getClientForUrl(new URL(validData.url));
- if (!jiraResourceClient) {
- throw new ApiError("User is not authenticated with this JIRA instance", ErrCode.ForbiddenUser);
- }
- const connection = new JiraProjectConnection(roomId, as, validData, validData.url, tokenStore);
- log.debug(`projectKey for ${validData.url} is ${connection.projectKey}`);
- if (!connection.projectKey) {
- throw Error('Expected projectKey to be defined');
- }
- try {
- // Need to check that the user can access this.
- const project = await jiraResourceClient.getProject(connection.projectKey);
- // Fetch the project's id now, to support events that identify projects by id instead of url
- if (connection.state.id !== undefined && connection.state.id !== project.id) {
- log.warn(`Updating ID of project ${connection.projectKey} from ${connection.state.id} to ${project.id}`);
- connection.state.id = project.id;
- }
- } catch (ex) {
- throw new ApiError("Requested project was not found", ErrCode.ForbiddenUser);
- }
- await as.botIntent.underlyingClient.sendStateEvent(roomId, JiraProjectConnection.CanonicalEventType, connection.stateKey, validData);
+ await intent.underlyingClient.sendStateEvent(roomId, JiraProjectConnection.CanonicalEventType, connection.stateKey, validData);
log.info(`Created connection via provisionConnection ${connection.toString()}`);
return {connection};
}
-
- static createConnectionForState(roomId: string, state: StateEvent>, {config, as, tokenStore}: InstantiateConnectionOpts) {
+
+ static createConnectionForState(roomId: string, state: StateEvent>, {config, as, intent, tokenStore}: InstantiateConnectionOpts) {
if (!config.jira) {
throw Error('JIRA is not configured');
}
const connectionConfig = validateJiraConnectionState(state.content);
- return new JiraProjectConnection(roomId, as, connectionConfig, state.stateKey, tokenStore);
+ return new JiraProjectConnection(roomId, as, intent, connectionConfig, state.stateKey, tokenStore);
}
-
+
public get projectId() {
return this.state.id;
}
@@ -194,36 +201,42 @@ export class JiraProjectConnection extends CommandConnection;
+
+ constructor(
+ roomId: string,
private readonly as: Appservice,
+ private readonly intent: Intent,
state: JiraProjectConnectionState,
stateKey: string,
- private readonly tokenStore: UserTokenStore,) {
- super(
- roomId,
- stateKey,
- JiraProjectConnection.CanonicalEventType,
- state,
- as.botClient,
- JiraProjectConnection.botCommands,
- JiraProjectConnection.helpMessage,
- "!jira",
- "jira"
- );
- if (state.url) {
- this.projectUrl = new URL(state.url);
- } else if (state.id) {
- log.warn(`Legacy ID option in use, needs to be switched to 'url'`);
- } else {
- throw Error('State is missing both id and url, cannot create connection');
- }
-
+ private readonly tokenStore: UserTokenStore
+ ) {
+ super(
+ roomId,
+ stateKey,
+ JiraProjectConnection.CanonicalEventType,
+ state,
+ intent.underlyingClient,
+ JiraProjectConnection.botCommands,
+ JiraProjectConnection.helpMessage,
+ ["jira"],
+ "!jira",
+ "jira"
+ );
+ if (state.url) {
+ this.projectUrl = new URL(state.url);
+ } else if (state.id) {
+ log.warn(`Legacy ID option in use, needs to be switched to 'url'`);
+ } else {
+ throw Error('State is missing both id and url, cannot create connection');
}
+ this.grantChecker = new JiraGrantChecker(as, tokenStore);
+ }
public isInterestedInStateEvent(eventType: string, stateKey: string) {
return JiraProjectConnection.EventTypes.includes(eventType) && this.stateKey === stateKey;
@@ -233,6 +246,12 @@ export class JiraProjectConnection extends CommandConnection t.name).join(', ')}`;
- return this.as.botIntent.sendEvent(this.roomId,{
+ return this.intent.sendEvent(this.roomId,{
msgtype: "m.notice",
body: content,
formatted_body: md.render(content),
@@ -496,13 +514,16 @@ export class JiraProjectConnection extends CommandConnection Promise,
- private readonly pushConnections: (...connections: IConnection[]) => void) {
- super(
- roomId,
- "",
- "",
- // TODO Consider storing room-specific config in state.
- {},
- provisionOpts.as.botClient,
- SetupConnection.botCommands,
- SetupConnection.helpMessage,
- "!hookshot",
- )
- this.enabledHelpCategories = [
- this.config.github ? "github" : "",
- this.config.gitlab ? "gitlab": "",
- this.config.figma ? "figma": "",
- this.config.jira ? "jira": "",
- this.config.generic?.enabled ? "webhook": "",
- this.config.feeds?.enabled ? "feed" : "",
- this.config.widgets?.roomSetupWidget ? "widget" : "",
- ];
- this.includeTitlesInHelp = false;
+ private readonly getOrCreateAdminRoom: (intent: Intent, userId: string) => Promise,
+ private readonly pushConnections: (...connections: IConnection[]) => void,
+ ) {
+ super(
+ roomId,
+ "",
+ "",
+ // TODO Consider storing room-specific config in state.
+ {},
+ provisionOpts.intent.underlyingClient,
+ SetupConnection.botCommands,
+ SetupConnection.helpMessage,
+ helpCategories,
+ prefix,
+ );
+ 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"})
@@ -84,7 +89,7 @@ export class SetupConnection extends CommandConnection {
const [, org, repo] = urlParts;
const {connection} = await GitHubRepoConnection.provisionConnection(this.roomId, userId, {org, repo}, this.provisionOpts);
this.pushConnections(connection);
- await this.as.botClient.sendNotice(this.roomId, `Room configured to bridge ${connection.org}/${connection.repo}`);
+ 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"})
@@ -110,7 +115,7 @@ export class SetupConnection extends CommandConnection {
}
const {connection, warning} = await GitLabRepoConnection.provisionConnection(this.roomId, userId, {path, instance: name}, this.provisionOpts);
this.pushConnections(connection);
- await this.as.botClient.sendNotice(this.roomId, `Room configured to bridge ${connection.prettyPath}` + (warning ? `\n${warning.header}: ${warning.message}` : ""));
+ await this.client.sendNotice(this.roomId, `Room configured to bridge ${connection.prettyPath}` + (warning ? `\n${warning.header}: ${warning.message}` : ""));
}
private async checkJiraLogin(userId: string, urlStr: string) {
@@ -142,12 +147,12 @@ export class SetupConnection extends CommandConnection {
const res = await JiraProjectConnection.provisionConnection(this.roomId, userId, { url: safeUrl }, this.provisionOpts);
this.pushConnections(res.connection);
- await this.as.botClient.sendNotice(this.roomId, `Room configured to bridge Jira project ${res.connection.projectKey}.`);
+ 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"})
public async onJiraListProject() {
- const projects: JiraProjectConnectionState[] = await this.as.botClient.getRoomState(this.roomId).catch((err: any) => {
+ const projects: JiraProjectConnectionState[] = await this.client.getRoomState(this.roomId).catch((err: any) => {
if (err.body.errcode === 'M_NOT_FOUND') {
return []; // not an error to us
}
@@ -162,9 +167,9 @@ export class SetupConnection extends CommandConnection {
);
if (projects.length === 0) {
- return this.as.botClient.sendHtmlNotice(this.roomId, md.renderInline('Not connected to any JIRA projects'));
+ return this.client.sendHtmlNotice(this.roomId, md.renderInline('Not connected to any JIRA projects'));
} else {
- return this.as.botClient.sendHtmlNotice(this.roomId, md.render(
+ return this.client.sendHtmlNotice(this.roomId, md.render(
'Currently connected to these JIRA projects:\n\n' +
projects.map(project => ` - ${project.url}`).join('\n')
));
@@ -185,7 +190,7 @@ export class SetupConnection extends CommandConnection {
let eventType = "";
for (eventType of eventTypes) {
try {
- event = await this.as.botClient.getRoomStateEvent(this.roomId, eventType, safeUrl);
+ event = await this.client.getRoomStateEvent(this.roomId, eventType, safeUrl);
break;
} catch (err: any) {
if (err.body.errcode !== 'M_NOT_FOUND') {
@@ -197,11 +202,11 @@ export class SetupConnection extends CommandConnection {
throw new CommandError("Invalid Jira project URL", `Feed "${urlStr}" is not currently bridged to this room`);
}
- await this.as.botClient.sendStateEvent(this.roomId, eventType, safeUrl, {});
- return this.as.botClient.sendHtmlNotice(this.roomId, md.renderInline(`Room no longer bridged to Jira project \`${safeUrl}\`.`));
+ await this.client.sendStateEvent(this.roomId, eventType, safeUrl, {});
+ 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: "webhook"})
+ @botCommand("webhook", { help: "Create an inbound webhook.", requiredArgs: ["name"], includeUserId: true, category: "generic"})
public async onWebhook(userId: string, name: string) {
if (!this.config.generic?.enabled) {
throw new CommandError("not-configured", "The bridge is not configured to support webhooks.");
@@ -215,9 +220,15 @@ export class SetupConnection extends CommandConnection {
const c = await GenericHookConnection.provisionConnection(this.roomId, userId, {name}, this.provisionOpts);
this.pushConnections(c.connection);
const url = new URL(c.connection.hookId, this.config.generic.parsedUrlPrefix);
- const adminRoom = await this.getOrCreateAdminRoom(userId);
- await adminRoom.sendNotice(`You have bridged a webhook. Please configure your webhook source to use ${url}.`);
- return this.as.botClient.sendNotice(this.roomId, `Room configured to bridge webhooks. See admin room for secret url.`);
+ const adminRoom = await this.getOrCreateAdminRoom(this.intent, userId);
+ const safeRoomId = encodeURIComponent(this.roomId);
+ await adminRoom.sendNotice(
+ `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 configure your webhook source to use\n${url}`
+ );
+ return this.client.sendNotice(this.roomId, `Room configured to bridge webhooks. See admin room for secret url.`);
}
@botCommand("figma file", { help: "Bridge a Figma file to the room.", requiredArgs: ["url"], includeUserId: true, category: "figma"})
@@ -235,10 +246,10 @@ export class SetupConnection extends CommandConnection {
const [, fileId] = res;
const {connection} = await FigmaFileConnection.provisionConnection(this.roomId, userId, { fileId }, this.provisionOpts);
this.pushConnections(connection);
- return this.as.botClient.sendHtmlNotice(this.roomId, md.renderInline(`Room configured to bridge Figma file.`));
+ 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: "feed"})
+ @botCommand("feed", { help: "Bridge an RSS/Atom feed to the room.", requiredArgs: ["url"], optionalArgs: ["label"], includeUserId: true, category: "feeds"})
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.");
@@ -260,12 +271,12 @@ export class SetupConnection extends CommandConnection {
const {connection} = await FeedConnection.provisionConnection(this.roomId, userId, { url, label }, this.provisionOpts);
this.pushConnections(connection);
- return this.as.botClient.sendHtmlNotice(this.roomId, md.renderInline(`Room configured to bridge \`${url}\``));
+ return this.client.sendHtmlNotice(this.roomId, md.renderInline(`Room configured to bridge \`${url}\``));
}
- @botCommand("feed list", { help: "Show feeds currently subscribed to.", category: "feed"})
+ @botCommand("feed list", { help: "Show feeds currently subscribed to.", category: "feeds"})
public async onFeedList() {
- const feeds: FeedConnectionState[] = await this.as.botClient.getRoomState(this.roomId).catch((err: any) => {
+ const feeds: FeedConnectionState[] = await this.client.getRoomState(this.roomId).catch((err: any) => {
if (err.body.errcode === 'M_NOT_FOUND') {
return []; // not an error to us
}
@@ -277,7 +288,7 @@ export class SetupConnection extends CommandConnection {
);
if (feeds.length === 0) {
- return this.as.botClient.sendHtmlNotice(this.roomId, md.renderInline('Not subscribed to any feeds'));
+ return this.client.sendHtmlNotice(this.roomId, md.renderInline('Not subscribed to any feeds'));
} else {
const feedDescriptions = feeds.map(feed => {
if (feed.label) {
@@ -286,18 +297,18 @@ export class SetupConnection extends CommandConnection {
return feed.url;
});
- return this.as.botClient.sendHtmlNotice(this.roomId, md.render(
+ return this.client.sendHtmlNotice(this.roomId, md.render(
'Currently subscribed to these feeds:\n\n' +
feedDescriptions.map(desc => ` - ${desc}`).join('\n')
));
}
}
- @botCommand("feed remove", { help: "Unsubscribe from an RSS/Atom feed.", requiredArgs: ["url"], includeUserId: true, category: "feed"})
+ @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);
- const event = await this.as.botClient.getRoomStateEvent(this.roomId, FeedConnection.CanonicalEventType, url).catch((err: any) => {
+ const event = await this.client.getRoomStateEvent(this.roomId, FeedConnection.CanonicalEventType, url).catch((err: any) => {
if (err.body.errcode === 'M_NOT_FOUND') {
return null; // not an error to us
}
@@ -307,17 +318,17 @@ export class SetupConnection extends CommandConnection {
throw new CommandError("Invalid feed URL", `Feed "${url}" is not currently bridged to this room`);
}
- await this.as.botClient.sendStateEvent(this.roomId, FeedConnection.CanonicalEventType, url, {});
- return this.as.botClient.sendHtmlNotice(this.roomId, md.renderInline(`Unsubscribed from \`${url}\``));
+ await this.client.sendStateEvent(this.roomId, FeedConnection.CanonicalEventType, url, {});
+ return this.client.sendHtmlNotice(this.roomId, md.renderInline(`Unsubscribed from \`${url}\``));
}
@botCommand("setup-widget", {category: "widget", help: "Open the setup widget in the room"})
public async onSetupWidget() {
- if (!this.config.widgets?.roomSetupWidget) {
+ if (this.config.widgets?.roomSetupWidget === undefined) {
throw new CommandError("Not configured", "The bridge is not configured to support setup widgets");
}
- if (!await SetupWidget.SetupRoomConfigWidget(this.roomId, this.as.botIntent, this.config.widgets)) {
- await this.as.botClient.sendNotice(this.roomId, `This room already has a setup widget, please open the "Hookshot Configuration" widget.`);
+ if (!await SetupWidget.SetupRoomConfigWidget(this.roomId, this.intent, this.config.widgets, this.serviceTypes)) {
+ await this.client.sendNotice(this.roomId, `This room already has a setup widget, please open the "Hookshot Configuration" widget.`);
}
}
@@ -325,10 +336,10 @@ export class SetupConnection extends CommandConnection {
if (!this.config.checkPermission(userId, service, BridgePermissionLevel.manageConnections)) {
throw new CommandError(`You are not permitted to provision connections for ${service}.`);
}
- if (!await this.as.botClient.userHasPowerLevelFor(userId, this.roomId, "", true)) {
+ if (!await this.client.userHasPowerLevelFor(userId, this.roomId, "", true)) {
throw new CommandError("not-configured", "You must be able to set state in a room ('Change settings') in order to set up new integrations.");
}
- if (!await this.as.botClient.userHasPowerLevelFor(this.as.botUserId, this.roomId, stateEventType, true)) {
+ if (!await this.client.userHasPowerLevelFor(this.intent.userId, this.roomId, stateEventType, true)) {
throw new CommandError("Bot lacks power level to set room state", "I do not have permission to set up a bridge in this room. Please promote me to an Admin/Moderator.");
}
}
diff --git a/src/Github/GithubInstance.ts b/src/Github/GithubInstance.ts
index f0c981d7..c4c31c48 100644
--- a/src/Github/GithubInstance.ts
+++ b/src/Github/GithubInstance.ts
@@ -10,6 +10,7 @@ import UserAgent from "../UserAgent";
const log = new Logger("GithubInstance");
export const GITHUB_CLOUD_URL = new URL("https://api.github.com");
+export const GITHUB_CLOUD_PUBLIC_URL = new URL("https://github.com");
export class GitHubOAuthError extends Error {
constructor(errorResponse: GitHubOAuthErrorResponse) {
@@ -182,7 +183,7 @@ export class GithubInstance {
public get newInstallationUrl() {
if (this.baseUrl.hostname === GITHUB_CLOUD_URL.hostname) {
// Cloud
- return new URL(`/apps/${this.appSlug}/installations/new`, this.baseUrl);
+ return new URL(`/apps/${this.appSlug}/installations/new`, GITHUB_CLOUD_PUBLIC_URL);
}
// Enterprise (yes, i know right)
return new URL(`/github-apps/${this.appSlug}/installations/new`, this.baseUrl);
@@ -192,7 +193,7 @@ export class GithubInstance {
const q = new URLSearchParams(params as Record);
if (baseUrl.hostname === GITHUB_CLOUD_URL.hostname) {
// Cloud doesn't use `api.` for oauth.
- baseUrl = new URL("https://github.com");
+ baseUrl = GITHUB_CLOUD_PUBLIC_URL;
}
const rawUrl = baseUrl.toString();
return rawUrl + `${rawUrl.endsWith('/') ? '' : '/'}` + `login/oauth/${action}?${q}`;
diff --git a/src/Github/GrantChecker.ts b/src/Github/GrantChecker.ts
new file mode 100644
index 00000000..e3aa9ace
--- /dev/null
+++ b/src/Github/GrantChecker.ts
@@ -0,0 +1,39 @@
+import { Appservice } from "matrix-bot-sdk";
+import { GitHubRepoConnection } from "../Connections";
+import { GrantChecker } from "../grants/GrantCheck";
+import { UserTokenStore } from "../UserTokenStore";
+import { GithubInstance } from "./GithubInstance";
+import { Logger } from 'matrix-appservice-bridge';
+
+const log = new Logger('GitHubGrantChecker');
+
+interface GitHubGrantConnectionId {
+ org: string;
+ repo: string;
+}
+
+
+export class GitHubGrantChecker extends GrantChecker {
+ constructor(private readonly as: Appservice, private readonly github: GithubInstance, private readonly tokenStore: UserTokenStore) {
+ super(as.botIntent, "github")
+ }
+
+ protected async checkFallback(roomId: string, connectionId: GitHubGrantConnectionId, sender?: string) {
+ if (!sender) {
+ log.debug(`Tried to check fallback for ${roomId} with a missing sender`);
+ // Cannot validate without a sender.
+ return false;
+ }
+ if (this.as.isNamespacedUser(sender)) {
+ // Bridge is always valid.
+ return true;
+ }
+ try {
+ await GitHubRepoConnection.assertUserHasAccessToRepo(sender, connectionId.org, connectionId.repo, this.github, this.tokenStore);
+ return true;
+ } catch (ex) {
+ log.info(`Tried to check fallback for ${roomId}: ${sender} does not have access to ${connectionId.org}/${connectionId.repo}`, ex);
+ return false;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Gitlab/Client.ts b/src/Gitlab/Client.ts
index b1673170..2c2cb85b 100644
--- a/src/Gitlab/Client.ts
+++ b/src/Gitlab/Client.ts
@@ -35,6 +35,10 @@ export class GitLabClient {
};
}
+ async get(path: string) {
+ return await axios.get(path, { ...this.defaultConfig, responseType: 'arraybuffer'});
+ }
+
async version() {
return (await axios.get("api/v4/versions", this.defaultConfig)).data;
}
@@ -80,7 +84,7 @@ export class GitLabClient {
min_access_level: minAccess,
simple: true,
pagination: "keyset",
- per_page: 50,
+ per_page: 10,
order_by: "id",
sort: "asc",
id_after: idAfter,
diff --git a/src/Gitlab/GrantChecker.ts b/src/Gitlab/GrantChecker.ts
new file mode 100644
index 00000000..1ce94731
--- /dev/null
+++ b/src/Gitlab/GrantChecker.ts
@@ -0,0 +1,40 @@
+import { Logger } from "matrix-appservice-bridge";
+import { Appservice } from "matrix-bot-sdk";
+import { BridgeConfigGitLab } from "../Config/Config";
+import { GitLabRepoConnection } from "../Connections";
+import { GrantChecker } from "../grants/GrantCheck";
+import { UserTokenStore } from "../UserTokenStore";
+
+const log = new Logger('GitLabGrantChecker');
+
+interface GitLabGrantConnectionId{
+ instance: string;
+ path: string;
+}
+
+
+
+export class GitLabGrantChecker extends GrantChecker {
+ constructor(private readonly as: Appservice, private readonly config: BridgeConfigGitLab, private readonly tokenStore: UserTokenStore) {
+ super(as.botIntent, "gitlab")
+ }
+
+ protected async checkFallback(roomId: string, connectionId: GitLabGrantConnectionId, sender?: string) {
+ if (!sender) {
+ log.debug(`Tried to check fallback for ${roomId} with a missing sender`);
+ // Cannot validate without a sender.
+ return false;
+ }
+ if (this.as.isNamespacedUser(sender)) {
+ // Bridge is always valid.
+ return true;
+ }
+ try {
+ await GitLabRepoConnection.assertUserHasAccessToProject(connectionId.instance, connectionId.path, sender, this.tokenStore, this.config);
+ return true;
+ } catch (ex) {
+ log.info(`${sender} does not have access to ${connectionId.instance}/${connectionId.path}`, ex);
+ return false;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Gitlab/Types.ts b/src/Gitlab/Types.ts
index 224f6047..0ef55965 100644
--- a/src/Gitlab/Types.ts
+++ b/src/Gitlab/Types.ts
@@ -221,6 +221,8 @@ export interface ProjectHook extends ProjectHookOpts {
}
export interface SimpleProject {
+ avatar_url?: string;
+ description?: string;
id: string;
name: string;
path: string;
diff --git a/src/HookFilter.ts b/src/HookFilter.ts
index 934e8935..474ddfe4 100644
--- a/src/HookFilter.ts
+++ b/src/HookFilter.ts
@@ -1,19 +1,32 @@
export class HookFilter {
+ static convertIgnoredHooksToEnabledHooks(explicitlyEnabledHooks: T[] = [], ignoredHooks: T[], defaultHooks: T[]): T[] {
+ const resultHookSet = new Set([
+ ...explicitlyEnabledHooks,
+ ...defaultHooks,
+ ]);
+
+ // For each ignored hook, remove anything that matches.
+ for (const ignoredHook of ignoredHooks) {
+ resultHookSet.delete(ignoredHook);
+ // If the hook is a "root" hook name, remove all children.
+ for (const enabledHook of resultHookSet) {
+ if (enabledHook.startsWith(`${ignoredHook}.`)) {
+ resultHookSet.delete(enabledHook);
+ }
+ }
+ }
+
+ return [...resultHookSet];
+ }
+
constructor(
- public readonly defaultHooks: T[],
public enabledHooks: T[] = [],
- public ignoredHooks: T[] = []
) {
}
public shouldSkip(...hookName: T[]) {
- if (hookName.some(name => this.ignoredHooks.includes(name))) {
- return true;
- }
- if (hookName.some(name => this.enabledHooks.includes(name))) {
- return false;
- }
- return !hookName.some(h => this.defaultHooks.includes(h));
+ // Should skip if all of the hook names are missing
+ return hookName.every(name => !this.enabledHooks.includes(name));
}
}
\ No newline at end of file
diff --git a/src/IntentUtils.ts b/src/IntentUtils.ts
index 415ed633..31d2b5ba 100644
--- a/src/IntentUtils.ts
+++ b/src/IntentUtils.ts
@@ -1,12 +1,43 @@
import { Logger } from "matrix-appservice-bridge";
-import { Appservice } from "matrix-bot-sdk";
+import { Appservice, Intent, MatrixClient } from "matrix-bot-sdk";
import axios from "axios";
const log = new Logger("IntentUtils");
-export async function getIntentForUser(user: {avatarUrl?: string, login: string}, as: Appservice, prefix: string) {
+/**
+ * Attempt to ensure that a given user is in a room, inviting them
+ * via the bot user if nessacery.
+ *
+ * If the bot user isn't in the room (and the target isn't in the room already),
+ * this will fail.
+ * @param targetIntent The intent for the user who should be in the room.
+ * @param botClient The bot client for the room.
+ * @param roomId The target room to invite to.
+ * @throws If it was not possible to invite the user.
+ */
+export async function ensureUserIsInRoom(targetIntent: Intent, botClient: MatrixClient, roomId: string) {
+ const senderUserId = targetIntent.userId;
+ try {
+ try {
+ await targetIntent.ensureJoined(roomId);
+ } catch (ex) {
+ if ('errcode' in ex && ex.errcode === "M_FORBIDDEN") {
+ // Make sure ghost user is invited to the room
+ await botClient.inviteUser(senderUserId, roomId);
+ await targetIntent.ensureJoined(roomId);
+ } else {
+ throw ex;
+ }
+ }
+ } catch (ex) {
+ log.warn(`Could not ensure that ${senderUserId} is in ${roomId}`, ex);
+ throw Error(`Could not ensure that ${senderUserId} is in ${roomId}`);
+ }
+}
+
+export async function getIntentForUser(user: {avatarUrl?: string, login: string}, as: Appservice, prefix?: string) {
const domain = as.botUserId.split(":")[1];
- const intent = as.getIntentForUserId(`@${prefix}${user.login}:${domain}`);
+ const intent = as.getIntentForUserId(`@${prefix ?? ''}${user.login}:${domain}`);
const displayName = `${user.login}`;
// Verify up-to-date profile
let profile;
diff --git a/src/Jira/Client.ts b/src/Jira/Client.ts
index 73e8194a..52cd375f 100644
--- a/src/Jira/Client.ts
+++ b/src/Jira/Client.ts
@@ -33,7 +33,7 @@ export abstract class HookshotJiraApi extends JiraApi {
return this.res;
}
- public abstract getAllProjects(): AsyncIterable;
+ public abstract getAllProjects(query?: string, maxResults?: number): AsyncIterable;
protected async apiRequest(path: string, method?: Method, data?: undefined): Promise
protected async apiRequest(path: string, method: Method, data?: R): Promise {
diff --git a/src/Jira/GrantChecker.ts b/src/Jira/GrantChecker.ts
new file mode 100644
index 00000000..211fc821
--- /dev/null
+++ b/src/Jira/GrantChecker.ts
@@ -0,0 +1,33 @@
+import { Appservice } from "matrix-bot-sdk";
+import { JiraProjectConnection } from "../Connections";
+import { GrantChecker } from "../grants/GrantCheck";
+import { UserTokenStore } from "../UserTokenStore";
+
+interface JiraGrantConnectionId{
+ url: string;
+}
+
+
+
+export class JiraGrantChecker extends GrantChecker {
+ constructor(private readonly as: Appservice, private readonly tokenStore: UserTokenStore) {
+ super(as.botIntent, "jira")
+ }
+
+ protected async checkFallback(roomId: string, connectionId: JiraGrantConnectionId, sender?: string) {
+ if (!sender) {
+ // Cannot validate without a sender.
+ return false;
+ }
+ if (this.as.isNamespacedUser(sender)) {
+ // Bridge is always valid.
+ return true;
+ }
+ try {
+ await JiraProjectConnection.assertUserHasAccessToProject(this.tokenStore, sender, connectionId.url);
+ return true;
+ } catch (ex) {
+ return false;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Jira/client/CloudClient.ts b/src/Jira/client/CloudClient.ts
index d83a4602..7d1e37de 100644
--- a/src/Jira/client/CloudClient.ts
+++ b/src/Jira/client/CloudClient.ts
@@ -6,6 +6,7 @@ import { BridgeConfigJira, BridgeConfigJiraCloudOAuth } from '../../Config/Confi
import { Logger } from "matrix-appservice-bridge";
import { HookshotJiraApi, JiraClient } from '../Client';
import JiraApi from 'jira-client';
+import * as qs from "node:querystring";
const log = new Logger("JiraCloudClient");
const ACCESSIBLE_RESOURCE_CACHE_LIMIT = 100;
@@ -44,11 +45,16 @@ export class HookshotCloudJiraApi extends HookshotJiraApi {
return super.addNewIssue(issue);
}
- async * getAllProjects(): AsyncIterable {
+ async * getAllProjects(query?: string, maxResults = 10): AsyncIterable {
let response;
let startAt = 0;
do {
- response = await this.apiRequest(`/rest/api/3/project/search?startAt=${startAt}`);
+ const params = qs.stringify({
+ startAt,
+ maxResults,
+ query
+ });
+ response = await this.apiRequest(`/rest/api/3/project/search?${params}`);
yield* response.values;
startAt += response.maxResults;
} while(!response.isLast)
diff --git a/src/Jira/client/OnPremClient.ts b/src/Jira/client/OnPremClient.ts
index 61f29ca9..22de4c8a 100644
--- a/src/Jira/client/OnPremClient.ts
+++ b/src/Jira/client/OnPremClient.ts
@@ -6,15 +6,27 @@ import { KeyObject } from 'crypto';
import { HookshotJiraApi, JiraClient } from '../Client';
import JiraApi from 'jira-client';
+function createSearchTerm(name?: string) {
+ return name?.toLowerCase()?.replaceAll(/[^a-z0-9]/g, '') || '';
+}
+
export class HookshotOnPremJiraApi extends HookshotJiraApi {
constructor(options: JiraApi.JiraApiOptions, res: JiraAPIAccessibleResource) {
super(options, res);
}
- async * getAllProjects(): AsyncIterable {
+ async * getAllProjects(search?: string): AsyncIterable {
// Note, status is ignored.
const results = await this.genericGet(`project`) as JiraOnPremProjectSearchResponse;
+
+ // Reasonable search algorithm.
+ const searchTerm = search && createSearchTerm(search);
+ if (searchTerm) {
+ yield *results.filter(p => createSearchTerm(p.name).includes(searchTerm) || createSearchTerm(p.key).includes(searchTerm));
+ return;
+ }
+
yield *results;
}
}
diff --git a/src/Managers/BotUsersManager.ts b/src/Managers/BotUsersManager.ts
new file mode 100644
index 00000000..46b26256
--- /dev/null
+++ b/src/Managers/BotUsersManager.ts
@@ -0,0 +1,217 @@
+import { Appservice, Intent } from "matrix-bot-sdk";
+import { Logger } from "matrix-appservice-bridge";
+
+import { BridgeConfig } from "../Config/Config";
+
+const log = new Logger("BotUsersManager");
+
+export class BotUser {
+ constructor(
+ private readonly as: Appservice,
+ readonly userId: string,
+ readonly services: string[],
+ readonly prefix: string,
+ // Bots with higher priority should handle a command first
+ readonly priority: number,
+ readonly avatar?: string,
+ readonly displayname?: string,
+ ) {}
+
+ get intent(): Intent {
+ return this.as.getIntentForUserId(this.userId);
+ }
+}
+
+// Sort bot users by highest priority first.
+const higherPriority: (a: BotUser, b: BotUser) => number = (a, b) => (b.priority - a.priority);
+
+export default class BotUsersManager {
+ // Map of user ID to config for all our configured bot users
+ private _botUsers = new Map();
+
+ // Map of room ID to set of bot users in the room
+ private _botsInRooms = new Map>();
+
+ constructor(
+ readonly config: BridgeConfig,
+ readonly as: Appservice,
+ ) {
+ // Default bot user
+ this._botUsers.set(
+ this.as.botUserId,
+ new BotUser(
+ this.as,
+ this.as.botUserId,
+ // Default bot can handle all services
+ this.config.enabledServices,
+ "!hookshot",
+ 0,
+ this.config.bot?.avatar,
+ this.config.bot?.displayname,
+ )
+ );
+
+ // Service bot users
+ if (this.config.serviceBots) {
+ this.config.serviceBots.forEach(bot => {
+ const botUserId = this.as.getUserId(bot.localpart);
+ this._botUsers.set(
+ botUserId,
+ new BotUser(
+ this.as,
+ botUserId,
+ [bot.service],
+ bot.prefix,
+ // Service bots should handle commands first
+ 1,
+ bot.avatar,
+ bot.displayname,
+ )
+ );
+ });
+ }
+ }
+
+ async start(): Promise {
+ await this.ensureProfiles();
+ await this.getJoinedRooms();
+ }
+
+ private async ensureProfiles(): Promise {
+ log.info("Ensuring bot users are set up...");
+ for (const botUser of this.botUsers) {
+ // Ensure the bot is registered
+ log.debug(`Ensuring bot user ${botUser.userId} is registered`);
+ await botUser.intent.ensureRegistered();
+
+ // Set up the bot profile
+ let profile;
+ try {
+ profile = await botUser.intent.underlyingClient.getUserProfile(botUser.userId);
+ } catch {
+ profile = {}
+ }
+ if (botUser.avatar && profile.avatar_url !== botUser.avatar) {
+ log.info(`Setting avatar for "${botUser.userId}" to ${botUser.avatar}`);
+ await botUser.intent.underlyingClient.setAvatarUrl(botUser.avatar);
+ }
+ if (botUser.displayname && profile.displayname !== botUser.displayname) {
+ log.info(`Setting displayname for "${botUser.userId}" to ${botUser.displayname}`);
+ await botUser.intent.underlyingClient.setDisplayName(botUser.displayname);
+ }
+ }
+ }
+
+ private async getJoinedRooms(): Promise {
+ log.info("Getting joined rooms...");
+ for (const botUser of this.botUsers) {
+ const joinedRooms = await botUser.intent.underlyingClient.getJoinedRooms();
+ for (const roomId of joinedRooms) {
+ this.onRoomJoin(botUser, roomId);
+ }
+ }
+ }
+
+ /**
+ * Records a bot user having joined a room.
+ *
+ * @param botUser
+ * @param roomId
+ */
+ onRoomJoin(botUser: BotUser, roomId: string): void {
+ log.debug(`Bot user ${botUser.userId} joined room ${roomId}`);
+ const botUsers = this._botsInRooms.get(roomId) ?? new Set();
+ botUsers.add(botUser);
+ this._botsInRooms.set(roomId, botUsers);
+ }
+
+ /**
+ * Records a bot user having left a room.
+ *
+ * @param botUser
+ * @param roomId
+ */
+ onRoomLeave(botUser: BotUser, roomId: string): void {
+ log.info(`Bot user ${botUser.userId} left room ${roomId}`);
+ const botUsers = this._botsInRooms.get(roomId) ?? new Set();
+ botUsers.delete(botUser);
+ if (botUsers.size > 0) {
+ this._botsInRooms.set(roomId, botUsers);
+ } else {
+ this._botsInRooms.delete(roomId);
+ }
+ }
+
+ /**
+ * Gets the list of room IDs where at least one bot is a member.
+ *
+ * @returns List of room IDs.
+ */
+ get joinedRooms(): string[] {
+ return Array.from(this._botsInRooms.keys());
+ }
+
+ /**
+ * Gets the configured bot users, ordered by priority.
+ *
+ * @returns List of bot users.
+ */
+ get botUsers(): BotUser[] {
+ return Array.from(this._botUsers.values())
+ .sort(higherPriority)
+ }
+
+ /**
+ * Gets a configured bot user by user ID.
+ *
+ * @param userId User ID to get.
+ */
+ getBotUser(userId: string): BotUser | undefined {
+ return this._botUsers.get(userId);
+ }
+
+ /**
+ * Checks if the given user ID belongs to a configured bot user.
+ *
+ * @param userId User ID to check.
+ * @returns `true` if the user ID belongs to a bot user, otherwise `false`.
+ */
+ isBotUser(userId: string): boolean {
+ return this._botUsers.has(userId);
+ }
+
+ /**
+ * Gets all the bot users in a room, ordered by priority.
+ *
+ * @param roomId Room ID to get bots for.
+ */
+ getBotUsersInRoom(roomId: string): BotUser[] {
+ return Array.from(this._botsInRooms.get(roomId) || new Set())
+ .sort(higherPriority);
+ }
+
+ /**
+ * Gets a bot user in a room, optionally for a particular service.
+ * When a service is specified, the bot user with the highest priority which handles that service is returned.
+ *
+ * @param roomId Room ID to get a bot user for.
+ * @param serviceType Optional service type for the bot.
+ */
+ getBotUserInRoom(roomId: string, serviceType?: string): BotUser | undefined {
+ const botUsersInRoom = this.getBotUsersInRoom(roomId);
+ if (serviceType) {
+ return botUsersInRoom.find(b => b.services.includes(serviceType));
+ } else {
+ return botUsersInRoom[0];
+ }
+ }
+
+ /**
+ * Gets the bot user with the highest priority for a particular service.
+ *
+ * @param serviceType Service type for the bot.
+ */
+ getBotUserForService(serviceType: string): BotUser | undefined {
+ return this.botUsers.find(b => b.services.includes(serviceType));
+ }
+}
diff --git a/src/MatrixSender.ts b/src/MatrixSender.ts
index 89db2a49..391cb4c6 100644
--- a/src/MatrixSender.ts
+++ b/src/MatrixSender.ts
@@ -1,8 +1,8 @@
import { BridgeConfig } from "./Config/Config";
import { MessageQueue, createMessageQueue } from "./MessageQueue";
-import { Appservice, Intent } from "matrix-bot-sdk";
+import { Appservice } from "matrix-bot-sdk";
import { Logger } from "matrix-appservice-bridge";
-import { v4 as uuid } from "uuid";
+import { randomUUID } from 'node:crypto';
export interface IMatrixSendMessage {
sender: string|null;
@@ -32,7 +32,7 @@ export class MatrixSender {
this.mq.subscribe("matrix.message");
this.mq.on("matrix.message", async (msg) => {
try {
- await this.sendMatrixMessage(msg.messageId || uuid(), msg.data);
+ 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})`);
}
diff --git a/src/MessageQueue/LocalMQ.ts b/src/MessageQueue/LocalMQ.ts
index 31fe57f7..dff8bd78 100644
--- a/src/MessageQueue/LocalMQ.ts
+++ b/src/MessageQueue/LocalMQ.ts
@@ -1,7 +1,7 @@
import { EventEmitter } from "events";
import { MessageQueue, MessageQueueMessage, DEFAULT_RES_TIMEOUT } from "./Types";
import micromatch from "micromatch";
-import {v4 as uuid} from "uuid";
+import { randomUUID } from 'node:crypto';
import Metrics from "../Metrics";
export class LocalMQ extends EventEmitter implements MessageQueue {
@@ -25,7 +25,7 @@ export class LocalMQ extends EventEmitter implements MessageQueue {
return;
}
if (!message.messageId) {
- message.messageId = uuid();
+ message.messageId = randomUUID();
}
this.emit(message.eventName, message);
}
diff --git a/src/MessageQueue/RedisQueue.ts b/src/MessageQueue/RedisQueue.ts
index e328de66..38b2db58 100644
--- a/src/MessageQueue/RedisQueue.ts
+++ b/src/MessageQueue/RedisQueue.ts
@@ -1,11 +1,10 @@
import { MessageQueue, MessageQueueMessage, DEFAULT_RES_TIMEOUT, MessageQueueMessageOut } from "./Types";
import { Redis, default as redis } from "ioredis";
-import { BridgeConfig, BridgeConfigQueue } from "../Config/Config";
+import { BridgeConfigQueue } from "../Config/Config";
import { EventEmitter } from "events";
import { Logger } from "matrix-appservice-bridge";
-
-import {v4 as uuid} from "uuid";
+import { randomUUID } from 'node:crypto';
const log = new Logger("RedisMq");
@@ -26,7 +25,7 @@ export class RedisMQ extends EventEmitter implements MessageQueue {
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");
- this.myUuid = uuid();
+ this.myUuid = randomUUID();
this.redisSub.on("pmessage", (_: string, channel: string, message: string) => {
const msg = JSON.parse(message) as MessageQueueMessageOut;
if (msg.for && msg.for !== this.myUuid) {
@@ -63,7 +62,7 @@ export class RedisMQ extends EventEmitter implements MessageQueue {
public async push(message: MessageQueueMessage, single = false) {
if (!message.messageId) {
- message.messageId = uuid();
+ message.messageId = randomUUID();
}
if (single) {
const recipient = await this.getRecipientForEvent(message.eventName);
diff --git a/src/Stores/MemoryStorageProvider.ts b/src/Stores/MemoryStorageProvider.ts
index 6f4cd7fb..ac29b997 100644
--- a/src/Stores/MemoryStorageProvider.ts
+++ b/src/Stores/MemoryStorageProvider.ts
@@ -2,6 +2,7 @@ import { MemoryStorageProvider as MSP } from "matrix-bot-sdk";
import { IBridgeStorageProvider } from "./StorageProvider";
import { IssuesGetResponseData } from "../Github/Types";
import { ProvisionSession } from "matrix-appservice-bridge";
+import QuickLRU from "@alloc/quick-lru";
export class MemoryStorageProvider extends MSP implements IBridgeStorageProvider {
private issues: Map = new Map();
@@ -9,6 +10,7 @@ export class MemoryStorageProvider extends MSP implements IBridgeStorageProvider
private reviewData: Map = new Map();
private figmaCommentIds: Map = new Map();
private widgetSessions: Map = new Map();
+ private storedFiles = new QuickLRU({ maxSize: 128 });
constructor() {
super();
@@ -66,4 +68,11 @@ export class MemoryStorageProvider extends MSP implements IBridgeStorageProvider
.forEach(s => this.widgetSessions.delete(s.token));
}
+ public async getStoredTempFile(key: string): Promise {
+ return this.storedFiles.get(key) ?? null;
+ }
+
+ public async setStoredTempFile(key: string, value: string) {
+ this.storedFiles.set(key, value);
+ }
}
diff --git a/src/Stores/RedisStorageProvider.ts b/src/Stores/RedisStorageProvider.ts
index f6821efc..9024519f 100644
--- a/src/Stores/RedisStorageProvider.ts
+++ b/src/Stores/RedisStorageProvider.ts
@@ -15,6 +15,8 @@ const GH_ISSUES_KEY = "gh.issues";
const GH_ISSUES_LAST_COMMENT_KEY = "gh.issues.last_comment";
const GH_ISSUES_REVIEW_DATA_KEY = "gh.issues.review_data";
const FIGMA_EVENT_COMMENT_ID = "figma.comment_event_id";
+const STORED_FILES_KEY = "storedfiles.";
+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
@@ -65,6 +67,9 @@ export class RedisStorageProvider extends RedisStorageContextualProvider impleme
this.redis.expire(COMPLETED_TRANSACTIONS_KEY, COMPLETED_TRANSACTIONS_EXPIRE_AFTER).catch((ex) => {
log.warn("Failed to set expiry time on as.completed_transactions", ex);
});
+ this.redis.expire(STORED_FILES_KEY, STORED_FILES_EXPIRE_AFTER).catch((ex) => {
+ log.warn(`Failed to set expiry time on ${STORED_FILES_KEY}`, ex);
+ });
}
public async connect(): Promise {
@@ -181,4 +186,12 @@ export class RedisStorageProvider extends RedisStorageContextualProvider impleme
}
return new RedisStorageContextualProvider(this.redis, newContext.join("."));
}
+
+ public async getStoredTempFile(key: string) {
+ return this.redis.get(STORED_FILES_KEY + key);
+ }
+
+ public async setStoredTempFile(key: string, value: string) {
+ await this.redis.set(STORED_FILES_KEY + key, value);
+ }
}
diff --git a/src/Stores/StorageProvider.ts b/src/Stores/StorageProvider.ts
index 75ebf490..09e5c81e 100644
--- a/src/Stores/StorageProvider.ts
+++ b/src/Stores/StorageProvider.ts
@@ -12,4 +12,6 @@ export interface IBridgeStorageProvider extends IAppserviceStorageProvider, ISto
getPRReviewData(repo: string, issueNumber: string, scope?: string): Promise;
setFigmaCommentEventId(roomId: string, figmaCommentId: string, eventId: string): Promise;
getFigmaCommentEventId(roomId: string, figmaCommentId: string): Promise;
+ getStoredTempFile(key: string): Promise;
+ setStoredTempFile(key: string, value: string): Promise;
}
\ No newline at end of file
diff --git a/src/UserTokenStore.ts b/src/UserTokenStore.ts
index ff4e8468..f0ca68dd 100644
--- a/src/UserTokenStore.ts
+++ b/src/UserTokenStore.ts
@@ -7,7 +7,7 @@ import { Logger } from "matrix-appservice-bridge";
import { isJiraCloudInstance, JiraClient } from "./Jira/Client";
import { JiraStoredToken } from "./Jira/Types";
import { BridgeConfig, BridgeConfigJira, BridgeConfigJiraOnPremOAuth, BridgePermissionLevel } from "./Config/Config";
-import { v4 as uuid } from "uuid";
+import { randomUUID } from 'node:crypto';
import { GitHubOAuthToken } from "./Github/Types";
import { ApiError, ErrCode } from "./api";
import { JiraOAuth } from "./Jira/OAuth";
@@ -26,8 +26,8 @@ const LEGACY_ACCOUNT_DATA_TYPE = "uk.half-shot.matrix-github.password-store:";
const LEGACY_ACCOUNT_DATA_GITLAB_TYPE = "uk.half-shot.matrix-github.gitlab.password-store:";
const log = new Logger("UserTokenStore");
-type TokenType = "github"|"gitlab"|"jira";
-const AllowedTokenTypes = ["github", "gitlab", "jira"];
+export type TokenType = "github"|"gitlab"|"jira";
+export const AllowedTokenTypes = ["github", "gitlab", "jira"];
interface StoredTokenData {
encrypted: string|string[];
@@ -107,6 +107,9 @@ export class UserTokenStore extends TypedEmitter {
}
public async clearUserToken(type: TokenType, userId: string, instanceUrl?: string): Promise {
+ if (!AllowedTokenTypes.includes(type)) {
+ throw Error('Unknown token type');
+ }
const key = tokenKey(type, userId, false, instanceUrl);
const obj = await this.intent.underlyingClient.getSafeAccountData(key);
if (!obj || "deleted" in obj) {
@@ -254,7 +257,7 @@ export class UserTokenStore extends TypedEmitter {
}
public createStateForOAuth(userId: string): string {
- const state = uuid();
+ const state = randomUUID();
this.oauthSessionStore.set(state, {
userId,
timeout: setTimeout(() => this.oauthSessionStore.delete(state), OAUTH_TIMEOUT_MS),
diff --git a/src/Webhooks.ts b/src/Webhooks.ts
index a4474c00..612dc836 100644
--- a/src/Webhooks.ts
+++ b/src/Webhooks.ts
@@ -1,8 +1,9 @@
+/* eslint-disable camelcase */
import { BridgeConfig } from "./Config/Config";
import { Router, default as express, Request, Response } from "express";
import { EventEmitter } from "events";
import { MessageQueue, createMessageQueue } from "./MessageQueue";
-import { Logger } from "matrix-appservice-bridge";
+import { ApiError, ErrCode, Logger } from "matrix-appservice-bridge";
import qs from "querystring";
import axios from "axios";
import { IGitLabWebhookEvent, IGitLabWebhookIssueStateEvent, IGitLabWebhookMREvent, IGitLabWebhookReleaseEvent } from "./Gitlab/WebhookTypes";
@@ -185,52 +186,77 @@ export class Webhooks extends EventEmitter {
}
}
- public async onGitHubGetOauth(req: Request , res: Response) {
- log.info(`Got new oauth request`, { state: req.query.state });
+ public async onGitHubGetOauth(req: Request , res: Response) {
+ const oauthUrl = this.config.widgets && new URL("oauth.html", this.config.widgets.parsedPublicUrl);
+ if (oauthUrl) {
+ oauthUrl.searchParams.set('service', 'github');
+ oauthUrl?.searchParams.set('oauth-kind', 'account');
+ }
+ const { setup_action, state } = req.query;
+ log.info("Got new oauth request", { state, setup_action });
try {
if (!this.config.github || !this.config.github.oauth) {
- return res.status(500).send(`
Bridge is not configured with OAuth support
`);
+ throw new ApiError('Bridge is not configured with OAuth support', ErrCode.DisabledFeature);
}
if (req.query.error) {
- return res.status(500).send(`