mirror of
https://github.com/matrix-org/matrix-hookshot.git
synced 2025-03-10 21:19:13 +00:00
Merge remote-tracking branch 'origin/main' into j94/refactor-jira-oauth
This commit is contained in:
commit
cabc666855
3
.github/workflows/docker-hub-latest.yml
vendored
3
.github/workflows/docker-hub-latest.yml
vendored
@ -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 }}
|
||||
|
128
CHANGELOG.md
128
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)
|
||||
==================
|
||||
|
||||
|
@ -1 +0,0 @@
|
||||
Add support for end-to-bridge encryption via MSC3202.
|
@ -1 +0,0 @@
|
||||
Remove support for Pantalaimon-based encryption.
|
@ -1 +0,0 @@
|
||||
Clarify GitLab setup docs
|
@ -1 +0,0 @@
|
||||
Parent projects are now taken into account when calculating a user's access level to a GitLab project.
|
@ -1 +0,0 @@
|
||||
Ensure bridge treats published and drafted GitHub releases as different events.
|
@ -1 +0,0 @@
|
||||
RSS feed polling now uses cache headers sent by servers, which should mean we will be more conservative on resources.
|
@ -1 +0,0 @@
|
||||
Fix a bug where unknown keys in a connections state would be clobbered when updated via widget UI.
|
@ -1 +0,0 @@
|
||||
Add new GitHubRepo connection config setting `workflowRun.workflows` to filter run reports by workflow name.
|
@ -1 +0,0 @@
|
||||
Only build ARM images when merging or releasing, due to slow ARM build times.
|
@ -1 +0,0 @@
|
||||
Improve webhook code editor performance.
|
@ -1 +0,0 @@
|
||||
A11y: Add alt tags to all images.
|
@ -1 +0,0 @@
|
||||
Increase maximum size of incoming webhook payload from `100kb` to `10mb`.
|
@ -1 +0,0 @@
|
||||
Mark encryption feature as experimental (config option is now `experimentalEncryption`).
|
@ -1 +0,0 @@
|
||||
Improve startup stability by not loading all room state at once.
|
@ -1 +0,0 @@
|
||||
Cache yarn dependencies during Docker build.
|
@ -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.
|
@ -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:
|
||||
|
@ -27,3 +27,4 @@
|
||||
- [Workers](./advanced/workers.md)
|
||||
- [🔒 Encryption](./advanced/encryption.md)
|
||||
- [🪀 Widgets](./advanced/widgets.md)
|
||||
- [Service Bots](./advanced/service_bots.md)
|
||||
|
36
docs/advanced/service_bots.md
Normal file
36
docs/advanced/service_bots.md
Normal file
@ -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
|
||||
```
|
@ -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:
|
||||
|
@ -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:
|
||||
|
@ -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 *
|
||||
|
@ -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 *
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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");
|
||||
|
270
src/Bridge.ts
270
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<string, AdminRoom> = 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,15 +117,23 @@ 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,
|
||||
);
|
||||
}
|
||||
@ -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) => {
|
||||
@ -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) {
|
||||
@ -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<AdminAccountData>(
|
||||
let accountData = await botUser.intent.underlyingClient.getSafeRoomAccountData<AdminAccountData>(
|
||||
BRIDGE_ROOM_TYPE, roomId,
|
||||
);
|
||||
if (!accountData) {
|
||||
accountData = await this.as.botClient.getSafeRoomAccountData<AdminAccountData>(
|
||||
accountData = await botUser.intent.underlyingClient.getSafeRoomAccountData<AdminAccountData>(
|
||||
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)) {
|
||||
@ -720,13 +734,16 @@ export class Bridge {
|
||||
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,
|
||||
);
|
||||
|
||||
}
|
||||
@ -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<MatrixMemberContent>) {
|
||||
if (event.state_key !== this.as.botUserId) {
|
||||
// Only interested in bot leaves.
|
||||
private async onRoomLeave(roomId: string, matrixEvent: MatrixEvent<MatrixMemberContent>) {
|
||||
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
|
||||
|
||||
const botUsersInRoom = this.botUsersManager.getBotUsersInRoom(roomId);
|
||||
// Try each bot in the room until one handles the command
|
||||
for (const botUser of botUsersInRoom) {
|
||||
try {
|
||||
await (
|
||||
new SetupConnection(
|
||||
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,14 +912,18 @@ 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);
|
||||
);
|
||||
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<MatrixMemberContent>) {
|
||||
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<AdminAccountData>(
|
||||
const adminAccountData = await botUser.intent.underlyingClient.getSafeRoomAccountData<AdminAccountData>(
|
||||
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) ||
|
||||
// Recreate connections for the room
|
||||
await this.connectionManager.removeConnectionsForRoom(roomId);
|
||||
await this.connectionManager.createConnectionsForRoomId(roomId, true);
|
||||
|
||||
// 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 {
|
||||
// Set up the widget
|
||||
await SetupWidget.SetupRoomConfigWidget(roomId, this.as.botIntent, this.config.widgets);
|
||||
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 {
|
||||
}
|
||||
}
|
||||
|
||||
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?.[this.as.botUserId] || plEvent.defaultUserLevel;
|
||||
const previousPl = plEvent.previousContent?.users?.[this.as.botUserId] || plEvent.previousContent?.users_default;
|
||||
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, this.as.botIntent, this.config.widgets);
|
||||
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;
|
||||
}
|
||||
@ -1172,13 +1254,13 @@ export class Bridge {
|
||||
|
||||
}
|
||||
|
||||
private async getOrCreateAdminRoomForUser(userId: string): Promise<AdminRoom> {
|
||||
private async getOrCreateAdminRoom(intent: Intent, userId: string): Promise<AdminRoom> {
|
||||
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,7 +1306,7 @@ 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,
|
||||
@ -1227,17 +1314,18 @@ export class Bridge {
|
||||
res,
|
||||
issueInfo.projects,
|
||||
this.as,
|
||||
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;
|
||||
|
@ -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");
|
||||
@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
@ -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 || [];
|
||||
|
||||
@ -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) {
|
||||
@ -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<string, unknown> {
|
||||
let config: undefined|Record<string, unknown>;
|
||||
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;
|
||||
|
@ -1,15 +1,18 @@
|
||||
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",
|
||||
},
|
||||
@ -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<string, unknown>, parentNode?: YAMLSeq) {
|
||||
const entries = Object.entries(obj);
|
||||
|
@ -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<string, unknown>) {
|
||||
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<string, unknown>,
|
||||
) {
|
||||
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<any>, 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<any>, 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)[] {
|
||||
@ -295,6 +342,10 @@ export class ConnectionManager extends EventEmitter {
|
||||
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);
|
||||
}
|
||||
@ -370,7 +421,7 @@ export class ConnectionManager extends EventEmitter {
|
||||
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");
|
||||
|
@ -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<StateType extends IConnectionState = IConnectionState> extends BaseConnection {
|
||||
protected enabledHelpCategories?: string[];
|
||||
export abstract class CommandConnection<StateType extends IConnectionState = IConnectionState, ValidatedStateType extends StateType = StateType> 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<StateType extends IConnectionState = ICo
|
||||
}
|
||||
|
||||
public async onStateUpdate(stateEv: MatrixEvent<unknown>) {
|
||||
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>|ValidatedStateType;
|
||||
|
||||
public async onMessageEvent(ev: MatrixEvent<MatrixMessageContent>, checkPermission: PermissionCheckFn) {
|
||||
const commandResult = await handleCommand(
|
||||
@ -80,6 +80,6 @@ export abstract class CommandConnection<StateType extends IConnectionState = ICo
|
||||
|
||||
@botCommand("help", "This help text")
|
||||
public async helpCommand() {
|
||||
return this.botClient.sendEvent(this.roomId, 'm.room.message', this.helpMessage(this.commandPrefix, this.enabledHelpCategories, this.includeTitlesInHelp));
|
||||
return this.botClient.sendEvent(this.roomId, 'm.room.message', this.helpMessage(this.commandPrefix, this.helpCategories, this.includeTitlesInHelp));
|
||||
}
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
import {Appservice, StateEvent} from "matrix-bot-sdk";
|
||||
import {Appservice, Intent, StateEvent} from "matrix-bot-sdk";
|
||||
import { IConnection, IConnectionState, InstantiateConnectionOpts } from ".";
|
||||
import { ApiError, ErrCode } from "../api";
|
||||
import { BridgeConfigFeeds } from "../Config/Config";
|
||||
@ -42,13 +42,13 @@ const MAX_LAST_RESULT_ITEMS = 5;
|
||||
export class FeedConnection extends BaseConnection implements IConnection {
|
||||
static readonly CanonicalEventType = "uk.half-shot.matrix-hookshot.feed";
|
||||
static readonly EventTypes = [ FeedConnection.CanonicalEventType ];
|
||||
static readonly ServiceCategory = "feed";
|
||||
static readonly ServiceCategory = "feeds";
|
||||
|
||||
public static createConnectionForState(roomId: string, event: StateEvent<any>, {config, as, storage}: InstantiateConnectionOpts) {
|
||||
public static createConnectionForState(roomId: string, event: StateEvent<any>, {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<void> {
|
||||
@ -74,7 +74,7 @@ export class FeedConnection extends BaseConnection implements IConnection {
|
||||
}
|
||||
}
|
||||
|
||||
static async provisionConnection(roomId: string, _userId: string, data: Record<string, unknown> = {}, {as, config, storage}: ProvisionConnectionOpts) {
|
||||
static async provisionConnection(roomId: string, _userId: string, data: Record<string, unknown> = {}, {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<void> {
|
||||
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 {
|
||||
|
@ -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");
|
||||
|
||||
@ -43,33 +44,36 @@ export class FigmaFileConnection extends BaseConnection implements IConnection {
|
||||
}
|
||||
}
|
||||
|
||||
public static createConnectionForState(roomId: string, event: StateEvent<any>, {config, as, storage}: InstantiateConnectionOpts) {
|
||||
public static createConnectionForState(roomId: string, event: StateEvent<any>, {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<string, unknown> = {}, {as, config, storage}: ProvisionConnectionOpts) {
|
||||
static async provisionConnection(roomId: string, userId: string, data: Record<string, unknown> = {}, {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,7 +112,12 @@ 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");
|
||||
|
@ -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 {
|
||||
/**
|
||||
@ -130,19 +131,19 @@ export class GenericHookConnection extends BaseConnection implements IConnection
|
||||
};
|
||||
}
|
||||
|
||||
static async createConnectionForState(roomId: string, event: StateEvent<Record<string, unknown>>, {as, config, messageClient}: InstantiateConnectionOpts) {
|
||||
static async createConnectionForState(roomId: string, event: StateEvent<Record<string, unknown>>, {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<GenericHookAccountData>(GenericHookConnection.CanonicalEventType, roomId, {});
|
||||
const acctData = await intent.underlyingClient.getSafeRoomAccountData<GenericHookAccountData>(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<string, unknown> = {}, {as, config, messageClient}: ProvisionConnectionOpts) {
|
||||
static async provisionConnection(roomId: string, userId: string, data: Record<string, unknown> = {}, {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<GenericHookAccountData>(GenericHookConnection.CanonicalEventType, roomId, {});
|
||||
static async ensureRoomAccountData(roomId: string, intent: Intent, hookId: string, stateKey: string, remove = false) {
|
||||
const data = await intent.underlyingClient.getSafeRoomAccountData<GenericHookAccountData>(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,13 +199,16 @@ 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) {
|
||||
private readonly as: Appservice,
|
||||
private readonly intent: Intent,
|
||||
) {
|
||||
super(roomId, stateKey, GenericHookConnection.CanonicalEventType);
|
||||
if (state.transformationFunction && config.allowJsTransformationFunctions) {
|
||||
this.transformationFunction = new Script(state.transformationFunction);
|
||||
@ -217,39 +219,37 @@ export class GenericHookConnection extends BaseConnection implements IConnection
|
||||
return this.state.priority || super.priority;
|
||||
}
|
||||
|
||||
|
||||
public isInterestedInStateEvent(eventType: string, stateKey: string) {
|
||||
return GenericHookConnection.EventTypes.includes(eventType) && this.stateKey === stateKey;
|
||||
}
|
||||
|
||||
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.
|
||||
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;
|
||||
@ -376,7 +376,10 @@ 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);
|
||||
@ -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<string, unknown>) {
|
||||
// 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
|
||||
|
@ -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<any>, {
|
||||
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<string>(); //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,19 +94,37 @@ 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<string, undefined>({ 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) {
|
||||
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) {
|
||||
@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
public async ensureGrant(sender?: string) {
|
||||
await this.grantChecker.assertConnectionGranted(this.roomId, GitHubDiscussionConnection.grantKey(this.state), sender);
|
||||
}
|
||||
}
|
@ -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<any>, {
|
||||
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<Record<string, unknown>> {
|
||||
public static async onQueryRoom(result: RegExpExecArray, opts: {githubInstance: GithubInstance, as: Appservice}): Promise<Record<string, unknown>> {
|
||||
if (!result || result.length < 2) {
|
||||
log.error(`Invalid alias pattern '${result}'`);
|
||||
throw Error("Could not find issue");
|
||||
@ -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) {
|
||||
@ -168,8 +180,14 @@ export class GitHubDiscussionSpace extends BaseConnection implements IConnection
|
||||
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 {
|
||||
|
||||
|
@ -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<any>, {
|
||||
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,15 +154,18 @@ 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,) {
|
||||
private config: BridgeConfigGitHub,
|
||||
) {
|
||||
super(roomId, stateKey, GitHubIssueConnection.CanonicalEventType);
|
||||
}
|
||||
|
||||
@ -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<MatrixMessageContent>, 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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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<any>, {config, as}: InstantiateConnectionOpts) {
|
||||
public static createConnectionForState(roomId: string, event: StateEvent<any>, {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<GitHubProjectConnection> {
|
||||
public static getGrantKey(projectId: number) {
|
||||
return `${this.CanonicalEventType}/${projectId}`;
|
||||
}
|
||||
|
||||
static async onOpenProject(project: ProjectsGetResponseData, as: Appservice, intent: Intent, config: BridgeConfig, inviteUser: string): Promise<GitHubProjectConnection> {
|
||||
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,19 +61,31 @@ export class GitHubProjectConnection extends BaseConnection implements IConnecti
|
||||
},
|
||||
],
|
||||
});
|
||||
await new GrantChecker(as.botIntent, 'github').grantConnection(roomId, this.getGrantKey(project.id));
|
||||
|
||||
return new GitHubProjectConnection(roomId, as, state, project.url)
|
||||
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) {
|
||||
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) {
|
||||
|
@ -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,7 +96,7 @@ export type GitHubRepoConnectionTarget = GitHubRepoConnectionOrgTarget|GitHubRep
|
||||
export type GitHubRepoResponseItem = GetConnectionsResponseItem<GitHubRepoConnectionState>;
|
||||
|
||||
|
||||
type AllowedEventsNames =
|
||||
export type AllowedEventsNames =
|
||||
"issue.changed" |
|
||||
"issue.created" |
|
||||
"issue.edited" |
|
||||
@ -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: {
|
||||
@ -295,43 +323,45 @@ const WORKFLOW_CONCLUSION_TO_NOTICE: Record<WorkflowRunCompletedEvent["workflow_
|
||||
const LABELED_DEBOUNCE_MS = 5000;
|
||||
const CREATED_GRACE_PERIOD_MS = 6000;
|
||||
const DEFAULT_HOTLINK_PREFIX = "#";
|
||||
const MAX_RETURNED_TARGETS = 10;
|
||||
|
||||
function compareEmojiStrings(e0: string, e1: string, e0Index = 0) {
|
||||
return e0.codePointAt(e0Index) === e1.codePointAt(0);
|
||||
}
|
||||
|
||||
export interface GitHubTargetFilter {
|
||||
search?: string;
|
||||
orgName?: string;
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles rooms connected to a GitHub repo.
|
||||
*/
|
||||
@Connection
|
||||
export class GitHubRepoConnection extends CommandConnection<GitHubRepoConnectionState> implements IConnection {
|
||||
|
||||
static validateState(state: unknown, isExistingState = false): GitHubRepoConnectionState {
|
||||
export class GitHubRepoConnection extends CommandConnection<GitHubRepoConnectionState, ConnectionValidatedState> 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<string, unknown>, {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<GitHubRepoConnection
|
||||
const me = await octokit.users.getAuthenticated();
|
||||
let permissionLevel;
|
||||
try {
|
||||
const repo = await octokit.repos.getCollaboratorPermissionLevel({owner: validData.org, repo: validData.repo, username: me.data.login });
|
||||
permissionLevel = repo.data.permission;
|
||||
const githubRepo = await octokit.repos.getCollaboratorPermissionLevel({owner: org, repo, username: me.data.login });
|
||||
permissionLevel = githubRepo.data.permission;
|
||||
} catch (ex) {
|
||||
throw new ApiError("Could not determine if the user has access to this repository, does the repository exist?", ErrCode.ForbiddenUser);
|
||||
}
|
||||
@ -348,6 +378,14 @@ export class GitHubRepoConnection extends CommandConnection<GitHubRepoConnection
|
||||
if (permissionLevel !== "admin" && permissionLevel !== "write") {
|
||||
throw new ApiError("You must at least have write permissions to bridge this repository", ErrCode.ForbiddenUser);
|
||||
}
|
||||
}
|
||||
|
||||
static async provisionConnection(roomId: string, userId: string, data: Record<string, unknown>, {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<GitHubRepoConnection
|
||||
);
|
||||
}
|
||||
const stateEventKey = `${validData.org}/${validData.repo}`;
|
||||
await as.botClient.sendStateEvent(roomId, this.CanonicalEventType, stateEventKey, validData);
|
||||
await new GrantChecker(as.botIntent, 'github').grantConnection(roomId, this.getGrantKey(validData.org, validData.repo));
|
||||
await intent.underlyingClient.sendStateEvent(roomId, this.CanonicalEventType, stateEventKey, validData);
|
||||
return {
|
||||
stateEventContent: validData,
|
||||
connection: new GitHubRepoConnection(roomId, as, validData, tokenStore, stateEventKey, github, config.github),
|
||||
connection: new GitHubRepoConnection(roomId, as, intent, validData, tokenStore, stateEventKey, github, config.github),
|
||||
}
|
||||
}
|
||||
|
||||
@ -377,11 +416,14 @@ export class GitHubRepoConnection extends CommandConnection<GitHubRepoConnection
|
||||
static readonly ServiceCategory = "github";
|
||||
static readonly QueryRoomRegex = /#github_(.+)_(.+):.*/;
|
||||
|
||||
static async createConnectionForState(roomId: string, state: StateEvent<Record<string, unknown>>, {as, tokenStore, github, config}: InstantiateConnectionOpts) {
|
||||
static async createConnectionForState(roomId: string, state: StateEvent<Record<string, unknown>>, {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<unknown> {
|
||||
@ -469,9 +511,13 @@ export class GitHubRepoConnection extends CommandConnection<GitHubRepoConnection
|
||||
|
||||
public debounceOnIssueLabeled = new Map<number, {labels: Set<string>, 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,
|
||||
@ -482,16 +528,15 @@ export class GitHubRepoConnection extends CommandConnection<GitHubRepoConnection
|
||||
stateKey,
|
||||
GitHubRepoConnection.CanonicalEventType,
|
||||
state,
|
||||
as.botClient,
|
||||
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<GitHubRepoConnection
|
||||
return this.state.priority || super.priority;
|
||||
}
|
||||
|
||||
protected validateConnectionState(content: unknown) {
|
||||
return GitHubRepoConnection.validateState(content);
|
||||
public async ensureGrant(sender?: string, state = this.state) {
|
||||
await this.grantChecker.assertConnectionGranted(this.roomId, state, sender);
|
||||
}
|
||||
|
||||
protected async validateConnectionState(content: unknown) {
|
||||
const state = GitHubRepoConnection.validateState(content);
|
||||
// Validate the permissions of this state
|
||||
await this.ensureGrant(undefined, state);
|
||||
return state;
|
||||
}
|
||||
|
||||
public async onStateUpdate(stateEv: MatrixEvent<unknown>) {
|
||||
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<GitHubRepoConnection
|
||||
message += ` [Issue Room](https://matrix.to/#/${this.as.getAlias(GitHubIssueConnection.generateAliasLocalpart(this.org, this.repo, issue.number))})`;
|
||||
}
|
||||
const content = emoji.emojify(message);
|
||||
await this.as.botIntent.sendEvent(this.roomId, {
|
||||
await this.intent.sendEvent(this.roomId, {
|
||||
msgtype: "m.notice",
|
||||
body: content ,
|
||||
formatted_body: md.renderInline(content),
|
||||
@ -624,14 +675,14 @@ export class GitHubRepoConnection extends CommandConnection<GitHubRepoConnection
|
||||
event: reviewEvent,
|
||||
});
|
||||
} catch (ex) {
|
||||
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: ev.event_id,
|
||||
key: "⛔",
|
||||
}
|
||||
});
|
||||
await this.as.botClient.sendEvent(this.roomId, 'm.room.message', {
|
||||
await this.intent.underlyingClient.sendEvent(this.roomId, 'm.room.message', {
|
||||
msgtype: "m.notice",
|
||||
body: `Failed to submit review: ${ex.message}`,
|
||||
});
|
||||
@ -750,7 +801,7 @@ export class GitHubRepoConnection extends CommandConnection<GitHubRepoConnection
|
||||
const workflow = workflows.data.workflows.find(w => 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<GitHubRepoConnection
|
||||
throw ex;
|
||||
}
|
||||
|
||||
await this.as.botIntent.sendText(this.roomId, `Workflow started.`, "m.notice");
|
||||
await this.intent.sendText(this.roomId, `Workflow started.`, "m.notice");
|
||||
}
|
||||
|
||||
public async onIssueCreated(event: IssuesOpenedEvent) {
|
||||
@ -808,7 +859,7 @@ export class GitHubRepoConnection extends CommandConnection<GitHubRepoConnection
|
||||
}
|
||||
const content = emoji.emojify(message);
|
||||
const labels = FormatUtil.formatLabels(event.issue.labels?.map(l => ({ name: l.name, description: l.description || undefined, color: l.color || undefined })));
|
||||
await this.as.botIntent.sendEvent(this.roomId, {
|
||||
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<GitHubRepoConnection
|
||||
}
|
||||
}
|
||||
const content = `**${event.sender.login}** ${state} issue [${orgRepoName}#${event.issue.number}](${event.issue.html_url}): "${emoji.emojify(event.issue.title)}"${withComment}`;
|
||||
await this.as.botIntent.sendEvent(this.roomId, {
|
||||
await this.intent.sendEvent(this.roomId, {
|
||||
msgtype: "m.notice",
|
||||
body: content,
|
||||
formatted_body: md.renderInline(content),
|
||||
@ -873,7 +924,7 @@ export class GitHubRepoConnection extends CommandConnection<GitHubRepoConnection
|
||||
log.info(`onIssueEdited ${this.roomId} ${this.org}/${this.repo} #${event.issue.number}`);
|
||||
const orgRepoName = event.repository.full_name;
|
||||
const content = `**${event.sender.login}** edited issue [${orgRepoName}#${event.issue.number}](${event.issue.html_url}): "${emoji.emojify(event.issue.title)}"`;
|
||||
await this.as.botIntent.sendEvent(this.roomId, {
|
||||
await this.intent.sendEvent(this.roomId, {
|
||||
msgtype: "m.notice",
|
||||
body: content,
|
||||
formatted_body: md.renderInline(content),
|
||||
@ -904,7 +955,7 @@ export class GitHubRepoConnection extends CommandConnection<GitHubRepoConnection
|
||||
const orgRepoName = event.repository.full_name;
|
||||
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}`: ""),
|
||||
@ -962,7 +1013,7 @@ export class GitHubRepoConnection extends CommandConnection<GitHubRepoConnection
|
||||
}
|
||||
const content = emoji.emojify(`**${event.sender.login}** ${verb} a new PR [${orgRepoName}#${event.pull_request.number}](${event.pull_request.html_url}): "${event.pull_request.title}"`);
|
||||
const labels = FormatUtil.formatLabels(event.pull_request.labels?.map(l => ({ name: l.name, description: l.description || undefined, color: l.color || undefined })));
|
||||
await this.as.botIntent.sendEvent(this.roomId, {
|
||||
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<GitHubRepoConnection
|
||||
}
|
||||
const orgRepoName = event.repository.full_name;
|
||||
const content = emoji.emojify(`**${event.sender.login}** has marked [${orgRepoName}#${event.pull_request.number}](${event.pull_request.html_url}) as ready to review "${event.pull_request.title}"`);
|
||||
await this.as.botIntent.sendEvent(this.roomId, {
|
||||
await this.intent.sendEvent(this.roomId, {
|
||||
msgtype: "m.notice",
|
||||
body: content,
|
||||
formatted_body: md.renderInline(content),
|
||||
@ -1018,7 +1069,7 @@ export class GitHubRepoConnection extends CommandConnection<GitHubRepoConnection
|
||||
return;
|
||||
}
|
||||
const content = emoji.emojify(`**${event.sender.login}** ${emojiForReview} ${event.review.state.toLowerCase()} [${orgRepoName}#${event.pull_request.number}](${event.pull_request.html_url}) "${event.pull_request.title}"`);
|
||||
await this.as.botIntent.sendEvent(this.roomId, {
|
||||
await this.intent.sendEvent(this.roomId, {
|
||||
msgtype: "m.notice",
|
||||
body: content,
|
||||
formatted_body: md.renderInline(content),
|
||||
@ -1064,7 +1115,7 @@ export class GitHubRepoConnection extends CommandConnection<GitHubRepoConnection
|
||||
}
|
||||
|
||||
const content = emoji.emojify(`**${event.sender.login}** ${verb} PR [${orgRepoName}#${event.pull_request.number}](${event.pull_request.html_url}): "${event.pull_request.title}"${withComment}`);
|
||||
await this.as.botIntent.sendEvent(this.roomId, {
|
||||
await this.intent.sendEvent(this.roomId, {
|
||||
msgtype: "m.notice",
|
||||
body: content,
|
||||
formatted_body: md.renderInline(content),
|
||||
@ -1094,7 +1145,7 @@ export class GitHubRepoConnection extends CommandConnection<GitHubRepoConnection
|
||||
if (event.release.body) {
|
||||
content += `\n\n${event.release.body}`
|
||||
}
|
||||
await this.as.botIntent.sendEvent(this.roomId, {
|
||||
await this.intent.sendEvent(this.roomId, {
|
||||
msgtype: "m.notice",
|
||||
body: content,
|
||||
formatted_body: md.render(content),
|
||||
@ -1120,7 +1171,7 @@ export class GitHubRepoConnection extends CommandConnection<GitHubRepoConnection
|
||||
if (event.release.body) {
|
||||
content += `\n\n${event.release.body}`
|
||||
}
|
||||
await this.as.botIntent.sendEvent(this.roomId, {
|
||||
await this.intent.sendEvent(this.roomId, {
|
||||
msgtype: "m.notice",
|
||||
body: content,
|
||||
formatted_body: md.render(content),
|
||||
@ -1153,7 +1204,7 @@ export class GitHubRepoConnection extends CommandConnection<GitHubRepoConnection
|
||||
log.info(`onWorkflowCompleted ${this.roomId} ${this.org}/${this.repo} '${workflowRun.id}'`);
|
||||
const orgRepoName = event.repository.full_name;
|
||||
const content = `Workflow **${event.workflow.name}** [${WORKFLOW_CONCLUSION_TO_NOTICE[workflowRun.conclusion]}](${workflowRun.html_url}) for ${orgRepoName} on branch \`${workflowRun.head_branch}\``;
|
||||
await this.as.botIntent.sendEvent(this.roomId, {
|
||||
await this.intent.sendEvent(this.roomId, {
|
||||
msgtype: "m.notice",
|
||||
body: content,
|
||||
formatted_body: md.render(content),
|
||||
@ -1168,7 +1219,7 @@ export class GitHubRepoConnection extends CommandConnection<GitHubRepoConnection
|
||||
}
|
||||
if (evt.type === 'm.reaction') {
|
||||
const {event_id, key} = (evt.content as MatrixReactionContent)["m.relates_to"];
|
||||
const ev = await this.as.botClient.getEvent(this.roomId, event_id);
|
||||
const ev = await this.intent.underlyingClient.getEvent(this.roomId, event_id);
|
||||
const issueContent = ev.content["uk.half-shot.matrix-hookshot.github.issue"];
|
||||
if (!issueContent) {
|
||||
log.debug('Reaction to event did not pertain to a issue');
|
||||
@ -1225,7 +1276,7 @@ export class GitHubRepoConnection extends CommandConnection<GitHubRepoConnection
|
||||
|
||||
public getProvisionerDetails(): GitHubRepoResponseItem {
|
||||
return {
|
||||
...GitHubRepoConnection.getProvisionerDetails(this.as.botUserId),
|
||||
...GitHubRepoConnection.getProvisionerDetails(this.intent.userId),
|
||||
id: this.connectionId,
|
||||
config: {
|
||||
...this.state,
|
||||
@ -1233,6 +1284,48 @@ export class GitHubRepoConnection extends CommandConnection<GitHubRepoConnection
|
||||
}
|
||||
}
|
||||
|
||||
public static async searchInstallationForRepos(octokit: Octokit, orgName: string, installationId: number, searchTerms?: string) {
|
||||
// First, do a search on GitHub for repos. This will use the user's context so it will find user repos.
|
||||
let searchRepos: string[]|null = null;
|
||||
if (searchTerms) {
|
||||
const terms = encodeURIComponent(searchTerms);
|
||||
const searchResultsData = (await octokit.search.repos({
|
||||
q: `${terms} org:${orgName} `,
|
||||
per_page: MAX_RETURNED_TARGETS,
|
||||
})).data;
|
||||
if (searchResultsData.total_count === 0) {
|
||||
return [];
|
||||
}
|
||||
searchRepos = searchResultsData.items.map(r => 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<GitHubRepoConnectionTarget[]> {
|
||||
// Search for all repos under the user's control.
|
||||
const octokit = await tokenStore.getOctokitForUser(userId);
|
||||
@ -1262,37 +1355,25 @@ export class GitHubRepoConnection extends CommandConnection<GitHubRepoConnection
|
||||
// If we have an instance, search under it.
|
||||
const ownSelf = await octokit.users.getAuthenticated();
|
||||
|
||||
const page = filters.page ?? 1;
|
||||
const perPage = filters.perPage ?? 10;
|
||||
try {
|
||||
let reposPromise;
|
||||
|
||||
let installationId;
|
||||
if (ownSelf.data.login === filters.orgName) {
|
||||
const userInstallation = await githubInstance.appOctokit.apps.getUserInstallation({username: ownSelf.data.login});
|
||||
reposPromise = octokit.apps.listInstallationReposForAuthenticatedUser({
|
||||
page,
|
||||
installation_id: userInstallation.data.id,
|
||||
per_page: perPage,
|
||||
});
|
||||
installationId = (await githubInstance.appOctokit.apps.getUserInstallation({ username: ownSelf.data.login })).data.id;
|
||||
} else {
|
||||
const orgInstallation = await githubInstance.appOctokit.apps.getOrgInstallation({org: filters.orgName});
|
||||
|
||||
installationId = (await githubInstance.appOctokit.apps.getOrgInstallation({ org: filters.orgName })).data.id;
|
||||
// Github will error if the authed user tries to list repos of a disallowed installation, even
|
||||
// if we got the installation ID from the app's instance.
|
||||
reposPromise = octokit.apps.listInstallationReposForAuthenticatedUser({
|
||||
page,
|
||||
installation_id: orgInstallation.data.id,
|
||||
per_page: perPage,
|
||||
});
|
||||
}
|
||||
const reposRes = await reposPromise;
|
||||
return reposRes.data.repositories
|
||||
const reposRes = await this.searchInstallationForRepos(octokit, filters.orgName, installationId, filters.search);
|
||||
return reposRes
|
||||
.map(r => ({
|
||||
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<GitHubRepoConnection
|
||||
// Apply previous state to the current config, as provisioners might not return "unknown" keys.
|
||||
const newState = { ...this.state, ...config };
|
||||
const validatedConfig = GitHubRepoConnection.validateState(newState);
|
||||
await this.as.botClient.sendStateEvent(this.roomId, GitHubRepoConnection.CanonicalEventType, this.stateKey, validatedConfig);
|
||||
await this.intent.underlyingClient.sendStateEvent(this.roomId, GitHubRepoConnection.CanonicalEventType, this.stateKey, validatedConfig);
|
||||
this.state = validatedConfig;
|
||||
this.hookFilter.enabledHooks = this.state.enableHooks ?? [];
|
||||
this.hookFilter.ignoredHooks = this.state.ignoreHooks ?? [];
|
||||
}
|
||||
|
||||
public async onRemove() {
|
||||
log.info(`Removing ${this.toString()} for ${this.roomId}`);
|
||||
await this.grantChecker.ungrantConnection(this.roomId, { org: this.org, repo: this.repo });
|
||||
// Do a sanity check that the event exists.
|
||||
try {
|
||||
await this.as.botClient.getRoomStateEvent(this.roomId, GitHubRepoConnection.CanonicalEventType, this.stateKey);
|
||||
await this.as.botClient.sendStateEvent(this.roomId, GitHubRepoConnection.CanonicalEventType, this.stateKey, { disabled: true });
|
||||
await this.intent.underlyingClient.getRoomStateEvent(this.roomId, GitHubRepoConnection.CanonicalEventType, this.stateKey);
|
||||
await this.intent.underlyingClient.sendStateEvent(this.roomId, GitHubRepoConnection.CanonicalEventType, this.stateKey, { disabled: true });
|
||||
} catch (ex) {
|
||||
await this.as.botClient.getRoomStateEvent(this.roomId, GitHubRepoConnection.LegacyCanonicalEventType, this.stateKey);
|
||||
await this.as.botClient.sendStateEvent(this.roomId, GitHubRepoConnection.LegacyCanonicalEventType, this.stateKey, { disabled: true });
|
||||
await this.intent.underlyingClient.getRoomStateEvent(this.roomId, GitHubRepoConnection.LegacyCanonicalEventType, this.stateKey);
|
||||
await this.intent.underlyingClient.sendStateEvent(this.roomId, GitHubRepoConnection.LegacyCanonicalEventType, this.stateKey, { disabled: true });
|
||||
}
|
||||
}
|
||||
|
||||
@ -1334,6 +1415,10 @@ export class GitHubRepoConnection extends CommandConnection<GitHubRepoConnection
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public static getGrantKey(org: string, repo: string) {
|
||||
return `${this.CanonicalEventType}/${org}/${repo}`;
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
@ -5,6 +5,8 @@ import axios from "axios";
|
||||
import { GitHubDiscussionSpace } from ".";
|
||||
import { GithubInstance } from "../Github/GithubInstance";
|
||||
import { BaseConnection } from "./BaseConnection";
|
||||
import { ConfigGrantChecker, GrantChecker } from "../grants/GrantCheck";
|
||||
import { BridgeConfig } from "../Config/Config";
|
||||
|
||||
const log = new Logger("GitHubOwnerSpace");
|
||||
|
||||
@ -28,13 +30,17 @@ export class GitHubUserSpace extends BaseConnection implements IConnection {
|
||||
static readonly QueryRoomRegex = /#github_(.+):.*/;
|
||||
static readonly ServiceCategory = "github";
|
||||
|
||||
private static grantKey(state: GitHubUserSpaceConnectionState) {
|
||||
return `${this.CanonicalEventType}/${state.username}`;
|
||||
}
|
||||
|
||||
public static async createConnectionForState(roomId: string, event: StateEvent<any>, {
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
@ -137,10 +143,19 @@ 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) {
|
||||
|
@ -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<any>, {
|
||||
config, as, tokenStore, commentProcessor, messageClient}: InstantiateConnectionOpts) {
|
||||
public static async createConnectionForState(
|
||||
roomId: string,
|
||||
event: StateEvent<any>,
|
||||
{ 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,16 +127,35 @@ 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) {
|
||||
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) {
|
||||
@ -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<MatrixMessageContent>, 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),
|
||||
});
|
||||
}
|
||||
|
@ -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;
|
||||
@ -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<GitLabRepoConnectionState> implements IConnection {
|
||||
export class GitLabRepoConnection extends CommandConnection<GitLabRepoConnectionState, ConnectionStateValidated> implements IConnection {
|
||||
static readonly CanonicalEventType = "uk.half-shot.matrix-hookshot.gitlab.repository";
|
||||
static readonly LegacyCanonicalEventType = "uk.half-shot.matrix-github.gitlab.repository";
|
||||
|
||||
@ -152,19 +180,30 @@ export class GitLabRepoConnection extends CommandConnection<GitLabRepoConnection
|
||||
static helpMessage: (cmdPrefix?: string | undefined) => 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<Record<string, unknown>>, {as, tokenStore, config}: InstantiateConnectionOpts) {
|
||||
static async createConnectionForState(roomId: string, event: StateEvent<Record<string, unknown>>, {as, intent, tokenStore, config}: InstantiateConnectionOpts) {
|
||||
if (!config.gitlab) {
|
||||
throw Error('GitLab is not configured');
|
||||
}
|
||||
@ -173,18 +212,16 @@ export class GitLabRepoConnection extends CommandConnection<GitLabRepoConnection
|
||||
if (!instance) {
|
||||
throw Error('Instance name not recognised');
|
||||
}
|
||||
return new GitLabRepoConnection(roomId, event.stateKey, as, state, tokenStore, instance);
|
||||
return new GitLabRepoConnection(roomId, event.stateKey, as, config.gitlab, intent, state, tokenStore, instance);
|
||||
}
|
||||
|
||||
public static async provisionConnection(roomId: string, requester: string, data: Record<string, unknown>, { 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<GitLabRepoConnection
|
||||
}
|
||||
let permissionLevel;
|
||||
try {
|
||||
permissionLevel = await client.projects.getMyAccessLevel(validData.path);
|
||||
permissionLevel = await client.projects.getMyAccessLevel(path);
|
||||
} catch (ex) {
|
||||
throw new ApiError("Could not determine if the user has access to this project, does the project exist?", ErrCode.ForbiddenUser);
|
||||
}
|
||||
@ -200,11 +237,28 @@ export class GitLabRepoConnection extends CommandConnection<GitLabRepoConnection
|
||||
if (permissionLevel < AccessLevel.Developer) {
|
||||
throw new ApiError("You must at least have developer access to bridge this project", ErrCode.ForbiddenUser);
|
||||
}
|
||||
return permissionLevel;
|
||||
}
|
||||
|
||||
public static async provisionConnection(roomId: string, requester: string, data: Record<string, unknown>, { 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<GitLabRepoConnection
|
||||
};
|
||||
log.warn(`Not creating webhook, permission level is insufficient (${permissionLevel} < ${AccessLevel.Maintainer})`)
|
||||
}
|
||||
await as.botIntent.underlyingClient.sendStateEvent(roomId, this.CanonicalEventType, connection.stateKey, validData);
|
||||
await new GitLabGrantChecker(as, gitlabConfig, tokenStore).grantConnection(roomId, { instance: validData.instance, path: validData.path })
|
||||
await intent.underlyingClient.sendStateEvent(roomId, this.CanonicalEventType, connection.stateKey, validData);
|
||||
return {connection, warning};
|
||||
}
|
||||
|
||||
@ -257,7 +312,39 @@ export class GitLabRepoConnection extends CommandConnection<GitLabRepoConnection
|
||||
}
|
||||
}
|
||||
|
||||
public static async getConnectionTargets(userId: string, tokenStore: UserTokenStore, config: BridgeConfigGitLab, filters: GitLabTargetFilter = {}): Promise<GitLabRepoConnectionTarget[]> {
|
||||
public static async getBase64Avatar(avatarUrl: string, client: GitLabClient, storage: IBridgeStorageProvider): Promise<string|null> {
|
||||
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<GitLabRepoConnectionTarget[]> {
|
||||
// Search for all repos under the user's control.
|
||||
|
||||
if (!filters.instance) {
|
||||
@ -278,15 +365,16 @@ export class GitLabRepoConnection extends CommandConnection<GitLabRepoConnection
|
||||
if (!client) {
|
||||
throw new ApiError('Instance is not known or you do not have access to it.', ErrCode.NotFound);
|
||||
}
|
||||
const after = filters.after === undefined ? undefined : parseInt(filters.after, 10);
|
||||
const allProjects = await client.projects.list(AccessLevel.Developer, filters.parent, after, filters.search);
|
||||
return allProjects.map(p => ({
|
||||
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<string, {
|
||||
@ -306,31 +394,36 @@ export class GitLabRepoConnection extends CommandConnection<GitLabRepoConnection
|
||||
private readonly mergeRequestSeenDiscussionIds = new QuickLRU<string, undefined>({ maxSize: 100 });
|
||||
private readonly hookFilter: HookFilter<AllowedEventsNames>;
|
||||
|
||||
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) {
|
||||
private readonly instance: GitLabInstance,
|
||||
) {
|
||||
super(
|
||||
roomId,
|
||||
stateKey,
|
||||
GitLabRepoConnection.CanonicalEventType,
|
||||
state,
|
||||
as.botClient,
|
||||
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(
|
||||
// GitLab allows all events by default
|
||||
AllowedEvents,
|
||||
[],
|
||||
state.ignoreHooks,
|
||||
state.enableHooks ?? DefaultHooks,
|
||||
);
|
||||
}
|
||||
|
||||
@ -360,13 +453,18 @@ export class GitLabRepoConnection extends CommandConnection<GitLabRepoConnection
|
||||
}
|
||||
|
||||
public async onStateUpdate(stateEv: MatrixEvent<unknown>) {
|
||||
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<GitLabRepoConnection
|
||||
});
|
||||
|
||||
const content = `Created issue #${res.iid}: [${res.web_url}](${res.web_url})`;
|
||||
return this.as.botIntent.sendEvent(this.roomId,{
|
||||
return this.intent.sendEvent(this.roomId,{
|
||||
msgtype: "m.notice",
|
||||
body: content,
|
||||
formatted_body: md.render(content),
|
||||
@ -413,7 +511,7 @@ export class GitLabRepoConnection extends CommandConnection<GitLabRepoConnection
|
||||
});
|
||||
|
||||
const content = `Created confidential issue #${res.iid}: [${res.web_url}](${res.web_url})`;
|
||||
return this.as.botIntent.sendEvent(this.roomId,{
|
||||
return this.intent.sendEvent(this.roomId,{
|
||||
msgtype: "m.notice",
|
||||
body: content,
|
||||
formatted_body: md.render(content),
|
||||
@ -449,7 +547,7 @@ export class GitLabRepoConnection extends CommandConnection<GitLabRepoConnection
|
||||
this.validateMREvent(event);
|
||||
const orgRepoName = event.project.path_with_namespace;
|
||||
const content = `**${event.user.username}** opened a new MR [${orgRepoName}#${event.object_attributes.iid}](${event.object_attributes.url}): "${event.object_attributes.title}"`;
|
||||
await this.as.botIntent.sendEvent(this.roomId, {
|
||||
await this.intent.sendEvent(this.roomId, {
|
||||
msgtype: "m.notice",
|
||||
body: content,
|
||||
formatted_body: md.renderInline(content),
|
||||
@ -465,7 +563,7 @@ export class GitLabRepoConnection extends CommandConnection<GitLabRepoConnection
|
||||
this.validateMREvent(event);
|
||||
const orgRepoName = event.project.path_with_namespace;
|
||||
const content = `**${event.user.username}** closed MR [${orgRepoName}#${event.object_attributes.iid}](${event.object_attributes.url}): "${event.object_attributes.title}"`;
|
||||
await this.as.botIntent.sendEvent(this.roomId, {
|
||||
await this.intent.sendEvent(this.roomId, {
|
||||
msgtype: "m.notice",
|
||||
body: content,
|
||||
formatted_body: md.renderInline(content),
|
||||
@ -481,7 +579,7 @@ export class GitLabRepoConnection extends CommandConnection<GitLabRepoConnection
|
||||
this.validateMREvent(event);
|
||||
const orgRepoName = event.project.path_with_namespace;
|
||||
const content = `**${event.user.username}** merged MR [${orgRepoName}#${event.object_attributes.iid}](${event.object_attributes.url}): "${event.object_attributes.title}"`;
|
||||
await this.as.botIntent.sendEvent(this.roomId, {
|
||||
await this.intent.sendEvent(this.roomId, {
|
||||
msgtype: "m.notice",
|
||||
body: content,
|
||||
formatted_body: md.renderInline(content),
|
||||
@ -513,7 +611,7 @@ export class GitLabRepoConnection extends CommandConnection<GitLabRepoConnection
|
||||
// Nothing changed, drop it.
|
||||
return;
|
||||
}
|
||||
await this.as.botIntent.sendEvent(this.roomId, {
|
||||
await this.intent.sendEvent(this.roomId, {
|
||||
msgtype: "m.notice",
|
||||
body: content,
|
||||
formatted_body: md.renderInline(content),
|
||||
@ -532,7 +630,7 @@ export class GitLabRepoConnection extends CommandConnection<GitLabRepoConnection
|
||||
}
|
||||
const url = `${event.project.homepage}/-/tree/${tagname}`;
|
||||
const content = `**${event.user_name}** pushed tag [\`${tagname}\`](${url}) for ${event.project.path_with_namespace}`;
|
||||
await this.as.botIntent.sendEvent(this.roomId, {
|
||||
await this.intent.sendEvent(this.roomId, {
|
||||
msgtype: "m.notice",
|
||||
body: content,
|
||||
formatted_body: md.renderInline(content),
|
||||
@ -571,7 +669,7 @@ export class GitLabRepoConnection extends CommandConnection<GitLabRepoConnection
|
||||
}
|
||||
}
|
||||
|
||||
await this.as.botIntent.sendEvent(this.roomId, {
|
||||
await this.intent.sendEvent(this.roomId, {
|
||||
msgtype: "m.notice",
|
||||
body: content,
|
||||
formatted_body: md.render(content),
|
||||
@ -598,7 +696,7 @@ export class GitLabRepoConnection extends CommandConnection<GitLabRepoConnection
|
||||
const message = attributes.message && ` "${attributes.message}"`;
|
||||
|
||||
const content = `**${data.user.username}** ${statement} "[${attributes.title}](${attributes.url})" for ${data.project.path_with_namespace} ${message}`;
|
||||
await this.as.botIntent.sendEvent(this.roomId, {
|
||||
await this.intent.sendEvent(this.roomId, {
|
||||
msgtype: "m.notice",
|
||||
body: content,
|
||||
formatted_body: md.renderInline(content),
|
||||
@ -615,7 +713,7 @@ export class GitLabRepoConnection extends CommandConnection<GitLabRepoConnection
|
||||
const content = `**${data.commit.author.name}** 🪄 released [${data.name}](${data.url}) for ${orgRepoName}
|
||||
|
||||
${data.description}`;
|
||||
await this.as.botIntent.sendEvent(this.roomId, {
|
||||
await this.intent.sendEvent(this.roomId, {
|
||||
msgtype: "m.notice",
|
||||
body: content,
|
||||
formatted_body: md.render(content),
|
||||
@ -652,7 +750,7 @@ ${data.description}`;
|
||||
content += "\n\n> " + 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),
|
||||
@ -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
|
||||
}
|
||||
|
@ -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<C extends IConnection = IConnection> {
|
||||
EventTypes: string[];
|
||||
ServiceCategory: string;
|
||||
provisionConnection?: (roomId: string, userId: string, data: Record<string, unknown>, opts: ProvisionConnectionOpts) => Promise<{connection: C, warning?: ConnectionWarning}>;
|
||||
createConnectionForState: (roomId: string, state: StateEvent<Record<string, unknown>>, opts: InstantiateConnectionOpts) => C|Promise<C>
|
||||
createConnectionForState: (roomId: string, state: StateEvent<Record<string, unknown>>, opts: InstantiateConnectionOpts) => C|Promise<C>;
|
||||
}
|
||||
|
||||
export const ConnectionDeclarations: Array<ConnectionDeclaration> = [];
|
||||
|
||||
export interface InstantiateConnectionOpts {
|
||||
as: Appservice,
|
||||
intent: Intent,
|
||||
config: BridgeConfig,
|
||||
tokenStore: UserTokenStore,
|
||||
commentProcessor: CommentProcessor,
|
||||
|
@ -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<JiraProjectConnectionState> implements IConnection {
|
||||
|
||||
|
||||
static readonly CanonicalEventType = "uk.half-shot.matrix-hookshot.jira.project";
|
||||
static readonly LegacyCanonicalEventType = "uk.half-shot.matrix-github.jira.project";
|
||||
|
||||
@ -106,47 +107,53 @@ export class JiraProjectConnection extends CommandConnection<JiraProjectConnecti
|
||||
static botCommands: BotCommands;
|
||||
static helpMessage: (cmdPrefix?: string) => MatrixMessageContent;
|
||||
|
||||
static async provisionConnection(roomId: string, userId: string, data: Record<string, unknown>, {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<string, unknown>, {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 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);
|
||||
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;
|
||||
}
|
||||
} 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<Record<string, unknown>>, {config, as, tokenStore}: InstantiateConnectionOpts) {
|
||||
static createConnectionForState(roomId: string, state: StateEvent<Record<string, unknown>>, {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() {
|
||||
@ -199,19 +206,25 @@ export class JiraProjectConnection extends CommandConnection<JiraProjectConnecti
|
||||
*/
|
||||
private projectUrl?: URL;
|
||||
|
||||
constructor(roomId: string,
|
||||
private readonly grantChecker: GrantChecker<{url: string}>;
|
||||
|
||||
constructor(
|
||||
roomId: string,
|
||||
private readonly as: Appservice,
|
||||
private readonly intent: Intent,
|
||||
state: JiraProjectConnectionState,
|
||||
stateKey: string,
|
||||
private readonly tokenStore: UserTokenStore,) {
|
||||
private readonly tokenStore: UserTokenStore
|
||||
) {
|
||||
super(
|
||||
roomId,
|
||||
stateKey,
|
||||
JiraProjectConnection.CanonicalEventType,
|
||||
state,
|
||||
as.botClient,
|
||||
intent.underlyingClient,
|
||||
JiraProjectConnection.botCommands,
|
||||
JiraProjectConnection.helpMessage,
|
||||
["jira"],
|
||||
"!jira",
|
||||
"jira"
|
||||
);
|
||||
@ -222,7 +235,7 @@ export class JiraProjectConnection extends CommandConnection<JiraProjectConnecti
|
||||
} 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) {
|
||||
@ -233,6 +246,12 @@ export class JiraProjectConnection extends CommandConnection<JiraProjectConnecti
|
||||
return validateJiraConnectionState(content);
|
||||
}
|
||||
|
||||
public ensureGrant(sender?: string) {
|
||||
return this.grantChecker.assertConnectionGranted(this.roomId, {
|
||||
url: this.state.url,
|
||||
}, sender);
|
||||
}
|
||||
|
||||
public async onJiraIssueCreated(data: JiraIssueEvent) {
|
||||
// NOTE This is the only event type that shouldn't be skipped if the state object is missing,
|
||||
// for backwards compatibility with issue creation having been the only supported Jira event type,
|
||||
@ -248,7 +267,7 @@ export class JiraProjectConnection extends CommandConnection<JiraProjectConnecti
|
||||
}
|
||||
const url = generateJiraWebLinkFromIssue(data.issue);
|
||||
const content = `${creator.displayName} created a new JIRA issue [${data.issue.key}](${url}): "${data.issue.fields.summary}"`;
|
||||
await this.as.botIntent.sendEvent(this.roomId, {
|
||||
await this.intent.sendEvent(this.roomId, {
|
||||
msgtype: "m.notice",
|
||||
body: content,
|
||||
formatted_body: md.renderInline(content),
|
||||
@ -262,14 +281,13 @@ export class JiraProjectConnection extends CommandConnection<JiraProjectConnecti
|
||||
service: "jira",
|
||||
eventType: JiraProjectConnection.CanonicalEventType,
|
||||
type: "JiraProject",
|
||||
// TODO: Add ability to configure the bot per connnection type.
|
||||
botUserId: botUserId,
|
||||
}
|
||||
}
|
||||
|
||||
public getProvisionerDetails(): JiraProjectResponseItem {
|
||||
return {
|
||||
...JiraProjectConnection.getProvisionerDetails(this.as.botUserId),
|
||||
...JiraProjectConnection.getProvisionerDetails(this.intent.userId),
|
||||
id: this.connectionId,
|
||||
config: {
|
||||
...this.state,
|
||||
@ -313,7 +331,7 @@ export class JiraProjectConnection extends CommandConnection<JiraProjectConnecti
|
||||
|
||||
const allProjects: JiraProjectConnectionProjectTarget[] = [];
|
||||
try {
|
||||
for await (const project of resClient.getAllProjects()) {
|
||||
for await (const project of resClient.getAllProjects(filters.search)) {
|
||||
allProjects.push({
|
||||
state: {
|
||||
id: project.id,
|
||||
@ -351,7 +369,7 @@ export class JiraProjectConnection extends CommandConnection<JiraProjectConnecti
|
||||
content += `\n - ` + changes.join(`\n - `);
|
||||
}
|
||||
|
||||
await this.as.botIntent.sendEvent(this.roomId, {
|
||||
await this.intent.sendEvent(this.roomId, {
|
||||
msgtype: "m.notice",
|
||||
body: content,
|
||||
formatted_body: md.renderInline(content),
|
||||
@ -375,7 +393,7 @@ export class JiraProjectConnection extends CommandConnection<JiraProjectConnecti
|
||||
(this.projectKey && this.projectUrl ? ` for project [${this.projectKey}](${this.projectUrl})` : "") +
|
||||
`: [${data.version.name}](${url}) (_${data.version.description}_)`;
|
||||
|
||||
await this.as.botIntent.sendEvent(this.roomId, {
|
||||
await this.intent.sendEvent(this.roomId, {
|
||||
msgtype: "m.notice",
|
||||
body: content,
|
||||
formatted_body: md.renderInline(content),
|
||||
@ -441,7 +459,7 @@ export class JiraProjectConnection extends CommandConnection<JiraProjectConnecti
|
||||
|
||||
const link = generateJiraWebLinkFromIssue({self: this.projectUrl?.toString() || result.self, key: result.key as string});
|
||||
const content = `Created JIRA issue ${result.key}: [${link}](${link})`;
|
||||
return this.as.botIntent.sendEvent(this.roomId,{
|
||||
return this.intent.sendEvent(this.roomId,{
|
||||
msgtype: "m.notice",
|
||||
body: content,
|
||||
formatted_body: md.render(content),
|
||||
@ -465,7 +483,7 @@ export class JiraProjectConnection extends CommandConnection<JiraProjectConnecti
|
||||
}
|
||||
|
||||
const content = `Issue types: ${(result.issueTypes || []).map((t) => 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<JiraProjectConnecti
|
||||
|
||||
public async onRemove() {
|
||||
log.info(`Removing ${this.toString()} for ${this.roomId}`);
|
||||
await this.grantChecker.ungrantConnection(this.roomId, {
|
||||
url: this.state.url,
|
||||
});
|
||||
// Do a sanity check that the event exists.
|
||||
try {
|
||||
await this.as.botClient.getRoomStateEvent(this.roomId, JiraProjectConnection.CanonicalEventType, this.stateKey);
|
||||
await this.as.botClient.sendStateEvent(this.roomId, JiraProjectConnection.CanonicalEventType, this.stateKey, { disabled: true });
|
||||
await this.intent.underlyingClient.getRoomStateEvent(this.roomId, JiraProjectConnection.CanonicalEventType, this.stateKey);
|
||||
await this.intent.underlyingClient.sendStateEvent(this.roomId, JiraProjectConnection.CanonicalEventType, this.stateKey, { disabled: true });
|
||||
} catch (ex) {
|
||||
await this.as.botClient.getRoomStateEvent(this.roomId, JiraProjectConnection.LegacyCanonicalEventType, this.stateKey);
|
||||
await this.as.botClient.sendStateEvent(this.roomId, JiraProjectConnection.LegacyCanonicalEventType, this.stateKey, { disabled: true });
|
||||
await this.intent.underlyingClient.getRoomStateEvent(this.roomId, JiraProjectConnection.LegacyCanonicalEventType, this.stateKey);
|
||||
await this.intent.underlyingClient.sendStateEvent(this.roomId, JiraProjectConnection.LegacyCanonicalEventType, this.stateKey, { disabled: true });
|
||||
}
|
||||
}
|
||||
|
||||
@ -513,7 +534,7 @@ export class JiraProjectConnection extends CommandConnection<JiraProjectConnecti
|
||||
if (!validatedConfig.id) {
|
||||
await this.updateProjectId(validatedConfig, userId);
|
||||
}
|
||||
await this.as.botClient.sendStateEvent(this.roomId, JiraProjectConnection.CanonicalEventType, this.stateKey, validatedConfig);
|
||||
await this.intent.underlyingClient.sendStateEvent(this.roomId, JiraProjectConnection.CanonicalEventType, this.stateKey, validatedConfig);
|
||||
this.state = validatedConfig;
|
||||
}
|
||||
|
||||
|
@ -3,7 +3,6 @@ import { BotCommands, botCommand, compileBotCommands, HelpFunction } from "../Bo
|
||||
import { CommandConnection } from "./CommandConnection";
|
||||
import { GenericHookConnection, GitHubRepoConnection, JiraProjectConnection, JiraProjectConnectionState } from ".";
|
||||
import { CommandError } from "../errors";
|
||||
import { v4 as uuid } from "uuid";
|
||||
import { BridgePermissionLevel } from "../Config/Config";
|
||||
import markdown from "markdown-it";
|
||||
import { FigmaFileConnection } from "./FigmaFileConnection";
|
||||
@ -14,6 +13,7 @@ import { AdminRoom } from "../AdminRoom";
|
||||
import { GitLabRepoConnection } from "./GitlabRepo";
|
||||
import { IConnection, IConnectionState, ProvisionConnectionOpts } from "./IConnection";
|
||||
import { ApiError, Logger } from "matrix-appservice-bridge";
|
||||
import { Intent } from "matrix-bot-sdk";
|
||||
const md = new markdown();
|
||||
const log = new Logger("SetupConnection");
|
||||
|
||||
@ -34,35 +34,40 @@ export class SetupConnection extends CommandConnection {
|
||||
return this.provisionOpts.as;
|
||||
}
|
||||
|
||||
private get intent() {
|
||||
return this.provisionOpts.intent;
|
||||
}
|
||||
|
||||
private get client() {
|
||||
return this.intent.underlyingClient;
|
||||
}
|
||||
|
||||
protected validateConnectionState(content: unknown) {
|
||||
log.warn("SetupConnection has no state to be validated");
|
||||
return content as IConnectionState;
|
||||
}
|
||||
|
||||
constructor(public readonly roomId: string,
|
||||
constructor(
|
||||
readonly roomId: string,
|
||||
readonly prefix: string,
|
||||
readonly serviceTypes: string[],
|
||||
readonly helpCategories: string[],
|
||||
private readonly provisionOpts: ProvisionConnectionOpts,
|
||||
private readonly getOrCreateAdminRoom: (userId: string) => Promise<AdminRoom>,
|
||||
private readonly pushConnections: (...connections: IConnection[]) => void) {
|
||||
private readonly getOrCreateAdminRoom: (intent: Intent, userId: string) => Promise<AdminRoom>,
|
||||
private readonly pushConnections: (...connections: IConnection[]) => void,
|
||||
) {
|
||||
super(
|
||||
roomId,
|
||||
"",
|
||||
"",
|
||||
// TODO Consider storing room-specific config in state.
|
||||
{},
|
||||
provisionOpts.as.botClient,
|
||||
provisionOpts.intent.underlyingClient,
|
||||
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" : "",
|
||||
];
|
||||
helpCategories,
|
||||
prefix,
|
||||
);
|
||||
this.includeTitlesInHelp = false;
|
||||
}
|
||||
|
||||
@ -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.");
|
||||
}
|
||||
}
|
||||
|
@ -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<string, string>);
|
||||
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}`;
|
||||
|
39
src/Github/GrantChecker.ts
Normal file
39
src/Github/GrantChecker.ts
Normal file
@ -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<GitHubGrantConnectionId> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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,
|
||||
|
40
src/Gitlab/GrantChecker.ts
Normal file
40
src/Gitlab/GrantChecker.ts
Normal file
@ -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<GitLabGrantConnectionId> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
@ -221,6 +221,8 @@ export interface ProjectHook extends ProjectHookOpts {
|
||||
}
|
||||
|
||||
export interface SimpleProject {
|
||||
avatar_url?: string;
|
||||
description?: string;
|
||||
id: string;
|
||||
name: string;
|
||||
path: string;
|
||||
|
@ -1,19 +1,32 @@
|
||||
export class HookFilter<T extends string> {
|
||||
static convertIgnoredHooksToEnabledHooks<T extends string>(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));
|
||||
}
|
||||
}
|
@ -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;
|
||||
|
@ -33,7 +33,7 @@ export abstract class HookshotJiraApi extends JiraApi {
|
||||
return this.res;
|
||||
}
|
||||
|
||||
public abstract getAllProjects(): AsyncIterable<JiraProject>;
|
||||
public abstract getAllProjects(query?: string, maxResults?: number): AsyncIterable<JiraProject>;
|
||||
|
||||
protected async apiRequest<T>(path: string, method?: Method, data?: undefined): Promise<T>
|
||||
protected async apiRequest<T, R>(path: string, method: Method, data?: R): Promise<T> {
|
||||
|
33
src/Jira/GrantChecker.ts
Normal file
33
src/Jira/GrantChecker.ts
Normal file
@ -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<JiraGrantConnectionId> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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<JiraProject> {
|
||||
async * getAllProjects(query?: string, maxResults = 10): AsyncIterable<JiraProject> {
|
||||
let response;
|
||||
let startAt = 0;
|
||||
do {
|
||||
response = await this.apiRequest<JiraCloudProjectSearchResponse>(`/rest/api/3/project/search?startAt=${startAt}`);
|
||||
const params = qs.stringify({
|
||||
startAt,
|
||||
maxResults,
|
||||
query
|
||||
});
|
||||
response = await this.apiRequest<JiraCloudProjectSearchResponse>(`/rest/api/3/project/search?${params}`);
|
||||
yield* response.values;
|
||||
startAt += response.maxResults;
|
||||
} while(!response.isLast)
|
||||
|
@ -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<JiraProject> {
|
||||
async * getAllProjects(search?: string): AsyncIterable<JiraProject> {
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
217
src/Managers/BotUsersManager.ts
Normal file
217
src/Managers/BotUsersManager.ts
Normal file
@ -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<string, BotUser>();
|
||||
|
||||
// Map of room ID to set of bot users in the room
|
||||
private _botsInRooms = new Map<string, Set<BotUser>>();
|
||||
|
||||
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<void> {
|
||||
await this.ensureProfiles();
|
||||
await this.getJoinedRooms();
|
||||
}
|
||||
|
||||
private async ensureProfiles(): Promise<void> {
|
||||
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<void> {
|
||||
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<BotUser>();
|
||||
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<BotUser>();
|
||||
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<BotUser>())
|
||||
.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));
|
||||
}
|
||||
}
|
@ -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<IMatrixSendMessage>("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})`);
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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<unknown>;
|
||||
if (msg.for && msg.for !== this.myUuid) {
|
||||
@ -63,7 +62,7 @@ export class RedisMQ extends EventEmitter implements MessageQueue {
|
||||
|
||||
public async push<T>(message: MessageQueueMessage<T>, single = false) {
|
||||
if (!message.messageId) {
|
||||
message.messageId = uuid();
|
||||
message.messageId = randomUUID();
|
||||
}
|
||||
if (single) {
|
||||
const recipient = await this.getRecipientForEvent(message.eventName);
|
||||
|
@ -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<string, IssuesGetResponseData> = new Map();
|
||||
@ -9,6 +10,7 @@ export class MemoryStorageProvider extends MSP implements IBridgeStorageProvider
|
||||
private reviewData: Map<string, string> = new Map();
|
||||
private figmaCommentIds: Map<string, string> = new Map();
|
||||
private widgetSessions: Map<string, ProvisionSession> = new Map();
|
||||
private storedFiles = new QuickLRU<string, string>({ 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<string|null> {
|
||||
return this.storedFiles.get(key) ?? null;
|
||||
}
|
||||
|
||||
public async setStoredTempFile(key: string, value: string) {
|
||||
this.storedFiles.set(key, value);
|
||||
}
|
||||
}
|
||||
|
@ -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<void> {
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -12,4 +12,6 @@ export interface IBridgeStorageProvider extends IAppserviceStorageProvider, ISto
|
||||
getPRReviewData(repo: string, issueNumber: string, scope?: string): Promise<any|null>;
|
||||
setFigmaCommentEventId(roomId: string, figmaCommentId: string, eventId: string): Promise<void>;
|
||||
getFigmaCommentEventId(roomId: string, figmaCommentId: string): Promise<string|null>;
|
||||
getStoredTempFile(key: string): Promise<string|null>;
|
||||
setStoredTempFile(key: string, value: string): Promise<void>;
|
||||
}
|
@ -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<Emitter> {
|
||||
}
|
||||
|
||||
public async clearUserToken(type: TokenType, userId: string, instanceUrl?: string): Promise<boolean> {
|
||||
if (!AllowedTokenTypes.includes(type)) {
|
||||
throw Error('Unknown token type');
|
||||
}
|
||||
const key = tokenKey(type, userId, false, instanceUrl);
|
||||
const obj = await this.intent.underlyingClient.getSafeAccountData<StoredTokenData|DeletedTokenData>(key);
|
||||
if (!obj || "deleted" in obj) {
|
||||
@ -254,7 +257,7 @@ export class UserTokenStore extends TypedEmitter<Emitter> {
|
||||
}
|
||||
|
||||
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),
|
||||
|
@ -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,30 +186,37 @@ export class Webhooks extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
public async onGitHubGetOauth(req: Request<unknown, unknown, unknown, {error?: string, error_description?: string, code?: string, state?: string}> , res: Response) {
|
||||
log.info(`Got new oauth request`, { state: req.query.state });
|
||||
public async onGitHubGetOauth(req: Request<unknown, unknown, unknown, {error?: string, error_description?: string, code?: string, state?: string, setup_action?: 'install'}> , 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(`<p>Bridge is not configured with OAuth support</p>`);
|
||||
throw new ApiError('Bridge is not configured with OAuth support', ErrCode.DisabledFeature);
|
||||
}
|
||||
if (req.query.error) {
|
||||
return res.status(500).send(`<p><b>GitHub Error</b>: ${req.query.error} ${req.query.error_description}</p>`);
|
||||
throw new ApiError(`GitHub Error: ${req.query.error} ${req.query.error_description}`, ErrCode.Unknown);
|
||||
}
|
||||
if (!req.query.state) {
|
||||
return res.status(400).send(`<p>Missing state</p>`);
|
||||
if (setup_action !== 'install') {
|
||||
if (!state) {
|
||||
throw new ApiError(`Missing state`, ErrCode.BadValue);
|
||||
}
|
||||
if (!req.query.code) {
|
||||
return res.status(400).send(`<p>Missing code</p>`);
|
||||
throw new ApiError(`Missing code`, ErrCode.BadValue);
|
||||
}
|
||||
const exists = await this.queue.pushWait<OAuthRequest, boolean>({
|
||||
eventName: "github.oauth.response",
|
||||
sender: "GithubWebhooks",
|
||||
data: {
|
||||
state: req.query.state,
|
||||
state,
|
||||
},
|
||||
});
|
||||
if (!exists) {
|
||||
return res.status(404).send(`<p>Could not find user which authorised this request. Has it timed out?</p>`);
|
||||
throw new ApiError(`Could not find user which authorised this request. Has it timed out?`, undefined, 404);
|
||||
}
|
||||
const accessTokenUrl = GithubInstance.generateOAuthUrl(this.config.github.baseUrl, "access_token", {
|
||||
client_id: this.config.github.oauth.client_id,
|
||||
@ -220,17 +228,35 @@ export class Webhooks extends EventEmitter {
|
||||
const accessTokenRes = await axios.post(accessTokenUrl);
|
||||
const result = qs.parse(accessTokenRes.data) as GitHubOAuthTokenResponse|{error: string, error_description: string, error_uri: string};
|
||||
if ("error" in result) {
|
||||
return res.status(500).send(`<p><b>GitHub Error</b>: ${result.error} ${result.error_description}</p>`);
|
||||
throw new ApiError(`GitHub Error: ${result.error} ${result.error_description}`, ErrCode.Unknown);
|
||||
}
|
||||
await this.queue.push<GitHubOAuthTokenResponse>({
|
||||
eventName: "github.oauth.tokens",
|
||||
sender: "GithubWebhooks",
|
||||
data: { ...result, state: req.query.state as string },
|
||||
});
|
||||
return res.send(`<p> Your account has been bridged </p>`);
|
||||
} else if (oauthUrl) {
|
||||
// App install.
|
||||
oauthUrl.searchParams.set('oauth-kind', 'organisation');
|
||||
}
|
||||
} catch (ex) {
|
||||
if (ex instanceof ApiError) {
|
||||
if (oauthUrl) {
|
||||
oauthUrl?.searchParams.set('error', ex.error);
|
||||
oauthUrl?.searchParams.set('errcode', ex.errcode);
|
||||
} else {
|
||||
return res.status(ex.statusCode).send(ex.message);
|
||||
}
|
||||
} else {
|
||||
log.error("Failed to handle oauth request:", ex);
|
||||
return res.status(500).send(`<p>Encountered an error handing oauth request</p>`);
|
||||
return res.status(500).send('Failed to handle oauth request');
|
||||
}
|
||||
}
|
||||
if (oauthUrl) {
|
||||
// If we're serving widgets, do something prettier.
|
||||
return res.redirect(oauthUrl.toString());
|
||||
} else {
|
||||
return res.send(`<p> Your account has been bridged </p>`);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -3,24 +3,34 @@ import { AdminRoom } from "../AdminRoom";
|
||||
import { Logger } from "matrix-appservice-bridge";
|
||||
import { ApiError, ErrCode } from "../api";
|
||||
import { BridgeConfig } from "../Config/Config";
|
||||
import { GetConnectionsForServiceResponse } from "./BridgeWidgetInterface";
|
||||
import { GetAuthPollResponse, GetAuthResponse, GetConnectionsForServiceResponse } from "./BridgeWidgetInterface";
|
||||
import { ProvisioningApi, ProvisioningRequest } from "matrix-appservice-bridge";
|
||||
import { IBridgeStorageProvider } from "../Stores/StorageProvider";
|
||||
import { ConnectionManager } from "../ConnectionManager";
|
||||
import BotUsersManager, {BotUser} from "../Managers/BotUsersManager";
|
||||
import { assertUserPermissionsInRoom, GetConnectionsResponseItem } from "../provisioning/api";
|
||||
import { Intent, PowerLevelsEvent } from "matrix-bot-sdk";
|
||||
import { Appservice, PowerLevelsEvent } from "matrix-bot-sdk";
|
||||
import { GoNebMigrator } from "./GoNebMigrator";
|
||||
import { StatusCodes } from "http-status-codes";
|
||||
import { GithubInstance } from '../Github/GithubInstance';
|
||||
import { AllowedTokenTypes, TokenType, UserTokenStore } from '../UserTokenStore';
|
||||
|
||||
const log = new Logger("BridgeWidgetApi");
|
||||
|
||||
export class BridgeWidgetApi {
|
||||
private readonly api: ProvisioningApi;
|
||||
private readonly goNebMigrator?: GoNebMigrator;
|
||||
|
||||
constructor(
|
||||
private adminRooms: Map<string, AdminRoom>,
|
||||
private readonly config: BridgeConfig,
|
||||
storageProvider: IBridgeStorageProvider,
|
||||
expressApp: Application,
|
||||
private readonly connMan: ConnectionManager,
|
||||
private readonly intent: Intent,
|
||||
private readonly botUsersManager: BotUsersManager,
|
||||
private readonly as: Appservice,
|
||||
private readonly tokenStore: UserTokenStore,
|
||||
private readonly github?: GithubInstance,
|
||||
) {
|
||||
this.api = new ProvisioningApi(
|
||||
storageProvider,
|
||||
@ -52,6 +62,45 @@ export class BridgeWidgetApi {
|
||||
this.api.addRoute("patch", '/v1/:roomId/connections/:connectionId', wrapHandler(this.updateConnection));
|
||||
this.api.addRoute("delete", '/v1/:roomId/connections/:connectionId', wrapHandler(this.deleteConnection));
|
||||
this.api.addRoute("get", '/v1/targets/:type', wrapHandler(this.getConnectionTargets));
|
||||
this.api.addRoute('get', '/v1/service/:service/auth', wrapHandler(this.getAuth));
|
||||
this.api.addRoute('get', '/v1/service/:service/auth/:state', wrapHandler(this.getAuthPoll));
|
||||
this.api.addRoute('post', '/v1/service/:service/auth/logout', wrapHandler(this.postAuthLogout));
|
||||
|
||||
if (this.config.goNebMigrator) {
|
||||
this.goNebMigrator = new GoNebMigrator(
|
||||
this.config.goNebMigrator.apiUrl,
|
||||
this.config.goNebMigrator.serviceIds,
|
||||
);
|
||||
}
|
||||
|
||||
this.api.addRoute("get", "/v1/:roomId/goNebConnections", wrapHandler(this.getGoNebConnections));
|
||||
}
|
||||
|
||||
private async getGoNebConnections(req: ProvisioningRequest, res: Response) {
|
||||
if (!this.goNebMigrator) {
|
||||
res.status(StatusCodes.NO_CONTENT).send();
|
||||
return;
|
||||
}
|
||||
|
||||
const roomId = req.params.roomId;
|
||||
|
||||
if (!req.userId) {
|
||||
throw Error('Cannot get connections without a valid userId');
|
||||
}
|
||||
|
||||
const botUser = this.getBotUserInRoom(roomId);
|
||||
await assertUserPermissionsInRoom(req.userId, roomId, "read", botUser.intent);
|
||||
const connections = await this.goNebMigrator.getConnectionsForRoom(roomId, req.userId);
|
||||
|
||||
res.send(connections);
|
||||
}
|
||||
|
||||
private getBotUserInRoom(roomId: string, serviceType?: string): BotUser {
|
||||
const botUser = this.botUsersManager.getBotUserInRoom(roomId, serviceType);
|
||||
if (!botUser) {
|
||||
throw new ApiError("Bot is not joined to the room.", ErrCode.NotInRoom);
|
||||
}
|
||||
return botUser;
|
||||
}
|
||||
|
||||
private async getRoomFromRequest(req: ProvisioningRequest): Promise<AdminRoom> {
|
||||
@ -85,23 +134,31 @@ export class BridgeWidgetApi {
|
||||
}
|
||||
|
||||
private async getServiceConfig(req: ProvisioningRequest, res: Response<Record<string, unknown>>) {
|
||||
// GitHub is a special case because it depends on live config.
|
||||
if (req.params.service === 'github') {
|
||||
res.send(this.config.github?.publicConfig(this.github));
|
||||
} else {
|
||||
res.send(this.config.getPublicConfigForService(req.params.service));
|
||||
}
|
||||
}
|
||||
|
||||
private async getConnectionsForRequest(req: ProvisioningRequest) {
|
||||
if (!req.userId) {
|
||||
throw Error('Cannot get connections without a valid userId');
|
||||
}
|
||||
await assertUserPermissionsInRoom(req.userId, req.params.roomId as string, "read", this.intent);
|
||||
const allConnections = this.connMan.getAllConnectionsForRoom(req.params.roomId as string);
|
||||
const powerlevel = new PowerLevelsEvent({content: await this.intent.underlyingClient.getRoomStateEvent(req.params.roomId, "m.room.power_levels", "")});
|
||||
const roomId = req.params.roomId;
|
||||
const serviceType = req.params.service;
|
||||
|
||||
const botUser = this.getBotUserInRoom(roomId, serviceType);
|
||||
await assertUserPermissionsInRoom(req.userId, roomId, "read", botUser.intent);
|
||||
const allConnections = this.connMan.getAllConnectionsForRoom(roomId);
|
||||
const powerlevel = new PowerLevelsEvent({content: await botUser.intent.underlyingClient.getRoomStateEvent(roomId, "m.room.power_levels", "")});
|
||||
const serviceFilter = req.params.service;
|
||||
const connections = allConnections.map(c => c.getProvisionerDetails?.(true))
|
||||
.filter(c => !!c)
|
||||
// If we have a service filter.
|
||||
.filter(c => typeof serviceFilter !== "string" || c?.service === serviceFilter) as GetConnectionsResponseItem[];
|
||||
const userPl = powerlevel.content.users?.[req.userId] || powerlevel.defaultUserLevel;
|
||||
|
||||
for (const c of connections) {
|
||||
const requiredPl = Math.max(powerlevel.content.events?.[c.type] || 0, powerlevel.defaultStateEventLevel);
|
||||
c.canEdit = userPl >= requiredPl;
|
||||
@ -112,7 +169,7 @@ export class BridgeWidgetApi {
|
||||
|
||||
return {
|
||||
connections,
|
||||
canEdit: userPl >= powerlevel.defaultUserLevel
|
||||
canEdit: userPl >= powerlevel.defaultStateEventLevel,
|
||||
};
|
||||
}
|
||||
|
||||
@ -128,13 +185,22 @@ export class BridgeWidgetApi {
|
||||
if (!req.userId) {
|
||||
throw Error('Cannot get connections without a valid userId');
|
||||
}
|
||||
await assertUserPermissionsInRoom(req.userId, req.params.roomId as string, "write", this.intent);
|
||||
const roomId = req.params.roomId;
|
||||
const eventType = req.params.type;
|
||||
const connectionType = this.connMan.getConnectionTypeForEventType(eventType);
|
||||
if (!connectionType) {
|
||||
throw new ApiError("Unknown event type", ErrCode.NotFound);
|
||||
}
|
||||
const serviceType = connectionType.ServiceCategory;
|
||||
|
||||
const botUser = this.getBotUserInRoom(roomId, serviceType);
|
||||
await assertUserPermissionsInRoom(req.userId, roomId, "write", botUser.intent);
|
||||
try {
|
||||
if (!req.body || typeof req.body !== "object") {
|
||||
throw new ApiError("A JSON body must be provided", ErrCode.BadValue);
|
||||
}
|
||||
this.connMan.validateCommandPrefix(req.params.roomId, req.body);
|
||||
const result = await this.connMan.provisionConnection(req.params.roomId, req.userId, req.params.type, req.body);
|
||||
const result = await this.connMan.provisionConnection(roomId, botUser.intent, req.userId, connectionType, req.body);
|
||||
if (!result.connection.getProvisionerDetails) {
|
||||
throw new Error('Connection supported provisioning but not getProvisionerDetails');
|
||||
}
|
||||
@ -152,15 +218,20 @@ export class BridgeWidgetApi {
|
||||
if (!req.userId) {
|
||||
throw Error('Cannot get connections without a valid userId');
|
||||
}
|
||||
await assertUserPermissionsInRoom(req.userId, req.params.roomId as string, "write", this.intent);
|
||||
const connection = this.connMan.getConnectionById(req.params.roomId as string, req.params.connectionId as string);
|
||||
const roomId = req.params.roomId;
|
||||
const serviceType = req.params.type;
|
||||
const connectionId = req.params.connectionId;
|
||||
|
||||
const botUser = this.getBotUserInRoom(roomId, serviceType);
|
||||
await assertUserPermissionsInRoom(req.userId, roomId, "write", botUser.intent);
|
||||
const connection = this.connMan.getConnectionById(roomId, connectionId);
|
||||
if (!connection) {
|
||||
throw new ApiError("Connection does not exist", ErrCode.NotFound);
|
||||
}
|
||||
if (!connection.provisionerUpdateConfig || !connection.getProvisionerDetails) {
|
||||
throw new ApiError("Connection type does not support updates", ErrCode.UnsupportedOperation);
|
||||
}
|
||||
this.connMan.validateCommandPrefix(req.params.roomId, req.body, connection);
|
||||
this.connMan.validateCommandPrefix(roomId, req.body, connection);
|
||||
await connection.provisionerUpdateConfig(req.userId, req.body);
|
||||
res.send(connection.getProvisionerDetails(true));
|
||||
}
|
||||
@ -169,9 +240,12 @@ export class BridgeWidgetApi {
|
||||
if (!req.userId) {
|
||||
throw Error('Cannot get connections without a valid userId');
|
||||
}
|
||||
const roomId = req.params.roomId as string;
|
||||
const connectionId = req.params.connectionId as string;
|
||||
await assertUserPermissionsInRoom(req.userId, roomId, "write", this.intent);
|
||||
const roomId = req.params.roomId;
|
||||
const serviceType = req.params.type;
|
||||
const connectionId = req.params.connectionId;
|
||||
|
||||
const botUser = this.getBotUserInRoom(roomId, serviceType);
|
||||
await assertUserPermissionsInRoom(req.userId, roomId, "write", botUser.intent);
|
||||
const connection = this.connMan.getConnectionById(roomId, connectionId);
|
||||
if (!connection) {
|
||||
throw new ApiError("Connection does not exist", ErrCode.NotFound);
|
||||
@ -191,4 +265,109 @@ export class BridgeWidgetApi {
|
||||
const connections = await this.connMan.getConnectionTargets(req.userId, type, req.query);
|
||||
res.send(connections);
|
||||
}
|
||||
|
||||
|
||||
private async getAuth(req: ProvisioningRequest, res: Response<GetAuthResponse>) {
|
||||
if (!req.userId) {
|
||||
throw Error('Expected userId on request');
|
||||
}
|
||||
const service = req.params.service;
|
||||
if (!service) {
|
||||
throw Error('Expected service in parameters');
|
||||
}
|
||||
|
||||
// TODO: Should this be part of the GitHub module code.
|
||||
if (service === 'github') {
|
||||
if (!this.config.github || !this.config.github.oauth) {
|
||||
throw new ApiError('GitHub oauth is not configured', ErrCode.DisabledFeature);
|
||||
}
|
||||
|
||||
let user;
|
||||
try {
|
||||
const octokit = await this.tokenStore.getOctokitForUser(req.userId);
|
||||
if (octokit !== null) {
|
||||
const me = await octokit.users.getAuthenticated();
|
||||
user = {
|
||||
name: me.data.login,
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
// Need to authenticate
|
||||
}
|
||||
|
||||
if (user) {
|
||||
return res.json({
|
||||
authenticated: true,
|
||||
user
|
||||
});
|
||||
} else {
|
||||
const state = this.tokenStore.createStateForOAuth(req.userId);
|
||||
const authUrl = GithubInstance.generateOAuthUrl(
|
||||
this.config.github.baseUrl,
|
||||
'authorize',
|
||||
{
|
||||
state,
|
||||
client_id: this.config.github.oauth.client_id,
|
||||
redirect_uri: this.config.github.oauth.redirect_uri,
|
||||
}
|
||||
);
|
||||
return res.json({
|
||||
authenticated: false,
|
||||
stateId: state,
|
||||
authUrl
|
||||
});
|
||||
}
|
||||
} else {
|
||||
throw new ApiError('Service not found', ErrCode.NotFound);
|
||||
}
|
||||
}
|
||||
|
||||
private async getAuthPoll(req: ProvisioningRequest, res: Response<GetAuthPollResponse>) {
|
||||
if (!req.userId) {
|
||||
throw Error('Expected userId on request');
|
||||
}
|
||||
const { service, state } = req.params;
|
||||
|
||||
if (!service) {
|
||||
throw Error('Expected service in parameters');
|
||||
}
|
||||
|
||||
// N.B. Service isn't really used.
|
||||
const stateUserId = this.tokenStore.getUserIdForOAuthState(state);
|
||||
|
||||
if (!stateUserId || req.userId !== stateUserId) {
|
||||
// If the state isn't found then either the state has been completed or the key is wrong.
|
||||
// We don't actually know, so we assume the sender knows what they are doing.
|
||||
res.send({
|
||||
state: 'complete',
|
||||
});
|
||||
return;
|
||||
}
|
||||
res.send({
|
||||
state: 'waiting',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
private async postAuthLogout(req: ProvisioningRequest, res: Response<{ok: true}>) {
|
||||
if (!req.userId) {
|
||||
throw Error('Expected userId on request');
|
||||
}
|
||||
const { service } = req.params;
|
||||
|
||||
if (!service) {
|
||||
throw Error('Expected service in parameters');
|
||||
}
|
||||
|
||||
if (AllowedTokenTypes.includes(service)) {
|
||||
const result = await this.tokenStore.clearUserToken(service as TokenType, req.userId);
|
||||
if (result) {
|
||||
res.send({ok: true});
|
||||
} else {
|
||||
throw new ApiError("You are not logged in", ErrCode.NotFound);
|
||||
}
|
||||
} else {
|
||||
throw new ApiError('Service not found', ErrCode.NotFound);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -36,3 +36,24 @@ export interface GetConnectionsForServiceResponse<T extends GetConnectionsRespon
|
||||
connections: T[];
|
||||
canEdit: boolean;
|
||||
}
|
||||
|
||||
|
||||
export interface GetAuthResponseAuthenticated {
|
||||
authenticated: true;
|
||||
user: {
|
||||
name: string;
|
||||
}
|
||||
}
|
||||
|
||||
export interface GetAuthResponseUnauthenticated {
|
||||
authenticated: false;
|
||||
authUrl: string;
|
||||
stateId: string;
|
||||
}
|
||||
|
||||
|
||||
export type GetAuthResponse = GetAuthResponseAuthenticated|GetAuthResponseUnauthenticated;
|
||||
|
||||
export interface GetAuthPollResponse {
|
||||
state: 'complete'|'waiting';
|
||||
}
|
149
src/Widgets/GoNebMigrator.ts
Normal file
149
src/Widgets/GoNebMigrator.ts
Normal file
@ -0,0 +1,149 @@
|
||||
import { Logger } from "matrix-appservice-bridge";
|
||||
import axios from "axios";
|
||||
|
||||
import { FeedConnection, FeedConnectionState, GitHubRepoConnection, GitHubRepoConnectionState } from "../Connections";
|
||||
import { AllowedEvents as GitHubAllowedEvents, AllowedEventsNames as GitHubAllowedEventsNames } from "../Connections/GithubRepo";
|
||||
|
||||
const log = new Logger("GoNebMigrator");
|
||||
|
||||
interface MigratedGoNebConnection {
|
||||
goNebId: string;
|
||||
}
|
||||
|
||||
type MigratedFeed = FeedConnectionState & MigratedGoNebConnection;
|
||||
type MigratedGithub = GitHubRepoConnectionState & MigratedGoNebConnection;
|
||||
|
||||
interface MigratedConnections {
|
||||
[FeedConnection.ServiceCategory]: MigratedFeed[]|undefined,
|
||||
[GitHubRepoConnection.ServiceCategory]: MigratedGithub[]|undefined;
|
||||
}
|
||||
|
||||
interface GoNebFeedsConfig {
|
||||
[url: string]: {
|
||||
rooms: string[],
|
||||
}
|
||||
}
|
||||
|
||||
interface GoNebGithubRepos {
|
||||
[githubPath: string]: {
|
||||
Events: string[], // push, issues, pull_request, more?
|
||||
}
|
||||
}
|
||||
|
||||
interface GoNebService {
|
||||
Type: string;
|
||||
Config: any;
|
||||
}
|
||||
|
||||
interface GoNebGithubWebhookService extends GoNebService {
|
||||
Type: 'github-webhook';
|
||||
Config: {
|
||||
ClientUserID: string;
|
||||
Rooms: {
|
||||
[roomId: string]: { Repos: GoNebGithubRepos; }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export class GoNebMigrator {
|
||||
constructor(
|
||||
private apiUrl: string,
|
||||
private serviceIds?: string[],
|
||||
) {}
|
||||
|
||||
static convertFeeds(goNebFeeds: GoNebFeedsConfig): Map<string, FeedConnectionState[]> {
|
||||
const feedsPerRoom = new Map<string, FeedConnectionState[]>();
|
||||
|
||||
for (const [url, config] of Object.entries(goNebFeeds)) {
|
||||
for (const roomId of config.rooms) {
|
||||
const existing = feedsPerRoom.get(roomId) ?? [];
|
||||
existing.push({ url });
|
||||
feedsPerRoom.set(roomId, existing);
|
||||
}
|
||||
}
|
||||
|
||||
return feedsPerRoom;
|
||||
}
|
||||
|
||||
static convertGithub(roomRepos: GoNebGithubRepos): GitHubRepoConnectionState[] {
|
||||
const eventMapping: { [goNebEvent: string]: GitHubAllowedEventsNames } = {
|
||||
'pull_request': 'pull_request',
|
||||
'issues': 'issue',
|
||||
// 'push': ???
|
||||
};
|
||||
return Object.entries(roomRepos).map(([githubPath, { Events }]) => {
|
||||
const [org, repo] = githubPath.split('/');
|
||||
const enableHooks = Events.map(goNebEvent => eventMapping[goNebEvent]).filter(e => !!e);
|
||||
|
||||
return {
|
||||
org,
|
||||
repo,
|
||||
enableHooks,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
public async getConnectionsForRoom(roomId: string, userId: string): Promise<MigratedConnections> {
|
||||
const feeds: MigratedFeed[] = [];
|
||||
const github: MigratedGithub[] = [];
|
||||
|
||||
const serviceIds = [
|
||||
...(this.serviceIds ?? []),
|
||||
...['rssbot', 'github'].map(type => `${type}/${strictEncodeURIComponent(userId)}/${strictEncodeURIComponent(roomId)}`),
|
||||
];
|
||||
|
||||
for (const id of serviceIds) {
|
||||
const endpoint = this.apiUrl + (this.apiUrl.endsWith('/') ? '' : '/') + 'admin/getService';
|
||||
let obj: GoNebService;
|
||||
try {
|
||||
const res = await axios.post(endpoint, { 'Id': id });
|
||||
obj = res.data as GoNebService;
|
||||
} catch (err: unknown) {
|
||||
if (axios.isAxiosError(err)) {
|
||||
if (err.response?.status === 404) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
switch (obj.Type) {
|
||||
case 'rssbot': {
|
||||
const roomFeeds = GoNebMigrator.convertFeeds(obj.Config.feeds).get(roomId) ?? [];
|
||||
const migratedFeeds = roomFeeds.map(f => ({ ...f, goNebId: id }));
|
||||
feeds.push(...migratedFeeds);
|
||||
break;
|
||||
}
|
||||
case 'github-webhook': {
|
||||
const service = obj as GoNebGithubWebhookService;
|
||||
if (service.Config.ClientUserID === userId) {
|
||||
const roomRepos = service.Config.Rooms[roomId]?.Repos;
|
||||
if (roomRepos) {
|
||||
const githubConnections = GoNebMigrator.convertGithub(roomRepos);
|
||||
const migratedGithubs = githubConnections.map(f => ({ ...f, goNebId: id }));
|
||||
github.push(...migratedGithubs);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
log.warn(`Unrecognized go-neb service type (${obj.Type}), skipping`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
feeds,
|
||||
github,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// from https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent#encoding_for_rfc3986
|
||||
function strictEncodeURIComponent(str: string) {
|
||||
return encodeURIComponent(str)
|
||||
.replace(
|
||||
/[!'()*]/g,
|
||||
(c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`
|
||||
);
|
||||
}
|
@ -16,15 +16,31 @@ export class SetupWidget {
|
||||
return false;
|
||||
}
|
||||
|
||||
static async SetupRoomConfigWidget(roomId: string, botIntent: Intent, config: BridgeWidgetConfig): Promise<boolean> {
|
||||
if (await SetupWidget.createWidgetInRoom(roomId, botIntent, config, HookshotWidgetKind.RoomConfiguration, "hookshot_room_config")) {
|
||||
static async SetupRoomConfigWidget(roomId: string, botIntent: Intent, config: BridgeWidgetConfig, serviceTypes: string[]): Promise<boolean> {
|
||||
// If this is for a single service, scope the widget
|
||||
const serviceScope = serviceTypes.length === 1 ? serviceTypes[0] : undefined;
|
||||
if (await SetupWidget.createWidgetInRoom(
|
||||
roomId,
|
||||
botIntent,
|
||||
config,
|
||||
HookshotWidgetKind.RoomConfiguration,
|
||||
`hookshot_room_config_${config.parsedPublicUrl.host}${serviceScope ? '_' + serviceScope : ''}`,
|
||||
serviceScope,
|
||||
)) {
|
||||
await botIntent.sendText(roomId, `Please open the ${config.branding.widgetTitle} widget to set up integrations.`);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static async createWidgetInRoom(roomId: string, botIntent: Intent, config: BridgeWidgetConfig, kind: HookshotWidgetKind, stateKey: string): Promise<boolean> {
|
||||
private static async createWidgetInRoom(
|
||||
roomId: string,
|
||||
botIntent: Intent,
|
||||
config: BridgeWidgetConfig,
|
||||
kind: HookshotWidgetKind,
|
||||
stateKey: string,
|
||||
serviceScope?: string,
|
||||
): Promise<boolean> {
|
||||
log.info(`Running SetupRoomConfigWidget for ${roomId}`);
|
||||
if (!await botIntent.underlyingClient.userHasPowerLevelFor(botIntent.userId, roomId, "im.vector.modular.widgets", true)) {
|
||||
throw new CommandError("Bot lacks power level to set room state", "I do not have permission to create a widget in this room. Please promote me to an Admin/Moderator.");
|
||||
@ -53,12 +69,12 @@ export class SetupWidget {
|
||||
{
|
||||
"creatorUserId": botIntent.userId,
|
||||
"data": {
|
||||
"title": config.branding.widgetTitle
|
||||
"title": serviceScope ? serviceScope : config.branding.widgetTitle,
|
||||
},
|
||||
"id": stateKey,
|
||||
"name": config.branding.widgetTitle,
|
||||
"type": "m.custom",
|
||||
"url": new URL(`#/?kind=${kind}&roomId=$matrix_room_id&widgetId=$matrix_widget_id`, config.parsedPublicUrl).href,
|
||||
"url": new URL(`#/?kind=${kind}&roomId=$matrix_room_id&widgetId=$matrix_widget_id${serviceScope ? `&serviceScope=${serviceScope}` : ''}`, config.parsedPublicUrl).href,
|
||||
"waitForIframeLoad": true,
|
||||
}
|
||||
);
|
||||
|
114
src/grants/GrantCheck.ts
Normal file
114
src/grants/GrantCheck.ts
Normal file
@ -0,0 +1,114 @@
|
||||
import { Logger } from "matrix-appservice-bridge";
|
||||
import { Appservice, Intent, MatrixError } from "matrix-bot-sdk";
|
||||
import { BridgeConfig, BridgePermissionLevel } from "../Config/Config";
|
||||
import { FormatUtil } from "../FormatUtil";
|
||||
|
||||
const GRANT_ACCOUNT_DATA_KEY = "uk.half-shot.matrix-hookshot.grant";
|
||||
|
||||
interface GrantContent {
|
||||
granted: boolean;
|
||||
}
|
||||
|
||||
const log = new Logger("GrantChecker");
|
||||
|
||||
export class GrantRejectedError extends Error {
|
||||
constructor(public readonly roomId: string, public readonly connectionId: string) {
|
||||
super(`No grant exists for ${roomId}/${connectionId}. Rejecting`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
type ConnectionId = string|object;
|
||||
|
||||
export class GrantChecker<cId extends ConnectionId = ConnectionId> {
|
||||
private static stringifyConnectionId<cId = ConnectionId>(connId: cId) {
|
||||
if (typeof connId === "string") {
|
||||
return FormatUtil.hashId(connId.toString());
|
||||
}
|
||||
return FormatUtil.hashId(Object.entries(connId as Record<string, unknown>).map((data) => `${data[0]}:${data[1]}`).join(''));
|
||||
}
|
||||
|
||||
constructor(private readonly intent: Intent, protected readonly grantType: string) { }
|
||||
|
||||
/**
|
||||
* If the connection hasn't been previously granted, we can use this function to check
|
||||
* their permissions in the moment.
|
||||
*
|
||||
* By default, this always returns false.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
protected checkFallback(_roomId: string, _connectionId: cId, _sender?: string): Promise<boolean>|boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
private getKey(connectionIdStr: string): string {
|
||||
return `${GRANT_ACCOUNT_DATA_KEY}/${this.grantType}/${connectionIdStr}`.toLowerCase();
|
||||
}
|
||||
|
||||
public async assertConnectionGranted(roomId: string, connectionId: cId, sender?: string) {
|
||||
const connId = GrantChecker.stringifyConnectionId(connectionId);
|
||||
try {
|
||||
const content = await this.intent.underlyingClient.getRoomAccountData<GrantContent>(this.getKey(connId), roomId);
|
||||
if (!content.granted) {
|
||||
// Previously granted but now stale.
|
||||
throw new GrantRejectedError(roomId, connId);
|
||||
}
|
||||
} catch (ex) {
|
||||
if (ex instanceof MatrixError && ex.errcode === "M_NOT_FOUND") {
|
||||
if (!await this.checkFallback?.(roomId, connectionId, sender)) {
|
||||
throw new GrantRejectedError(roomId, connId);
|
||||
} else {
|
||||
log.info(`Grant fallback succeeded for ${roomId}/${connectionId}`);
|
||||
await this.grantConnection(roomId, connectionId);
|
||||
}
|
||||
} else {
|
||||
log.warn(`Failed to check grant in ${roomId}/${connectionId}`, ex);
|
||||
throw new GrantRejectedError(roomId, connId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async grantConnection(roomId: string, connectionId: cId) {
|
||||
const cidStr = GrantChecker.stringifyConnectionId(connectionId);
|
||||
log.info(`Granting ${roomId}/${cidStr}`);
|
||||
await this.intent.underlyingClient.setRoomAccountData(
|
||||
this.getKey(cidStr),
|
||||
roomId,
|
||||
{ granted: true } as GrantContent
|
||||
);
|
||||
}
|
||||
|
||||
public async ungrantConnection(roomId: string, connectionId: cId) {
|
||||
const cidStr = GrantChecker.stringifyConnectionId(connectionId);
|
||||
log.info(`Ungranting ${roomId}/${cidStr}`);
|
||||
await this.intent.underlyingClient.setRoomAccountData(
|
||||
this.getKey(cidStr),
|
||||
roomId,
|
||||
{ granted: false } as GrantContent
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check the grant of a given connection, falling back to checking the permissions of the user
|
||||
* across the bridge.
|
||||
*/
|
||||
export class ConfigGrantChecker<cId extends ConnectionId = ConnectionId> extends GrantChecker<cId> {
|
||||
static ConfigMinAccessLevel = BridgePermissionLevel.admin;
|
||||
|
||||
constructor(grantType: string, private readonly as: Appservice, private readonly config: BridgeConfig) {
|
||||
super(as.botIntent, grantType)
|
||||
}
|
||||
|
||||
protected checkFallback(_roomId: string, _connectionId: cId, sender?: string) {
|
||||
if (!sender) {
|
||||
// Cannot validate without a sender.
|
||||
return false;
|
||||
}
|
||||
if (this.as.isNamespacedUser(sender)) {
|
||||
// Bridge is always valid.
|
||||
return true;
|
||||
}
|
||||
return this.config.checkPermission(sender, this.grantType, ConfigGrantChecker.ConfigMinAccessLevel);
|
||||
}
|
||||
}
|
@ -4,8 +4,9 @@ import { ConnectionManager } from "../ConnectionManager";
|
||||
import { Logger } from "matrix-appservice-bridge";
|
||||
import { assertUserPermissionsInRoom, GetConnectionsResponseItem, GetConnectionTypeResponseItem } from "./api";
|
||||
import { ApiError, ErrCode } from "../api";
|
||||
import { Intent } from "matrix-bot-sdk";
|
||||
import { Appservice } from "matrix-bot-sdk";
|
||||
import Metrics from "../Metrics";
|
||||
import BotUsersManager from "../Managers/BotUsersManager";
|
||||
|
||||
const log = new Logger("Provisioner");
|
||||
|
||||
@ -19,7 +20,8 @@ export class Provisioner {
|
||||
constructor(
|
||||
private readonly config: BridgeConfigProvisioning,
|
||||
private readonly connMan: ConnectionManager,
|
||||
private readonly intent: Intent,
|
||||
private readonly botUsersManager: BotUsersManager,
|
||||
private readonly as: Appservice,
|
||||
additionalRoutes: {route: string, router: Router}[]) {
|
||||
if (!this.config.secret) {
|
||||
throw Error('Missing secret in provisioning config');
|
||||
@ -96,8 +98,14 @@ export class Provisioner {
|
||||
private async checkUserPermission(requiredPermission: "read"|"write", req: Request<{roomId: string}, unknown, unknown, {userId: string}>, res: Response, next: NextFunction) {
|
||||
const userId = req.query.userId;
|
||||
const roomId = req.params.roomId;
|
||||
|
||||
const botUser = this.botUsersManager.getBotUserInRoom(roomId);
|
||||
if (!botUser) {
|
||||
throw new ApiError("Bot is not joined to the room.", ErrCode.NotInRoom);
|
||||
}
|
||||
|
||||
try {
|
||||
await assertUserPermissionsInRoom(userId, roomId, requiredPermission, this.intent);
|
||||
await assertUserPermissionsInRoom(userId, roomId, requiredPermission, botUser.intent);
|
||||
next();
|
||||
} catch (ex) {
|
||||
next(ex);
|
||||
@ -130,22 +138,38 @@ export class Provisioner {
|
||||
}
|
||||
|
||||
private async putConnection(req: Request<{roomId: string, type: string}, unknown, Record<string, unknown>, {userId: string}>, res: Response<GetConnectionsResponseItem>, next: NextFunction) {
|
||||
const roomId = req.params.roomId;
|
||||
const userId = req.query.userId;
|
||||
const eventType = req.params.type;
|
||||
const connectionType = this.connMan.getConnectionTypeForEventType(eventType);
|
||||
if (!connectionType) {
|
||||
throw new ApiError("Unknown event type", ErrCode.NotFound);
|
||||
}
|
||||
const serviceType = connectionType.ServiceCategory;
|
||||
|
||||
// Need to figure out which connections are available
|
||||
try {
|
||||
if (!req.body || typeof req.body !== "object") {
|
||||
throw new ApiError("A JSON body must be provided", ErrCode.BadValue);
|
||||
}
|
||||
this.connMan.validateCommandPrefix(req.params.roomId, req.body);
|
||||
const result = await this.connMan.provisionConnection(req.params.roomId, req.query.userId, req.params.type, req.body);
|
||||
this.connMan.validateCommandPrefix(roomId, req.body);
|
||||
|
||||
const botUser = this.botUsersManager.getBotUserInRoom(roomId, serviceType);
|
||||
if (!botUser) {
|
||||
throw new ApiError("Bot is not joined to the room.", ErrCode.NotInRoom);
|
||||
}
|
||||
|
||||
const result = await this.connMan.provisionConnection(roomId, botUser.intent, userId, connectionType, req.body);
|
||||
if (!result.connection.getProvisionerDetails) {
|
||||
throw new Error('Connection supported provisioning but not getProvisionerDetails');
|
||||
}
|
||||
|
||||
res.send({
|
||||
...result.connection.getProvisionerDetails(true),
|
||||
warning: result.warning,
|
||||
});
|
||||
} catch (ex) {
|
||||
log.error(`Failed to create connection for ${req.params.roomId}`, ex);
|
||||
log.error(`Failed to create connection for ${roomId}`, ex);
|
||||
return next(ex);
|
||||
}
|
||||
}
|
||||
|
@ -10,7 +10,7 @@ import { IntentMock } from "./utils/IntentMock";
|
||||
const ROOM_ID = "!foo:bar";
|
||||
|
||||
function createAdminRoom(data: any = {admin_user: "@admin:bar"}): [AdminRoom, IntentMock] {
|
||||
const intent = IntentMock.create();
|
||||
const intent = IntentMock.create("@admin:bar");
|
||||
if (!data.admin_user) {
|
||||
data.admin_user = "@admin:bar";
|
||||
}
|
||||
|
@ -3,29 +3,51 @@ import { HookFilter } from '../src/HookFilter';
|
||||
|
||||
const DEFAULT_SET = ['default-allowed', 'default-allowed-but-ignored'];
|
||||
const ENABLED_SET = ['enabled-hook', 'enabled-but-ignored'];
|
||||
const IGNORED_SET = ['ignored', 'enabled-but-ignored', 'default-allowed-but-ignored'];
|
||||
|
||||
describe("HookFilter", () => {
|
||||
let filter: HookFilter<string>;
|
||||
beforeEach(() => {
|
||||
filter = new HookFilter(DEFAULT_SET, ENABLED_SET, IGNORED_SET);
|
||||
filter = new HookFilter(ENABLED_SET);
|
||||
});
|
||||
it('should skip a hook named in ignoreHooks', () => {
|
||||
expect(filter.shouldSkip('ignored')).to.be.true;
|
||||
});
|
||||
it('should allow a hook named in defaults', () => {
|
||||
expect(filter.shouldSkip('default-allowed')).to.be.false;
|
||||
});
|
||||
it('should allow a hook named in enabled', () => {
|
||||
describe('shouldSkip', () => {
|
||||
it('should allow a hook named in enabled set', () => {
|
||||
expect(filter.shouldSkip('enabled-hook')).to.be.false;
|
||||
});
|
||||
it('should skip a hook named in defaults but also in ignored', () => {
|
||||
expect(filter.shouldSkip('default-allowed-but-ignored')).to.be.true;
|
||||
});
|
||||
it('should skip a hook named in enabled but also in ignored', () => {
|
||||
expect(filter.shouldSkip('enabled-but-ignored')).to.be.true;
|
||||
});
|
||||
it('should skip if any hooks are in ignored', () => {
|
||||
expect(filter.shouldSkip('enabled-hook', 'enabled-but-ignored')).to.be.true;
|
||||
it('should not allow a hook not named in enabled set', () => {
|
||||
expect(filter.shouldSkip('not-enabled-hook')).to.be.true;
|
||||
});
|
||||
});
|
||||
|
||||
describe('convertIgnoredHooksToEnabledHooks', () => {
|
||||
it('should correctly provide a list of default hooks', () => {
|
||||
expect(HookFilter.convertIgnoredHooksToEnabledHooks([], [], DEFAULT_SET)).to.have.members(DEFAULT_SET);
|
||||
});
|
||||
|
||||
it('should correctly include default and enabled hooks when ignored hooks is set', () => {
|
||||
expect(HookFilter.convertIgnoredHooksToEnabledHooks(ENABLED_SET, ['my-ignored-hook'], DEFAULT_SET)).to.have.members([
|
||||
...ENABLED_SET, ...DEFAULT_SET
|
||||
]);
|
||||
});
|
||||
|
||||
it('should deduplicate', () => {
|
||||
expect(HookFilter.convertIgnoredHooksToEnabledHooks(DEFAULT_SET, [], DEFAULT_SET)).to.have.members(DEFAULT_SET);
|
||||
});
|
||||
|
||||
it('should correctly exclude ignored hooks', () => {
|
||||
expect(HookFilter.convertIgnoredHooksToEnabledHooks([], [DEFAULT_SET[0]], DEFAULT_SET)).to.not.include([
|
||||
DEFAULT_SET[0]
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle ignored root hooks', () => {
|
||||
const defaultHooks = ['myhook', 'myhook.foo', 'myhook.foo.bar'];
|
||||
expect(HookFilter.convertIgnoredHooksToEnabledHooks([], ['myhook.foo.bar'], defaultHooks)).to.have.members([
|
||||
'myhook', 'myhook.foo'
|
||||
]);
|
||||
expect(HookFilter.convertIgnoredHooksToEnabledHooks([], ['myhook.foo'], defaultHooks)).to.have.members([
|
||||
'myhook'
|
||||
]);
|
||||
expect(HookFilter.convertIgnoredHooksToEnabledHooks([], ['myhook'], defaultHooks)).to.be.empty;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
58
tests/IntentUtilsTest.ts
Normal file
58
tests/IntentUtilsTest.ts
Normal file
@ -0,0 +1,58 @@
|
||||
import { IntentMock, MatrixClientMock } from "./utils/IntentMock";
|
||||
import { ensureUserIsInRoom } from "../src/IntentUtils";
|
||||
import { expect } from "chai";
|
||||
import { MatrixError } from "matrix-bot-sdk";
|
||||
|
||||
const ROOM_ID = "!foo:bar";
|
||||
const SENDER_USER_ID = "@my_target:foo";
|
||||
|
||||
describe("IntentUtils", () => {
|
||||
describe("ensureUserIsInRoom", () => {
|
||||
it("no-ops if the user is already joined to the room", () => {
|
||||
const targetIntent = IntentMock.create(SENDER_USER_ID);
|
||||
targetIntent.ensureJoined = () => { /* No-op */ };
|
||||
const matrixClient = MatrixClientMock.create();
|
||||
ensureUserIsInRoom(targetIntent, matrixClient, ROOM_ID);
|
||||
});
|
||||
|
||||
it("invites the user to the room and joins", () => {
|
||||
const targetIntent = IntentMock.create(SENDER_USER_ID);
|
||||
const matrixClient = MatrixClientMock.create();
|
||||
let hasInvited = false;
|
||||
// This should fail the first time, then pass once we've tried to invite the user
|
||||
targetIntent.ensureJoined = (roomId: string) => {
|
||||
if (hasInvited) {
|
||||
return;
|
||||
}
|
||||
expect(roomId).to.equal(ROOM_ID);
|
||||
throw new MatrixError({ errcode: "M_FORBIDDEN", error: "Test forced error"}, 401)
|
||||
};
|
||||
|
||||
// This should invite the puppet user.
|
||||
matrixClient.inviteUser = (userId: string, roomId: string) => {
|
||||
expect(userId).to.equal(SENDER_USER_ID);
|
||||
expect(roomId).to.equal(ROOM_ID);
|
||||
hasInvited = true;
|
||||
}
|
||||
|
||||
ensureUserIsInRoom(targetIntent, matrixClient, ROOM_ID);
|
||||
// Only pass if we've actually bothered to invite the bot.
|
||||
expect(hasInvited).to.be.true;
|
||||
});
|
||||
|
||||
it("invites the user to the room and joins", () => {
|
||||
const targetIntent = IntentMock.create(SENDER_USER_ID);
|
||||
const matrixClient = MatrixClientMock.create();
|
||||
|
||||
// This should fail the first time, then pass once we've tried to invite the user
|
||||
targetIntent.ensureJoined = () => {
|
||||
throw new MatrixError({ errcode: "FORCED_FAILURE", error: "Test forced error"}, 500)
|
||||
};
|
||||
try {
|
||||
ensureUserIsInRoom(targetIntent, matrixClient, ROOM_ID);
|
||||
} catch (ex) {
|
||||
expect(ex.message).to.contain(`Could not ensure that ${SENDER_USER_ID} is in ${ROOM_ID}`)
|
||||
}
|
||||
});
|
||||
})
|
||||
});
|
@ -1,5 +1,6 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { expect } from "chai";
|
||||
import { MatrixError } from "matrix-bot-sdk";
|
||||
import { BridgeConfigGenericWebhooks, BridgeGenericWebhooksConfigYAML } from "../../src/Config/Config";
|
||||
import { GenericHookConnection, GenericHookConnectionState } from "../../src/Connections/GenericHook";
|
||||
import { MessageSenderClient, IMatrixSendMessage } from "../../src/MatrixSender";
|
||||
@ -11,14 +12,35 @@ const ROOM_ID = "!foo:bar";
|
||||
const V1TFFunction = "result = `The answer to '${data.question}' is ${data.answer}`;";
|
||||
const V2TFFunction = "result = {plain: `The answer to '${data.question}' is ${data.answer}`, version: 'v2'}";
|
||||
|
||||
function createGenericHook(state: GenericHookConnectionState = {
|
||||
name: "some-name"
|
||||
}, config: BridgeGenericWebhooksConfigYAML = { enabled: true, urlPrefix: "https://example.com/webhookurl"}): [GenericHookConnection, LocalMQ] {
|
||||
async function testSimpleWebhook(connection: GenericHookConnection, mq: LocalMQ, testValue: string) {
|
||||
const webhookData = {simple: testValue};
|
||||
const messagePromise = handleMessage(mq);
|
||||
await connection.onGenericHook(webhookData);
|
||||
expect(await messagePromise).to.deep.equal({
|
||||
roomId: ROOM_ID,
|
||||
sender: connection.getUserId(),
|
||||
content: {
|
||||
body: "Received webhook data:\n\n```json\n\n{\n \"simple\": \"" + testValue + "\"\n}\n\n```",
|
||||
format: "org.matrix.custom.html",
|
||||
formatted_body: "<p>Received webhook data:</p><p><pre><code class=\\\"language-json\\\">{\n \"simple\": \"" + testValue + "\"\n}</code></pre></p>",
|
||||
msgtype: "m.notice",
|
||||
"uk.half-shot.hookshot.webhook_data": webhookData,
|
||||
},
|
||||
type: 'm.room.message',
|
||||
});
|
||||
}
|
||||
|
||||
function createGenericHook(
|
||||
state: GenericHookConnectionState = { name: "some-name" },
|
||||
config: BridgeGenericWebhooksConfigYAML = { enabled: true, urlPrefix: "https://example.com/webhookurl"}
|
||||
) {
|
||||
const mq = new LocalMQ();
|
||||
mq.subscribe('*');
|
||||
const messageClient = new MessageSenderClient(mq);
|
||||
const connection = new GenericHookConnection(ROOM_ID, state, "foobar", "foobar", messageClient, new BridgeConfigGenericWebhooks(config), AppserviceMock.create())
|
||||
return [connection, mq];
|
||||
const as = AppserviceMock.create();
|
||||
const intent = as.getIntentForUserId('@webhooks:example.test');
|
||||
const connection = new GenericHookConnection(ROOM_ID, state, "foobar", "foobar", messageClient, new BridgeConfigGenericWebhooks(config), as, intent);
|
||||
return [connection, mq, as, intent];
|
||||
}
|
||||
|
||||
function handleMessage(mq: LocalMQ): Promise<IMatrixSendMessage> {
|
||||
@ -34,23 +56,9 @@ function handleMessage(mq: LocalMQ): Promise<IMatrixSendMessage> {
|
||||
}
|
||||
|
||||
describe("GenericHookConnection", () => {
|
||||
it("will handle a simple hook event", async () => {
|
||||
const webhookData = {simple: "data"};
|
||||
it("will handle simple hook events", async () => {
|
||||
const [connection, mq] = createGenericHook();
|
||||
const messagePromise = handleMessage(mq);
|
||||
await connection.onGenericHook(webhookData);
|
||||
expect(await messagePromise).to.deep.equal({
|
||||
roomId: ROOM_ID,
|
||||
sender: connection.getUserId(),
|
||||
content: {
|
||||
body: "Received webhook data:\n\n```json\n\n{\n \"simple\": \"data\"\n}\n\n```",
|
||||
format: "org.matrix.custom.html",
|
||||
formatted_body: "<p>Received webhook data:</p><p><pre><code class=\\\"language-json\\\">{\n \"simple\": \"data\"\n}</code></pre></p>",
|
||||
msgtype: "m.notice",
|
||||
"uk.half-shot.hookshot.webhook_data": webhookData,
|
||||
},
|
||||
type: 'm.room.message',
|
||||
});
|
||||
await testSimpleWebhook(connection, mq, "data");
|
||||
});
|
||||
it("will handle a hook event containing text", async () => {
|
||||
const webhookData = {text: "simple-message"};
|
||||
@ -247,6 +255,60 @@ describe("GenericHookConnection", () => {
|
||||
an_array_of: ["1.2345", "6.789"],
|
||||
floats: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle simple hook events with user Id prefix", async () => {
|
||||
const config = { enabled: true, urlPrefix: "https://example.com/webhookurl", userIdPrefix: "_webhooks_"};
|
||||
const [connection, mq] = createGenericHook(undefined, config);
|
||||
await testSimpleWebhook(connection, mq, "data1");
|
||||
// regression test covering https://github.com/matrix-org/matrix-hookshot/issues/625
|
||||
await testSimpleWebhook(connection, mq, "data2");
|
||||
});
|
||||
|
||||
it("should invite a configured puppet to the room if it's unable to join", async () => {
|
||||
const senderUserId = "@_webhooks_some-name:example.test";
|
||||
const config = { enabled: true, urlPrefix: "https://example.com/webhookurl", userIdPrefix: "_webhooks_"};
|
||||
const [connection, mq, as, botIntent] = createGenericHook(undefined, config);
|
||||
const intent = as.getIntentForUserId(senderUserId);
|
||||
let hasInvited = false;
|
||||
|
||||
// This should fail the first time, then pass once we've tried to invite the user
|
||||
intent.ensureJoined = (roomId: string) => {
|
||||
if (hasInvited) {
|
||||
return;
|
||||
}
|
||||
expect(roomId).to.equal(ROOM_ID);
|
||||
throw new MatrixError({ errcode: "M_FORBIDDEN", error: "Test forced error"}, 401)
|
||||
};
|
||||
|
||||
// This should invite the puppet user.
|
||||
botIntent.underlyingClient.inviteUser = (userId: string, roomId: string) => {
|
||||
expect(userId).to.equal(senderUserId);
|
||||
expect(roomId).to.equal(ROOM_ID);
|
||||
hasInvited = true;
|
||||
}
|
||||
|
||||
// regression test covering https://github.com/matrix-org/matrix-hookshot/issues/625
|
||||
await testSimpleWebhook(connection, mq, "data1");
|
||||
// Only pass if we've actually bothered to invite the bot.
|
||||
expect(hasInvited).to.be.true;
|
||||
});
|
||||
|
||||
it("should fail a message if a bot cannot join a room", async () => {
|
||||
const senderUserId = "@_webhooks_some-name:example.test";
|
||||
const config = { enabled: true, urlPrefix: "https://example.com/webhookurl", userIdPrefix: "_webhooks_"};
|
||||
const [connection, mq, as] = createGenericHook(undefined, config);
|
||||
const intent = as.getIntentForUserId(senderUserId);
|
||||
|
||||
// This should fail the first time, then pass once we've tried to invite the user
|
||||
intent.ensureJoined = () => {
|
||||
throw new MatrixError({ errcode: "FORCED_FAILURE", error: "Test forced error"}, 500)
|
||||
};
|
||||
try {
|
||||
// regression test covering https://github.com/matrix-org/matrix-hookshot/issues/625
|
||||
await testSimpleWebhook(connection, mq, "data1");
|
||||
} catch (ex) {
|
||||
expect(ex.message).to.contain(`Could not ensure that ${senderUserId} is in ${ROOM_ID}`)
|
||||
}
|
||||
});
|
||||
})
|
||||
|
@ -5,6 +5,7 @@ import { UserTokenStore } from "../../src/UserTokenStore";
|
||||
import { DefaultConfig } from "../../src/Config/Defaults";
|
||||
import { AppserviceMock } from "../utils/AppserviceMock";
|
||||
import { ApiError, ErrCode, ValidatorApiError } from "../../src/api";
|
||||
import { expect } from "chai";
|
||||
|
||||
const ROOM_ID = "!foo:bar";
|
||||
|
||||
@ -39,10 +40,12 @@ function createConnection(state: Record<string, unknown> = {}, isExistingState=f
|
||||
});
|
||||
mq.subscribe('*');
|
||||
const as = AppserviceMock.create();
|
||||
const intent = as.getIntentForUserId('@github:example.test');
|
||||
const githubInstance = new GithubInstance("foo", "bar", new URL("https://github.com"));
|
||||
const connection = new GitHubRepoConnection(
|
||||
ROOM_ID,
|
||||
as,
|
||||
intent,
|
||||
GitHubRepoConnection.validateState({
|
||||
org: "a-fake-org",
|
||||
repo: "a-fake-repo",
|
||||
@ -55,7 +58,7 @@ function createConnection(state: Record<string, unknown> = {}, isExistingState=f
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
DefaultConfig.github!
|
||||
);
|
||||
return {connection, as};
|
||||
return {connection, intent};
|
||||
}
|
||||
|
||||
describe("GitHubRepoConnection", () => {
|
||||
@ -64,7 +67,7 @@ describe("GitHubRepoConnection", () => {
|
||||
GitHubRepoConnection.validateState({
|
||||
org: "foo",
|
||||
repo: "bar",
|
||||
ignoreHooks: ["issue", "pull_request", "release"],
|
||||
enableHooks: ["issue", "pull_request", "release"],
|
||||
commandPrefix: "!foo",
|
||||
showIssueRoomLink: true,
|
||||
prDiff: {
|
||||
@ -81,6 +84,16 @@ describe("GitHubRepoConnection", () => {
|
||||
}
|
||||
} as GitHubRepoConnectionState as unknown as Record<string, unknown>);
|
||||
});
|
||||
it("will convert ignoredHooks for existing state", () => {
|
||||
const state = GitHubRepoConnection.validateState({
|
||||
org: "foo",
|
||||
repo: "bar",
|
||||
ignoreHooks: ["issue"],
|
||||
enableHooks: ["issue", "pull_request", "release"],
|
||||
commandPrefix: "!foo",
|
||||
} as GitHubRepoConnectionState as unknown as Record<string, unknown>, true);
|
||||
expect(state.enableHooks).to.not.contain('issue');
|
||||
});
|
||||
it("will disallow invalid state", () => {
|
||||
try {
|
||||
GitHubRepoConnection.validateState({
|
||||
@ -93,12 +106,12 @@ describe("GitHubRepoConnection", () => {
|
||||
}
|
||||
}
|
||||
});
|
||||
it("will disallow ignoreHooks to contains invalid enums if this is new state", () => {
|
||||
it("will disallow enabledHooks to contains invalid enums if this is new state", () => {
|
||||
try {
|
||||
GitHubRepoConnection.validateState({
|
||||
org: "foo",
|
||||
repo: "bar",
|
||||
ignoreHooks: ["issue", "pull_request", "release", "not-real"],
|
||||
enabledHooks: ["not-real"],
|
||||
}, false);
|
||||
} catch (ex) {
|
||||
if (ex instanceof ApiError === false || ex.errcode !== ErrCode.BadValue) {
|
||||
@ -106,25 +119,25 @@ describe("GitHubRepoConnection", () => {
|
||||
}
|
||||
}
|
||||
});
|
||||
it("will allow ignoreHooks to contains invalid enums if this is old state", () => {
|
||||
it("will allow enabledHooks to contains invalid enums if this is old state", () => {
|
||||
GitHubRepoConnection.validateState({
|
||||
org: "foo",
|
||||
repo: "bar",
|
||||
ignoreHooks: ["issue", "pull_request", "release", "not-real"],
|
||||
enabledHooks: ["not-real"],
|
||||
}, true);
|
||||
});
|
||||
});
|
||||
describe("onIssueCreated", () => {
|
||||
it("will handle a simple issue", async () => {
|
||||
const { connection, as } = createConnection();
|
||||
const { connection, intent } = createConnection();
|
||||
await connection.onIssueCreated(GITHUB_ISSUE_CREATED_PAYLOAD as never);
|
||||
// Statement text.
|
||||
as.botIntent.expectEventBodyContains('**alice** created new issue', 0);
|
||||
as.botIntent.expectEventBodyContains(GITHUB_ISSUE_CREATED_PAYLOAD.issue.html_url, 0);
|
||||
as.botIntent.expectEventBodyContains(GITHUB_ISSUE_CREATED_PAYLOAD.issue.title, 0);
|
||||
intent.expectEventBodyContains('**alice** created new issue', 0);
|
||||
intent.expectEventBodyContains(GITHUB_ISSUE_CREATED_PAYLOAD.issue.html_url, 0);
|
||||
intent.expectEventBodyContains(GITHUB_ISSUE_CREATED_PAYLOAD.issue.title, 0);
|
||||
});
|
||||
it("will filter out issues not matching includingLabels.", async () => {
|
||||
const { connection, as } = createConnection({
|
||||
const { connection, intent } = createConnection({
|
||||
includingLabels: ["include-me"]
|
||||
});
|
||||
await connection.onIssueCreated({
|
||||
@ -138,10 +151,10 @@ describe("GitHubRepoConnection", () => {
|
||||
} as never);
|
||||
// ..or issues with no labels
|
||||
await connection.onIssueCreated(GITHUB_ISSUE_CREATED_PAYLOAD as never);
|
||||
as.botIntent.expectNoEvent();
|
||||
intent.expectNoEvent();
|
||||
});
|
||||
it("will filter out issues matching excludingLabels.", async () => {
|
||||
const { connection, as } = createConnection({
|
||||
const { connection, intent } = createConnection({
|
||||
excludingLabels: ["exclude-me"]
|
||||
});
|
||||
await connection.onIssueCreated({
|
||||
@ -153,10 +166,10 @@ describe("GitHubRepoConnection", () => {
|
||||
}],
|
||||
}
|
||||
} as never);
|
||||
as.botIntent.expectNoEvent();
|
||||
intent.expectNoEvent();
|
||||
});
|
||||
it("will include issues matching includingLabels.", async () => {
|
||||
const { connection, as } = createConnection({
|
||||
const { connection, intent } = createConnection({
|
||||
includingIssues: ["include-me"]
|
||||
});
|
||||
await connection.onIssueCreated({
|
||||
@ -168,7 +181,7 @@ describe("GitHubRepoConnection", () => {
|
||||
}],
|
||||
}
|
||||
} as never);
|
||||
as.botIntent.expectEventBodyContains('**alice** created new issue', 0);
|
||||
intent.expectEventBodyContains('**alice** created new issue', 0);
|
||||
});
|
||||
});
|
||||
});
|
@ -3,6 +3,8 @@ import { UserTokenStore } from "../../src/UserTokenStore";
|
||||
import { AppserviceMock } from "../utils/AppserviceMock";
|
||||
import { ApiError, ErrCode, ValidatorApiError } from "../../src/api";
|
||||
import { GitLabRepoConnection, GitLabRepoConnectionState } from "../../src/Connections";
|
||||
import { expect } from "chai";
|
||||
import { BridgeConfigGitLab } from "../../src/Config/Config";
|
||||
|
||||
const ROOM_ID = "!foo:bar";
|
||||
|
||||
@ -37,10 +39,13 @@ function createConnection(state: Record<string, unknown> = {}, isExistingState=f
|
||||
});
|
||||
mq.subscribe('*');
|
||||
const as = AppserviceMock.create();
|
||||
const intent = as.getIntentForUserId('@gitlab:example.test');
|
||||
const connection = new GitLabRepoConnection(
|
||||
ROOM_ID,
|
||||
"state_key",
|
||||
as,
|
||||
{} as BridgeConfigGitLab,
|
||||
intent,
|
||||
GitLabRepoConnection.validateState({
|
||||
instance: "bar",
|
||||
path: "foo",
|
||||
@ -51,7 +56,7 @@ function createConnection(state: Record<string, unknown> = {}, isExistingState=f
|
||||
url: "https://gitlab.example.com"
|
||||
},
|
||||
);
|
||||
return {connection, as};
|
||||
return {connection, intent};
|
||||
}
|
||||
|
||||
describe("GitLabRepoConnection", () => {
|
||||
@ -60,7 +65,7 @@ describe("GitLabRepoConnection", () => {
|
||||
GitLabRepoConnection.validateState({
|
||||
instance: "foo",
|
||||
path: "bar/baz",
|
||||
ignoreHooks: [
|
||||
enableHooks: [
|
||||
"merge_request.open",
|
||||
"merge_request.close",
|
||||
"merge_request.merge",
|
||||
@ -79,6 +84,17 @@ describe("GitLabRepoConnection", () => {
|
||||
excludingLabels: ["but-not-me"],
|
||||
} as GitLabRepoConnectionState as unknown as Record<string, unknown>);
|
||||
});
|
||||
it("will convert ignoredHooks for existing state", () => {
|
||||
const state = GitLabRepoConnection.validateState({
|
||||
instance: "foo",
|
||||
path: "bar/baz",
|
||||
ignoreHooks: [
|
||||
"merge_request",
|
||||
],
|
||||
commandPrefix: "!gl",
|
||||
} as GitLabRepoConnectionState as unknown as Record<string, unknown>, true);
|
||||
expect(state.enableHooks).to.not.contain('merge_request');
|
||||
});
|
||||
it("will disallow invalid state", () => {
|
||||
try {
|
||||
GitLabRepoConnection.validateState({
|
||||
@ -91,12 +107,12 @@ describe("GitLabRepoConnection", () => {
|
||||
}
|
||||
}
|
||||
});
|
||||
it("will disallow ignoreHooks to contains invalid enums if this is new state", () => {
|
||||
it("will disallow enabledHooks to contains invalid enums if this is new state", () => {
|
||||
try {
|
||||
GitLabRepoConnection.validateState({
|
||||
instance: "bar",
|
||||
path: "foo",
|
||||
ignoreHooks: ["issue", "pull_request", "release", "not-real"],
|
||||
enabledHooks: ["not-real"],
|
||||
}, false);
|
||||
} catch (ex) {
|
||||
if (ex instanceof ApiError === false || ex.errcode !== ErrCode.BadValue) {
|
||||
@ -104,25 +120,25 @@ describe("GitLabRepoConnection", () => {
|
||||
}
|
||||
}
|
||||
});
|
||||
it("will allow ignoreHooks to contains invalid enums if this is old state", () => {
|
||||
it("will allow enabledHooks to contains invalid enums if this is old state", () => {
|
||||
GitLabRepoConnection.validateState({
|
||||
instance: "bar",
|
||||
path: "foo",
|
||||
ignoreHooks: ["issues", "merge_request", "foo"],
|
||||
enabledHooks: ["not-real"],
|
||||
}, true);
|
||||
});
|
||||
});
|
||||
describe("onIssueCreated", () => {
|
||||
it("will handle a simple issue", async () => {
|
||||
const { connection, as } = createConnection();
|
||||
const { connection, intent } = createConnection();
|
||||
await connection.onMergeRequestOpened(GITLAB_ISSUE_CREATED_PAYLOAD as never);
|
||||
// Statement text.
|
||||
as.botIntent.expectEventBodyContains('**alice** opened a new MR', 0);
|
||||
as.botIntent.expectEventBodyContains(GITLAB_ISSUE_CREATED_PAYLOAD.object_attributes.url, 0);
|
||||
as.botIntent.expectEventBodyContains(GITLAB_ISSUE_CREATED_PAYLOAD.object_attributes.title, 0);
|
||||
intent.expectEventBodyContains('**alice** opened a new MR', 0);
|
||||
intent.expectEventBodyContains(GITLAB_ISSUE_CREATED_PAYLOAD.object_attributes.url, 0);
|
||||
intent.expectEventBodyContains(GITLAB_ISSUE_CREATED_PAYLOAD.object_attributes.title, 0);
|
||||
});
|
||||
it("will filter out issues not matching includingLabels.", async () => {
|
||||
const { connection, as } = createConnection({
|
||||
const { connection, intent } = createConnection({
|
||||
includingLabels: ["include-me"]
|
||||
});
|
||||
await connection.onMergeRequestOpened({
|
||||
@ -133,10 +149,10 @@ describe("GitLabRepoConnection", () => {
|
||||
} as never);
|
||||
// ..or issues with no labels
|
||||
await connection.onMergeRequestOpened(GITLAB_ISSUE_CREATED_PAYLOAD as never);
|
||||
as.botIntent.expectNoEvent();
|
||||
intent.expectNoEvent();
|
||||
});
|
||||
it("will filter out issues matching excludingLabels.", async () => {
|
||||
const { connection, as } = createConnection({
|
||||
const { connection, intent } = createConnection({
|
||||
excludingLabels: ["exclude-me"]
|
||||
});
|
||||
await connection.onMergeRequestOpened({
|
||||
@ -145,10 +161,10 @@ describe("GitLabRepoConnection", () => {
|
||||
title: "exclude-me",
|
||||
}],
|
||||
} as never);
|
||||
as.botIntent.expectNoEvent();
|
||||
intent.expectNoEvent();
|
||||
});
|
||||
it("will include issues matching includingLabels.", async () => {
|
||||
const { connection, as } = createConnection({
|
||||
const { connection, intent } = createConnection({
|
||||
includingIssues: ["include-me"]
|
||||
});
|
||||
await connection.onMergeRequestOpened({
|
||||
@ -157,7 +173,7 @@ describe("GitLabRepoConnection", () => {
|
||||
title: "include-me",
|
||||
}],
|
||||
} as never);
|
||||
as.botIntent.expectEventBodyContains('**alice** opened a new MR', 0);
|
||||
intent.expectEventBodyContains('**alice** opened a new MR', 0);
|
||||
});
|
||||
});
|
||||
});
|
177
tests/grants/GrantChecker.spec.ts
Normal file
177
tests/grants/GrantChecker.spec.ts
Normal file
@ -0,0 +1,177 @@
|
||||
import { expect } from "chai";
|
||||
import { BridgeConfig } from "../../src/Config/Config";
|
||||
import { DefaultConfigRoot } from "../../src/Config/Defaults";
|
||||
import { FormatUtil } from "../../src/FormatUtil";
|
||||
import { ConfigGrantChecker, GrantChecker, GrantRejectedError } from '../../src/grants/GrantCheck';
|
||||
import { AppserviceMock } from "../utils/AppserviceMock";
|
||||
import { IntentMock } from "../utils/IntentMock";
|
||||
|
||||
const ROOM_ID = '!a-room:bar';
|
||||
const CONNECTION_ID = '!a-room:bar';
|
||||
const ALWAYS_GRANT_USER = '@grant_me:bar';
|
||||
const GRANT_SERVICE_USER = '@grant_service_user:bar';
|
||||
const GRANT_SERVCE_LOW_PERMS = '@grant_service_user_without_perms:bar';
|
||||
const GRANT_WRONG_SERVCE_USER = '@grant_wrong_service_user:bar';
|
||||
const ALICE_USERID = '@alice:bar';
|
||||
const GRANT_SERVICE = 'example-grant';
|
||||
|
||||
async function doesAssert(checker: GrantChecker<string>, roomId: string, connectionId: string, sender?: string) {
|
||||
try {
|
||||
await checker.assertConnectionGranted(roomId, connectionId, sender);
|
||||
throw Error(`Expected ${roomId}/${connectionId} to have thrown an error`)
|
||||
} catch (ex) {
|
||||
expect(ex).instanceOf(GrantRejectedError, 'Error thrown, but was not a grant rejected error');
|
||||
expect(ex.roomId).to.equal(roomId, "Grant rejected, but roomId didn't match");
|
||||
// connectionIds are always hashed
|
||||
expect(ex.connectionId).to.equal(FormatUtil.hashId(connectionId), "Grant rejected, but connectionId didn't match");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
class TestGrantChecker extends GrantChecker {
|
||||
protected checkFallback(roomId: string, connectionId: string | object, sender?: string | undefined) {
|
||||
return sender === ALWAYS_GRANT_USER;
|
||||
}
|
||||
}
|
||||
|
||||
describe("GrantChecker", () => {
|
||||
describe('base grant system', () => {
|
||||
let check: GrantChecker<string>;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let intent: any;
|
||||
beforeEach(() => {
|
||||
intent = IntentMock.create('@foo:bar');
|
||||
check = new TestGrantChecker(intent, GRANT_SERVICE);
|
||||
});
|
||||
|
||||
it('will grant a connection', async () => {
|
||||
await check.grantConnection(ROOM_ID, CONNECTION_ID);
|
||||
// And then to check that the grant has now been allowed.
|
||||
await check.assertConnectionGranted(ROOM_ID, CONNECTION_ID);
|
||||
});
|
||||
|
||||
it('will assert on a missing grant', async () => {
|
||||
await doesAssert(
|
||||
check,
|
||||
ROOM_ID,
|
||||
CONNECTION_ID
|
||||
);
|
||||
});
|
||||
|
||||
it('will allow a missing grant if sender matches', async () => {
|
||||
// Use the special user to grant the connection
|
||||
await check.assertConnectionGranted(ROOM_ID, CONNECTION_ID, ALWAYS_GRANT_USER);
|
||||
|
||||
// And then to check that the grant has now been allowed.
|
||||
await check.assertConnectionGranted(ROOM_ID, CONNECTION_ID);
|
||||
});
|
||||
|
||||
it('will not conflict with another connection id', async () => {
|
||||
await check.grantConnection(ROOM_ID, CONNECTION_ID);
|
||||
await doesAssert(
|
||||
check,
|
||||
ROOM_ID,
|
||||
CONNECTION_ID + "2",
|
||||
);
|
||||
});
|
||||
|
||||
it('will not conflict with another room', async () => {
|
||||
await check.grantConnection(ROOM_ID, CONNECTION_ID);
|
||||
await doesAssert(
|
||||
check,
|
||||
ROOM_ID + "2",
|
||||
CONNECTION_ID
|
||||
);
|
||||
});
|
||||
|
||||
it('will not conflict with another grant service', async () => {
|
||||
const anotherchecker = new TestGrantChecker(intent, GRANT_SERVICE + "-two");
|
||||
await check.grantConnection(ROOM_ID, CONNECTION_ID);
|
||||
|
||||
await doesAssert(
|
||||
anotherchecker,
|
||||
ROOM_ID,
|
||||
CONNECTION_ID
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('config fallback', () => {
|
||||
let check: GrantChecker<string>;
|
||||
let as: AppserviceMock;
|
||||
beforeEach(() => {
|
||||
const mockAs = AppserviceMock.create();
|
||||
as = mockAs;
|
||||
const config = new BridgeConfig(
|
||||
{
|
||||
...DefaultConfigRoot,
|
||||
permissions: [{
|
||||
actor: ALWAYS_GRANT_USER,
|
||||
services: [{
|
||||
service: '*',
|
||||
level: "admin",
|
||||
}],
|
||||
},
|
||||
{
|
||||
actor: GRANT_SERVICE_USER,
|
||||
services: [{
|
||||
service: GRANT_SERVICE,
|
||||
level: "admin",
|
||||
}]
|
||||
},
|
||||
{
|
||||
actor: GRANT_SERVCE_LOW_PERMS,
|
||||
services: [{
|
||||
service: GRANT_SERVICE,
|
||||
level: 'notifications',
|
||||
}]
|
||||
},
|
||||
{
|
||||
actor: GRANT_WRONG_SERVCE_USER,
|
||||
services: [{
|
||||
service: 'another-service',
|
||||
level: "admin",
|
||||
}]
|
||||
}],
|
||||
}
|
||||
);
|
||||
check = new ConfigGrantChecker(GRANT_SERVICE, mockAs, config);
|
||||
});
|
||||
|
||||
it('will deny a missing grant if the sender is not provided', async () => {
|
||||
await doesAssert(
|
||||
check,
|
||||
ROOM_ID,
|
||||
CONNECTION_ID
|
||||
);
|
||||
});
|
||||
|
||||
it('will deny a missing grant if the sender is not in the appservice whitelist', async () => {
|
||||
await doesAssert(
|
||||
check,
|
||||
ROOM_ID,
|
||||
CONNECTION_ID,
|
||||
ALICE_USERID,
|
||||
);
|
||||
});
|
||||
|
||||
it('will grant if the user is part of the appservice', async () => {
|
||||
await check.assertConnectionGranted(ROOM_ID, CONNECTION_ID, as.namespace + "bot");
|
||||
});
|
||||
|
||||
it('will grant if the user has access to all services', async () => {
|
||||
await check.assertConnectionGranted(ROOM_ID, CONNECTION_ID, ALWAYS_GRANT_USER);
|
||||
});
|
||||
|
||||
it('will grant if the user has access to this service', async () => {
|
||||
await check.assertConnectionGranted(ROOM_ID, CONNECTION_ID, GRANT_SERVICE_USER);
|
||||
});
|
||||
|
||||
it('will not grant if the user has low access to this service', async () => {
|
||||
await doesAssert(check, ROOM_ID, CONNECTION_ID, GRANT_SERVCE_LOW_PERMS);
|
||||
});
|
||||
|
||||
it('will not grant if the user has access to a different service', async () => {
|
||||
await doesAssert(check, ROOM_ID, CONNECTION_ID, GRANT_WRONG_SERVCE_USER);
|
||||
});
|
||||
});
|
||||
});
|
@ -1,21 +1,35 @@
|
||||
import { IntentMock } from "./IntentMock";
|
||||
|
||||
export class AppserviceMock {
|
||||
public readonly botIntent = IntentMock.create();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
public readonly intentMap = new Map<string, any>();
|
||||
public readonly botIntent = IntentMock.create(`@bot:example.com`);
|
||||
public namespace = "@hookshot_";
|
||||
|
||||
static create(){
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return new this() as any;
|
||||
}
|
||||
|
||||
get botUserId() {
|
||||
return `@bot:example.com`;
|
||||
return this.botIntent.userId;
|
||||
}
|
||||
|
||||
get botClient() {
|
||||
return this.botIntent.underlyingClient;
|
||||
}
|
||||
|
||||
public getIntentForUserId() {
|
||||
return IntentMock.create();
|
||||
public getIntentForUserId(userId: string) {
|
||||
let intent = this.intentMap.get(userId);
|
||||
if (intent) {
|
||||
return intent;
|
||||
}
|
||||
intent = IntentMock.create(userId);
|
||||
this.intentMap.set(userId, intent);
|
||||
return intent;
|
||||
}
|
||||
|
||||
public isNamespacedUser(userId: string) {
|
||||
return userId.startsWith(this.namespace);
|
||||
}
|
||||
}
|
||||
|
@ -1,18 +1,61 @@
|
||||
|
||||
import { expect } from "chai";
|
||||
import { MatrixError } from "matrix-bot-sdk";
|
||||
export class MatrixClientMock {
|
||||
|
||||
static create(){
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return new this() as any;
|
||||
}
|
||||
|
||||
// map room Id → user Ids
|
||||
private joinedMembers: Map<string, string[]> = new Map();
|
||||
public readonly roomAccountData: Map<string, string> = new Map();
|
||||
|
||||
async setDisplayName() {
|
||||
return;
|
||||
}
|
||||
|
||||
async getJoinedRoomMembers(roomId: string): Promise<string[]> {
|
||||
return this.joinedMembers.get(roomId) || [];
|
||||
}
|
||||
|
||||
async inviteUser(userId: string, roomId: string): Promise<void> {
|
||||
const roomMembers = this.joinedMembers.get(roomId) || [];
|
||||
|
||||
if (roomMembers.includes(userId)) {
|
||||
throw new Error("User already in room");
|
||||
}
|
||||
|
||||
roomMembers.push(userId);
|
||||
this.joinedMembers.set(roomId, roomMembers);
|
||||
}
|
||||
|
||||
async getRoomAccountData(key: string, roomId: string): Promise<string> {
|
||||
const data = this.roomAccountData.get(roomId+key);
|
||||
if (data) {
|
||||
return data;
|
||||
}
|
||||
throw new MatrixError({
|
||||
errcode: 'M_NOT_FOUND',
|
||||
error: 'Test error: No account data',
|
||||
}, 404);
|
||||
}
|
||||
|
||||
async setRoomAccountData(key: string, roomId: string, value: string): Promise<void> {
|
||||
this.roomAccountData.set(roomId+key, value);
|
||||
}
|
||||
}
|
||||
|
||||
export class IntentMock {
|
||||
public readonly underlyingClient = new MatrixClientMock();
|
||||
public sentEvents: {roomId: string, content: any}[] = [];
|
||||
|
||||
static create(){
|
||||
constructor(readonly userId: string) {}
|
||||
|
||||
static create(userId: string){
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return new this() as any;
|
||||
return new this(userId) as any;
|
||||
}
|
||||
|
||||
sendText(roomId: string, noticeText: string, msgtype: string) {
|
||||
@ -48,6 +91,10 @@ export class IntentMock {
|
||||
expect(!!this.sentEvents.find(ev => ev.content.body.includes(matcher)), `Expected any event body to match '${matcher}'`).to.be.true;
|
||||
}
|
||||
|
||||
async ensureJoined() {
|
||||
return true;
|
||||
}
|
||||
|
||||
async ensureRegistered() {
|
||||
return true;
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
{
|
||||
"extends": "@tsconfig/node14/tsconfig.json",
|
||||
"extends": "@tsconfig/node16/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"incremental": true,
|
||||
"declaration": false,
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import preact from '@preact/preset-vite'
|
||||
import svgLoader from 'vite-svg-loader'
|
||||
import { resolve } from 'path'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
@ -9,6 +10,12 @@ export default defineConfig({
|
||||
base: '',
|
||||
build: {
|
||||
outDir: '../public',
|
||||
rollupOptions: {
|
||||
input: {
|
||||
main: resolve('web', 'index.html'),
|
||||
oauth: resolve('web', 'oauth.html'),
|
||||
}
|
||||
},
|
||||
emptyOutDir: true,
|
||||
},
|
||||
})
|
||||
|
19
web/App.tsx
19
web/App.tsx
@ -1,9 +1,10 @@
|
||||
/* eslint-disable no-console */
|
||||
import { h, Component } from 'preact';
|
||||
import { Component } from 'preact';
|
||||
import WA, { MatrixCapabilities } from 'matrix-widget-api';
|
||||
import { BridgeAPI, BridgeAPIError } from './BridgeAPI';
|
||||
import { BridgeAPI, BridgeAPIError, EmbedType, embedTypeParameter } from './BridgeAPI';
|
||||
import { BridgeRoomState } from '../src/Widgets/BridgeWidgetInterface';
|
||||
import { ErrorPane } from './components/elements';
|
||||
import { LoadingSpinner } from './components/elements/LoadingSpinner';
|
||||
import { Card, ErrorPane } from './components/elements';
|
||||
import AdminSettings from './components/AdminSettings';
|
||||
import RoomConfigView from './components/RoomConfigView';
|
||||
|
||||
@ -19,6 +20,7 @@ interface ICompleteState extends IMinimalState {
|
||||
[sectionName: string]: boolean;
|
||||
},
|
||||
serviceScope?: string,
|
||||
embedType: EmbedType,
|
||||
kind: "invite"|"admin"|"roomConfig",
|
||||
}
|
||||
|
||||
@ -55,6 +57,7 @@ export default class App extends Component<void, IState> {
|
||||
const roomId = assertParam(qs, 'roomId');
|
||||
const widgetKind = qs.get('kind') as "invite"|"admin"|"roomConfig";
|
||||
const serviceScope = qs.get('serviceScope');
|
||||
const embedType = qs.get(embedTypeParameter);
|
||||
// Fetch via config.
|
||||
this.widgetApi = new WA.WidgetApi(widgetId);
|
||||
this.widgetApi.requestCapability(MatrixCapabilities.RequiresClient);
|
||||
@ -84,6 +87,7 @@ export default class App extends Component<void, IState> {
|
||||
roomId,
|
||||
supportedServices,
|
||||
serviceScope: serviceScope || undefined,
|
||||
embedType: embedType === EmbedType.IntegrationManager ? EmbedType.IntegrationManager : EmbedType.Default,
|
||||
kind: widgetKind,
|
||||
busy: false,
|
||||
});
|
||||
@ -110,7 +114,9 @@ export default class App extends Component<void, IState> {
|
||||
if (this.state.error) {
|
||||
content = <ErrorPane>{this.state.error}</ErrorPane>;
|
||||
} else if (this.state.busy) {
|
||||
content = <div class="spinner" />;
|
||||
content = <Card>
|
||||
<LoadingSpinner />
|
||||
</Card>;
|
||||
}
|
||||
|
||||
if ("kind" in this.state) {
|
||||
@ -123,6 +129,7 @@ export default class App extends Component<void, IState> {
|
||||
roomId={this.state.roomId}
|
||||
supportedServices={this.state.supportedServices}
|
||||
serviceScope={this.state.serviceScope}
|
||||
embedType={this.state.embedType}
|
||||
bridgeApi={this.bridgeApi}
|
||||
widgetApi={this.widgetApi}
|
||||
/>;
|
||||
@ -135,7 +142,9 @@ export default class App extends Component<void, IState> {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<div style={{
|
||||
padding: this.state.embedType === "integration-manager" ? "0" : "16px",
|
||||
}}>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { BridgeRoomState, GetConnectionsForServiceResponse } from '../src/Widgets/BridgeWidgetInterface';
|
||||
import { BridgeRoomState, GetAuthPollResponse, GetAuthResponse, GetConnectionsForServiceResponse } from '../src/Widgets/BridgeWidgetInterface';
|
||||
import { GetConnectionsResponseItem } from "../src/provisioning/api";
|
||||
import { ExchangeOpenAPIRequestBody, ExchangeOpenAPIResponseBody } from "matrix-appservice-bridge";
|
||||
import { WidgetApi } from 'matrix-widget-api';
|
||||
@ -18,6 +18,10 @@ export class BridgeAPIError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
interface RequestOpts {
|
||||
abortController?: AbortController;
|
||||
}
|
||||
|
||||
export class BridgeAPI {
|
||||
static async getBridgeAPI(baseUrl: string, widgetApi: WidgetApi): Promise<BridgeAPI> {
|
||||
const sessionToken = localStorage.getItem('hookshot-sessionToken');
|
||||
@ -70,9 +74,10 @@ export class BridgeAPI {
|
||||
this.baseUrl = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
|
||||
}
|
||||
|
||||
async request(method: string, endpoint: string, body?: unknown) {
|
||||
async request(method: string, endpoint: string, body?: unknown, opts?: RequestOpts) {
|
||||
const res = await fetch(`${this.baseUrl}${endpoint}`, {
|
||||
cache: 'no-cache',
|
||||
signal: opts?.abortController?.signal,
|
||||
method,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
headers: {
|
||||
@ -113,6 +118,10 @@ export class BridgeAPI {
|
||||
return this.request('GET', `/widgetapi/v1/${encodeURIComponent(roomId)}/connections`);
|
||||
}
|
||||
|
||||
async getGoNebConnectionsForRoom(roomId: string): Promise<any|undefined> {
|
||||
return this.request('GET', `/widgetapi/v1/${encodeURIComponent(roomId)}/goNebConnections`);
|
||||
}
|
||||
|
||||
async getConnectionsForService<T extends GetConnectionsResponseItem >(roomId: string, service: string): Promise<GetConnectionsForServiceResponse<T>> {
|
||||
return this.request('GET', `/widgetapi/v1/${encodeURIComponent(roomId)}/connections/${encodeURIComponent(service)}`);
|
||||
}
|
||||
@ -129,13 +138,32 @@ export class BridgeAPI {
|
||||
return this.request('DELETE', `/widgetapi/v1/${encodeURIComponent(roomId)}/connections/${encodeURIComponent(connectionId)}`);
|
||||
}
|
||||
|
||||
getConnectionTargets<R>(type: string, filters?: Record<never, never>|Record<string, string>): Promise<R[]> {
|
||||
getConnectionTargets<R>(type: string, filters?: Record<never, never>|Record<string, string>, abortController?: AbortController): Promise<R[]> {
|
||||
const searchParams = filters && !!Object.keys(filters).length && new URLSearchParams(filters);
|
||||
return this.request('GET', `/widgetapi/v1/targets/${encodeURIComponent(type)}${searchParams ? `?${searchParams}` : ''}`);
|
||||
return this.request('GET', `/widgetapi/v1/targets/${encodeURIComponent(type)}${searchParams ? `?${searchParams}` : ''}`, undefined, { abortController });
|
||||
}
|
||||
|
||||
async getAuth(service: string): Promise<GetAuthResponse> {
|
||||
return this.request('GET', `/widgetapi/v1/service/${service}/auth`);
|
||||
}
|
||||
|
||||
async getAuthPoll(service: string, state: string): Promise<GetAuthPollResponse> {
|
||||
return this.request('GET', `/widgetapi/v1/service/${service}/auth/${state}`);
|
||||
}
|
||||
|
||||
async serviceLogout(service: string): Promise<GetAuthResponse> {
|
||||
return this.request('POST', `/widgetapi/v1/service/${service}/auth/logout`);
|
||||
}
|
||||
}
|
||||
|
||||
export const embedTypeParameter = 'io_element_embed_type';
|
||||
export enum EmbedType {
|
||||
IntegrationManager = 'integration-manager',
|
||||
Default = 'default',
|
||||
}
|
||||
|
||||
export type BridgeConfig = FunctionComponent<{
|
||||
api: BridgeAPI,
|
||||
roomId: string,
|
||||
showHeader: boolean,
|
||||
}>;
|
@ -1,5 +1,5 @@
|
||||
import { h } from "preact";
|
||||
import { useEffect, useState, useCallback } from 'preact/hooks';
|
||||
import { LoadingSpinner } from "./elements/LoadingSpinner";
|
||||
import { BridgeRoomState } from "../../src/Widgets/BridgeWidgetInterface";
|
||||
import GeneralConfig from './configs/GeneralConfig';
|
||||
import style from "./AdminSettings.module.scss";
|
||||
@ -37,7 +37,7 @@ export default function AdminSettings(props: IProps) {
|
||||
);
|
||||
if (busy) {
|
||||
return <div class={style.root}>
|
||||
<div class="spinner" />
|
||||
<LoadingSpinner />
|
||||
</div>;
|
||||
}
|
||||
return <div class={style.root}>
|
||||
|
@ -1,4 +1,3 @@
|
||||
import { h } from "preact";
|
||||
import style from "./ConnectionCard.module.scss";
|
||||
|
||||
interface IProps {
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { h, FunctionComponent } from 'preact';
|
||||
import { FunctionComponent } from 'preact';
|
||||
import { BridgeRoomStateGitHub } from '../../src/Widgets/BridgeWidgetInterface';
|
||||
import "./GitHubState.css";
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { WidgetApi } from "matrix-widget-api";
|
||||
import { useState } from "preact/hooks"
|
||||
import { BridgeAPI, BridgeConfig } from "../BridgeAPI";
|
||||
import { BridgeAPI, BridgeConfig, EmbedType } from "../BridgeAPI";
|
||||
import style from "./RoomConfigView.module.scss";
|
||||
import { ConnectionCard } from "./ConnectionCard";
|
||||
import { FeedsConfig } from "./roomConfig/FeedsConfig";
|
||||
@ -21,6 +21,7 @@ interface IProps {
|
||||
bridgeApi: BridgeAPI,
|
||||
supportedServices: {[service: string]: boolean},
|
||||
serviceScope?: string,
|
||||
embedType: EmbedType,
|
||||
roomId: string,
|
||||
}
|
||||
|
||||
@ -80,7 +81,11 @@ export default function RoomConfigView(props: IProps) {
|
||||
|
||||
if (activeConnectionType) {
|
||||
const ConfigComponent = connections[activeConnectionType].component;
|
||||
content = <ConfigComponent roomId={props.roomId} api={props.bridgeApi} />;
|
||||
content = <ConfigComponent
|
||||
roomId={props.roomId}
|
||||
api={props.bridgeApi}
|
||||
showHeader={props.embedType !== EmbedType.IntegrationManager}
|
||||
/>;
|
||||
} else {
|
||||
content = <>
|
||||
<section>
|
||||
@ -100,13 +105,13 @@ export default function RoomConfigView(props: IProps) {
|
||||
}
|
||||
|
||||
return <div className={style.root}>
|
||||
<header>
|
||||
{!serviceScope && activeConnectionType &&
|
||||
<header>
|
||||
<span className={style.backButton} onClick={() => setActiveConnectionType(null)}>
|
||||
<span className="chevron" /> Browse integrations
|
||||
</span>
|
||||
}
|
||||
</header>
|
||||
}
|
||||
{content}
|
||||
</div>;
|
||||
}
|
||||
|
@ -1,3 +1,7 @@
|
||||
.icon {
|
||||
width: 48px;
|
||||
}
|
||||
|
||||
.serviceCard {
|
||||
display: grid !important;
|
||||
grid-template-columns: 0.6fr 1fr 1fr;
|
||||
|
@ -1,10 +1,10 @@
|
||||
import { h, FunctionComponent } from "preact";
|
||||
import { FunctionComponent } from "preact";
|
||||
import style from "./ServiceCard.module.scss";
|
||||
|
||||
|
||||
export const ServiceCard: FunctionComponent<{serviceName: string, iconUrl: string, onConfigure: () => void}> = ({ serviceName, iconUrl, onConfigure }) => {
|
||||
return <div className={`card ${style.serviceCard}`}>
|
||||
<img style="width: 48px;" src={iconUrl} />
|
||||
<img className={style.icon} src={iconUrl} />
|
||||
<div>
|
||||
<span>{serviceName}</span>
|
||||
<button onClick={onConfigure}>Configure</button>
|
||||
|
@ -1,4 +1,3 @@
|
||||
import { h } from "preact";
|
||||
import { Button } from "../elements";
|
||||
|
||||
export default function GeneralConfig() {
|
||||
|
@ -1,7 +1,11 @@
|
||||
import { FunctionComponent, h } from "preact";
|
||||
import style from "./Button.module.scss";
|
||||
|
||||
export const Button: FunctionComponent = (props: { [key: string]: unknown, intent?: string}) => {
|
||||
interface ButtonProps extends h.JSX.HTMLAttributes<HTMLButtonElement> {
|
||||
intent?: string;
|
||||
}
|
||||
|
||||
export const Button: FunctionComponent = (props: ButtonProps) => {
|
||||
let className = style.button;
|
||||
if (props.intent === "remove") {
|
||||
className += ` ${style.remove}`;
|
||||
|
11
web/components/elements/Card.module.scss
Normal file
11
web/components/elements/Card.module.scss
Normal file
@ -0,0 +1,11 @@
|
||||
.card {
|
||||
/* Compound/Light/Background */
|
||||
background: #FFFFFF;
|
||||
|
||||
/* Compound/Light/Quinary Content */
|
||||
border: 1px solid #E3E8F0;
|
||||
box-sizing: border-box;
|
||||
border-radius: 8px;
|
||||
|
||||
padding: 32px;
|
||||
}
|
11
web/components/elements/Card.tsx
Normal file
11
web/components/elements/Card.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
import React from 'preact';
|
||||
|
||||
import styles from './Card.module.scss';
|
||||
|
||||
const Card = (props: React.ComponentProps<'div'>) =>
|
||||
<div
|
||||
{...props}
|
||||
className={styles.card}
|
||||
/>;
|
||||
|
||||
export { Card };
|
142
web/components/elements/ConnectionSearch.tsx
Normal file
142
web/components/elements/ConnectionSearch.tsx
Normal file
@ -0,0 +1,142 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "preact/hooks";
|
||||
import { BridgeAPIError } from "../../BridgeAPI";
|
||||
import { DropdownSearch, DropItem } from "./DropdownSearch";
|
||||
import { ErrorPane } from "./ErrorPane";
|
||||
import { InputField } from "./InputField";
|
||||
|
||||
interface Instance {
|
||||
name: string;
|
||||
}
|
||||
|
||||
type Project = DropItem;
|
||||
|
||||
interface IProps {
|
||||
serviceName: string;
|
||||
addNewInstanceUrl?: string;
|
||||
getInstances(): Promise<Instance[]>;
|
||||
getProjects(currentInstance: string, searchTerm?: string, abortController?: AbortController): Promise<Project[]>;
|
||||
onPicked: (instanceValue: string, projectValue: string) => void;
|
||||
onClear: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* This component is designed to generically fetch a bunch of instances for a given connection type
|
||||
* and then a list of projects associated with that instance. The user should be able to pick from
|
||||
* that list.
|
||||
* @param props
|
||||
* @returns
|
||||
*/
|
||||
export function ConnectionSearch({
|
||||
serviceName,
|
||||
addNewInstanceUrl,
|
||||
onPicked,
|
||||
onClear,
|
||||
getInstances,
|
||||
getProjects,
|
||||
}: IProps) {
|
||||
const [currentInstance, setCurrentInstance] = useState<string>("");
|
||||
const [instances, setInstances] = useState<Instance[]|null>(null);
|
||||
const [searchError, setSearchError] = useState<string|null>(null);
|
||||
const [exampleProjectName, setExampleProjectName] = useState<string>("Loading...");
|
||||
|
||||
useEffect(() => {
|
||||
getInstances().then(res => {
|
||||
setInstances(res);
|
||||
setCurrentInstance(res[0]?.name ?? '');
|
||||
}).catch(ex => {
|
||||
if (ex instanceof BridgeAPIError && ex.errcode === "HS_FORBIDDEN_USER") {
|
||||
setSearchError(`You are not logged into ${serviceName}.`);
|
||||
return;
|
||||
}
|
||||
setSearchError(`Could not load ${serviceName} instances.`);
|
||||
console.warn(`Failed to get connection targets from query:`, ex);
|
||||
});
|
||||
}, [getInstances, serviceName]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentInstance) {
|
||||
return;
|
||||
}
|
||||
getProjects(currentInstance).then(res => {
|
||||
setExampleProjectName(res[0]?.value ?? "my-org/my-example-project");
|
||||
}).catch(ex => {
|
||||
setSearchError(`Could not load ${serviceName} projects for instance`);
|
||||
console.warn(`Failed to get connection targets from query:`, ex);
|
||||
});
|
||||
}, [currentInstance, getProjects, serviceName]);
|
||||
|
||||
const searchFn = useCallback(async(terms: string, { instance }: { instance: string }, abortController: AbortController) => {
|
||||
try {
|
||||
const res = await getProjects(instance, terms, abortController);
|
||||
return res.map((item) => ({
|
||||
description: item.description,
|
||||
imageSrc: item.imageSrc,
|
||||
title: item.title,
|
||||
value: item.value,
|
||||
}) as DropItem);
|
||||
} catch (ex) {
|
||||
setSearchError("There was an error fetching search results.");
|
||||
// Rather than raising an error, let's just log and let the user retry a query.
|
||||
console.warn(`Failed to get connection targets from query:`, ex);
|
||||
return [];
|
||||
}
|
||||
}, [getProjects]);
|
||||
|
||||
const onInstancePicked = useCallback((evt: {target: EventTarget|null}) => {
|
||||
// Reset everything
|
||||
setCurrentInstance((evt.target as HTMLSelectElement).selectedOptions[0].value);
|
||||
onClear();
|
||||
}, [onClear]);
|
||||
|
||||
const instanceListResults = useMemo(
|
||||
() => instances?.map(i => <option key={i.name}>{i.name}</option>),
|
||||
[instances]
|
||||
);
|
||||
|
||||
const onProjectPicked = useCallback((value: string|null) => {
|
||||
if (value === null) {
|
||||
onClear();
|
||||
return;
|
||||
}
|
||||
if (!currentInstance) {
|
||||
throw Error('Should never pick a project without an instance');
|
||||
}
|
||||
onPicked(currentInstance, value);
|
||||
}, [currentInstance, onClear, onPicked]);
|
||||
|
||||
const searchProps = useMemo(() => ({ instance: currentInstance }), [currentInstance]);
|
||||
|
||||
let addNewInstance = null;
|
||||
if (instances?.length === 0) {
|
||||
if (addNewInstanceUrl) {
|
||||
addNewInstance = <p> You have not connected any {serviceName} instances.
|
||||
<a href={addNewInstanceUrl} rel="noreferrer" target="_blank">Add a new instances</a>.
|
||||
</p>;
|
||||
} else {
|
||||
addNewInstance = <p> You have not connected any {serviceName} instances.</p>;
|
||||
}
|
||||
} else if (addNewInstanceUrl) {
|
||||
addNewInstance = <p><a href={addNewInstanceUrl} rel="noreferrer" target="_blank">Add a new instances</a>.</p>
|
||||
} // otherwise, empty
|
||||
|
||||
return <div>
|
||||
{!searchError && instances === null && <p> Loading {serviceName} instances. </p>}
|
||||
{searchError && <ErrorPane header="Search error"> {searchError} </ErrorPane> }
|
||||
<InputField visible={!!instances?.length} label={`${serviceName} Instance`} noPadding={true}>
|
||||
<select onChange={onInstancePicked}>
|
||||
{instanceListResults}
|
||||
</select>
|
||||
</InputField>
|
||||
{ addNewInstance }
|
||||
{ currentInstance && <InputField label="Project" noPadding={true}>
|
||||
<DropdownSearch
|
||||
placeholder={`Your project name, such as ${exampleProjectName}`}
|
||||
searchFn={searchFn}
|
||||
searchProps={searchProps}
|
||||
onChange={onProjectPicked}
|
||||
/>
|
||||
</InputField> }
|
||||
</div>;
|
||||
}
|
||||
|
||||
export default ConnectionSearch;
|
41
web/components/elements/DropdownSearch.module.scss
Normal file
41
web/components/elements/DropdownSearch.module.scss
Normal file
@ -0,0 +1,41 @@
|
||||
.dropdownItem {
|
||||
padding: 0.25em 0.5em;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
|
||||
background-color: rgba(54, 54, 54, 0.138);
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.description {
|
||||
/* These work cross browser */
|
||||
// https://developer.mozilla.org/en-US/docs/Web/CSS/-webkit-line-clamp
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 3;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-weight: 400;
|
||||
color: var(--light-color);
|
||||
}
|
||||
|
||||
.hasImg {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
grid-gap: 1em;
|
||||
}
|
||||
|
||||
.itemImage {
|
||||
width: auto;
|
||||
max-height: 48px;
|
||||
margin-top: auto;
|
||||
margin-bottom: auto;
|
||||
}
|
103
web/components/elements/DropdownSearch.tsx
Normal file
103
web/components/elements/DropdownSearch.tsx
Normal file
@ -0,0 +1,103 @@
|
||||
import { FunctionComponent } from "preact";
|
||||
import { useCallback, useEffect, useState } from "preact/hooks";
|
||||
import style from "./DropdownSearch.module.scss";
|
||||
|
||||
interface Props<T> {
|
||||
searchFn: (searchTerm: string, additionalProps: T, abortController: AbortController) => Promise<DropItem[]>;
|
||||
searchProps: T,
|
||||
placeholder?: string,
|
||||
onChange: (value: string|null) => void;
|
||||
onError?: (error: unknown) => void;
|
||||
}
|
||||
|
||||
export interface DropItem {
|
||||
value: string;
|
||||
title: string;
|
||||
imageSrc?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
const DEBOUNCE_TIMEOUT_MS = 750;
|
||||
|
||||
export const DropdownItem: FunctionComponent<DropItem&{onPicked?: (value: string) => void}> = ({ imageSrc, title, value, description, onPicked }) => {
|
||||
// Need placeholder image.
|
||||
return <li className={`card ${style.dropdownItem} ${imageSrc ? style.hasImg : ''}`} role="button" onClick={() => onPicked?.(value)}>
|
||||
{ imageSrc && <img className={style.itemImage} src={imageSrc} /> }
|
||||
<div>
|
||||
<p className={style.title}>{title} <span className={style.value}>{value}</span></p>
|
||||
<p className={style.description}>{description}</p>
|
||||
</div>
|
||||
</li>;
|
||||
};
|
||||
|
||||
export const DropdownSearch = function<T>({searchFn, searchProps, onChange, onError, placeholder}: Props<T>) {
|
||||
const [selectedItem, setSelectedItem] = useState<DropItem|null>();
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [results, setResults] = useState<DropItem[]|null>();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Reset if the search properties are altered.
|
||||
useEffect(() => {
|
||||
setSearchTerm("");
|
||||
setSelectedItem(null);
|
||||
}, [searchProps]);
|
||||
|
||||
// Search whenever the term is updated.
|
||||
useEffect(() => {
|
||||
if (searchTerm.trim().length === 0) {
|
||||
return;
|
||||
}
|
||||
if (selectedItem) {
|
||||
// Clear any selected items
|
||||
setSelectedItem(null);
|
||||
onChange(null);
|
||||
}
|
||||
const controller = new AbortController();
|
||||
// Browser types
|
||||
const debounceTimer = setTimeout(() => {
|
||||
setLoading(true);
|
||||
searchFn(searchTerm, searchProps, controller).then(result => {
|
||||
setResults(result);
|
||||
}).catch(err => {
|
||||
onError?.(err);
|
||||
}).finally(() => {
|
||||
setLoading(false);
|
||||
})
|
||||
}, DEBOUNCE_TIMEOUT_MS);
|
||||
return () => {
|
||||
controller.abort();
|
||||
clearTimeout(debounceTimer);
|
||||
}
|
||||
}, [searchTerm, onChange, setResults, onError, searchProps, selectedItem, searchFn]);
|
||||
|
||||
const onSearchInputChange = useCallback((event: {target: EventTarget|null}) => {
|
||||
const terms = (event.target as HTMLInputElement).value;
|
||||
setSearchTerm(terms);
|
||||
}, []);
|
||||
|
||||
const onItemPicked = useCallback((item: DropItem) => {
|
||||
onChange(item.value);
|
||||
setResults([]);
|
||||
setSearchTerm("");
|
||||
setSelectedItem(item);
|
||||
}, [onChange]);
|
||||
|
||||
const onItemCleared = useCallback(() => {
|
||||
onChange(null);
|
||||
setResults([]);
|
||||
setSearchTerm("");
|
||||
setSelectedItem(null);
|
||||
}, [onChange]);
|
||||
|
||||
return <>
|
||||
{selectedItem && <DropdownItem {...selectedItem} onPicked={onItemCleared} />}
|
||||
{!selectedItem && <input type="search" placeholder={placeholder} onChange={onSearchInputChange} value={searchTerm} />}
|
||||
{loading && <p> Searching... </p>}
|
||||
{!loading && !selectedItem && searchTerm && results?.length === 0 && <p> No results found. </p>}
|
||||
<ul>
|
||||
{
|
||||
results?.map(item => <DropdownItem key={item.value} {...item} onPicked={ () => onItemPicked(item) } />)
|
||||
}
|
||||
</ul>
|
||||
</>;
|
||||
};
|
@ -1,4 +1,4 @@
|
||||
import { h, FunctionComponent } from "preact";
|
||||
import { FunctionComponent } from "preact";
|
||||
import ErrorBadge from "../../icons/error-badge.svg";
|
||||
import style from "./ErrorPane.module.scss";
|
||||
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user