├── .dockerignore ├── .envrc ├── .github ├── CODEOWNERS ├── pull_request_template.md └── workflows │ ├── devenv.yml │ ├── docker.yml │ ├── docs-latest.yml │ ├── docs-release.yml │ ├── helm-lint.yml │ ├── helm.yml │ ├── main.yml │ ├── newsfile.yml │ ├── release.yml │ ├── sign-off.yml │ └── triage-incoming.yml ├── .gitignore ├── .node-version ├── .vscode └── settings.json ├── .well-known └── funding-manifest-urls ├── CHANGELOG.md ├── CONTRIBUTING.md ├── Cargo.lock ├── Cargo.toml ├── Dockerfile ├── LICENSE ├── README.md ├── assets ├── feeds_avatar.png ├── figma_avatar.png ├── github_avatar.png ├── gitlab_avatar.png ├── jira_avatar.png ├── openproject_avatar.png ├── src │ ├── feeds_avatar.svg │ ├── figma_avatar.svg │ ├── github_avatar.svg │ ├── gitlab_avatar.svg │ ├── jira_avatar.svg │ └── webhooks_avatar.svg └── webhooks_avatar.png ├── book.toml ├── build.rs ├── changelog.d ├── .gitkeep ├── 1004.feature ├── 1062.feature └── 1063.misc ├── config.sample.yml ├── contrib ├── hookshot-dashboard.json └── jsTransformationFunctions │ └── alertmanager.js ├── devenv.lock ├── devenv.nix ├── devenv.yaml ├── docker-compose.yml ├── docs ├── SUMMARY.md ├── _site │ ├── icons │ │ ├── feeds.png │ │ ├── figma.png │ │ ├── github.png │ │ ├── gitlab.png │ │ ├── hound.png │ │ ├── jira.png │ │ ├── sentry.png │ │ └── webhooks.png │ ├── main.js │ ├── style.css │ └── version.js ├── advanced │ ├── encryption.md │ ├── service_bots.md │ ├── widgets.md │ ├── widgets.png │ └── workers.md ├── contributing.md ├── hookshot.md ├── metrics.md ├── sentry.md ├── setup.md ├── setup │ ├── challengehound.md │ ├── feeds.md │ ├── figma.md │ ├── github.md │ ├── gitlab.md │ ├── jira.md │ ├── openproject.md │ ├── sample-configuration.md │ └── webhooks.md ├── troubleshooting.md ├── usage.md └── usage │ ├── auth.md │ ├── dynamic_rooms.md │ ├── room_configuration.md │ └── room_configuration │ ├── connections1.svg │ ├── github_repo.md │ ├── gitlab_project.md │ └── jira_project.md ├── eslint.config.mjs ├── helm ├── cr.yaml ├── ct.yaml └── hookshot │ ├── .gitignore │ ├── .helmignore │ ├── .yamllint │ ├── Chart.yaml │ ├── README.md │ ├── README.md.gotmpl │ ├── templates │ ├── NOTES.txt │ ├── _helpers.tpl │ ├── _pod.tpl │ ├── configmap.yaml │ ├── deployment.yaml │ ├── hpa.yaml │ ├── ingress-appservice.yaml │ ├── ingress.yaml │ ├── service.yaml │ ├── serviceaccount.yaml │ └── tests │ │ └── test-connection.yaml │ └── values.yaml ├── logo.png ├── logo.svg ├── package.json ├── pyproject.toml ├── registration.sample.yml ├── screenshot.png ├── scripts ├── build-app.sh ├── build-metrics-docs.ts ├── changelog.sh ├── clean.sh ├── definitions-fixer.ts └── release.sh ├── spec ├── basic.spec.ts ├── e2ee.spec.ts ├── generic-hooks.spec.ts ├── github.spec.ts ├── gitlab.spec.ts ├── jira.spec.ts ├── openproject.spec.ts ├── permissions.spec.ts ├── util │ ├── bridge-api.ts │ ├── containers.ts │ ├── e2e-test.ts │ ├── fixtures.ts │ ├── helpers.ts │ └── homerunner.ts ├── webhooks.spec.ts └── widgets.spec.ts ├── src ├── AdminRoom.ts ├── AdminRoomCommandHandler.ts ├── App │ ├── BridgeApp.ts │ ├── GithubWebhookApp.ts │ ├── MatrixSenderApp.ts │ └── ResetCryptoStore.ts ├── Appservice.ts ├── BotCommands.ts ├── Bridge.ts ├── CommentProcessor.ts ├── ConnectionManager.ts ├── Connections │ ├── BaseConnection.ts │ ├── CommandConnection.ts │ ├── FeedConnection.ts │ ├── FigmaFileConnection.ts │ ├── GenericHook.ts │ ├── GithubDiscussion.ts │ ├── GithubDiscussionSpace.ts │ ├── GithubIssue.ts │ ├── GithubProject.ts │ ├── GithubRepo.ts │ ├── GithubUserSpace.ts │ ├── GitlabIssue.ts │ ├── GitlabRepo.ts │ ├── HoundConnection.ts │ ├── IConnection.ts │ ├── JiraProject.ts │ ├── OpenProjectConnection.ts │ ├── OutboundHook.ts │ ├── SetupConnection.ts │ └── index.ts ├── Errors.ts ├── FormatUtil.ts ├── HookFilter.ts ├── IntentUtils.ts ├── ListenerService.ts ├── MatrixEvent.ts ├── MatrixSender.ts ├── Metrics.ts ├── NotificationFilters.ts ├── NotificationsProcessor.ts ├── PromiseUtil.ts ├── UserAgent.ts ├── Webhooks.ts ├── api │ ├── Error.ts │ └── index.ts ├── config │ ├── Config.ts │ ├── Decorators.ts │ ├── Defaults.ts │ ├── mod.rs │ ├── permissions.rs │ └── sections │ │ ├── Cache.ts │ │ ├── Encryption.ts │ │ ├── GenericHooks.ts │ │ ├── OpenProject.ts │ │ ├── Queue.ts │ │ └── index.ts ├── feeds │ ├── FeedReader.ts │ ├── mod.rs │ └── parser.rs ├── figma │ ├── Router.ts │ ├── Types.ts │ └── index.ts ├── format_util.rs ├── generic │ ├── Router.ts │ ├── Types.ts │ └── WebhookTransformer.ts ├── github │ ├── AdminCommands.ts │ ├── Discussion.ts │ ├── GithubInstance.ts │ ├── GrantChecker.ts │ ├── Router.ts │ ├── Types.ts │ ├── mod.rs │ └── types.rs ├── gitlab │ ├── Client.ts │ ├── GrantChecker.ts │ ├── Router.ts │ ├── Types.ts │ └── WebhookTypes.ts ├── grants │ └── GrantCheck.ts ├── hound │ └── HoundReader.ts ├── jira │ ├── AdminCommands.ts │ ├── Client.ts │ ├── GrantChecker.ts │ ├── OAuth.ts │ ├── Router.ts │ ├── Types.ts │ ├── WebhookTypes.ts │ ├── client │ │ ├── CloudClient.ts │ │ └── OnPremClient.ts │ ├── index.ts │ ├── mod.rs │ ├── oauth │ │ ├── CloudOAuth.ts │ │ └── OnPremOAuth.ts │ ├── types.rs │ └── utils.rs ├── lib.rs ├── libRs.js ├── managers │ └── BotUsersManager.ts ├── messageQueue │ ├── LocalMQ.ts │ ├── MessageQueue.ts │ ├── RedisQueue.ts │ ├── Types.ts │ └── index.ts ├── notifications │ ├── GitHubWatcher.ts │ ├── GitLabWatcher.ts │ ├── NotificationWatcherTask.ts │ └── UserNotificationWatcher.ts ├── openproject │ ├── AdminCommands.ts │ ├── Client.ts │ ├── Format.ts │ ├── GrantChecker.ts │ ├── Oauth.ts │ ├── Router.ts │ ├── State.ts │ ├── Types.ts │ └── WidgetApi.ts ├── stores │ ├── MemoryStorageProvider.ts │ ├── RedisStorageProvider.ts │ └── StorageProvider.ts ├── tokens │ ├── Oauth.ts │ ├── UserTokenStore.ts │ └── mod.rs ├── util │ └── mod.rs └── widgets │ ├── Api.ts │ ├── BridgeWidgetApi.ts │ ├── BridgeWidgetInterface.ts │ ├── SetupWidget.ts │ └── WidgetKind.ts ├── tests ├── AdminRoomTest.ts ├── BotCommands.spec.ts ├── FeedReader.spec.ts ├── FormatUtilTest.ts ├── HookFilter.ts ├── IntentUtilsTest.ts ├── MessageQueueTest.ts ├── config │ ├── config.ts │ └── permissions.ts ├── connections │ ├── FeedTest.spec.ts │ ├── GenericHookTest.ts │ ├── GithubRepoTest.ts │ └── GitlabRepoTest.ts ├── github │ └── AdminCommands.ts ├── grants │ └── GrantChecker.spec.ts ├── init.ts ├── jira │ └── Utils.ts ├── tokens │ └── tokenencryption.spec.ts └── utils │ ├── AppserviceMock.ts │ └── IntentMock.ts ├── tsconfig.json ├── tsconfig.spec.json ├── vite.config.mjs ├── vitest.config.ts ├── web ├── App.tsx ├── BridgeAPI.ts ├── components │ ├── AdminSettings.module.scss │ ├── AdminSettings.tsx │ ├── ConnectionCard.module.scss │ ├── ConnectionCard.tsx │ ├── GitHubState.css │ ├── GitHubState.tsx │ ├── RoomConfigView.module.scss │ ├── RoomConfigView.tsx │ ├── ServiceCard.module.scss │ ├── ServiceCard.tsx │ ├── configs │ │ └── GeneralConfig.tsx │ ├── elements │ │ ├── Button.module.scss │ │ ├── Button.tsx │ │ ├── ButtonSet.module.scss │ │ ├── ButtonSet.tsx │ │ ├── Card.module.scss │ │ ├── Card.tsx │ │ ├── ConnectionSearch.tsx │ │ ├── DropdownSearch.module.scss │ │ ├── DropdownSearch.tsx │ │ ├── EventHookCheckbox.module.scss │ │ ├── EventHookCheckbox.tsx │ │ ├── InputField.module.scss │ │ ├── InputField.tsx │ │ ├── ListItem.module.scss │ │ ├── ListItem.tsx │ │ ├── LoadingSpinner.module.scss │ │ ├── LoadingSpinner.tsx │ │ └── index.ts │ └── roomConfig │ │ ├── Auth.tsx │ │ ├── FeedConnection.module.scss │ │ ├── FeedsConfig.tsx │ │ ├── GenericWebhookConfig.tsx │ │ ├── GithubRepoConfig.tsx │ │ ├── GitlabRepoConfig.tsx │ │ ├── JiraProjectConfig.tsx │ │ ├── OpenProjectConfig.tsx │ │ ├── OutboundWebhookConfig.tsx │ │ ├── RoomConfig.module.scss │ │ └── RoomConfig.tsx ├── context.ts ├── fonts │ └── fonts.scss ├── icons │ ├── feeds.png │ ├── github.png │ ├── gitlab.png │ ├── jira.png │ ├── openproject.png │ └── webhook.png ├── index.html ├── index.tsx ├── oauth.html ├── oauth.scss ├── oauth.tsx ├── styling.scss ├── tsconfig.json └── typings │ ├── images.d.ts │ └── sass.d.ts └── yarn.lock /.dockerignore: -------------------------------------------------------------------------------- 1 | .github/ 2 | *.pem 3 | config.yml 4 | lib/ 5 | node_modules/ 6 | public/ 7 | registration.yml 8 | tests/ 9 | tsconfig.tsbuildinfo 10 | 11 | # Added by cargo 12 | /target -------------------------------------------------------------------------------- /.envrc: -------------------------------------------------------------------------------- 1 | source_url "https://raw.githubusercontent.com/cachix/devenv/95f329d49a8a5289d31e0982652f7058a189bfca/direnvrc" "sha256-d+8cBpDfDBj41inrADaJt+bDWhOktwslgoP5YiGJ1v0=" 2 | 3 | use devenv -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @matrix-org/bridges 2 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /.github/workflows/devenv.yml: -------------------------------------------------------------------------------- 1 | name: Devenv 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | paths: 7 | - "/devenv*" 8 | pull_request: 9 | branches: [ main ] 10 | paths: 11 | - "/devenv*" 12 | 13 | jobs: 14 | test-nix-env: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v4 18 | - uses: cachix/install-nix-action@v26 19 | - uses: cachix/cachix-action@v14 20 | with: 21 | name: devenv 22 | - name: Install devenv.sh 23 | run: nix profile install nixpkgs#devenv 24 | - name: Build the devenv shell and run any git hooks 25 | run: devenv test -------------------------------------------------------------------------------- /.github/workflows/docs-latest.yml: -------------------------------------------------------------------------------- 1 | name: Build docs 2 | 3 | on: 4 | push: 5 | paths-ignore: 6 | - changelog.d/**' 7 | 8 | concurrency: 9 | group: ${{ github.workflow }}-${{ github.ref }} 10 | cancel-in-progress: true 11 | 12 | jobs: 13 | deploy: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v3 17 | 18 | - name: Use Node.js 19 | uses: actions/setup-node@v3 20 | with: 21 | node-version-file: .node-version 22 | 23 | # We want to install matrix-appservice-bridge, which we depend on. 24 | - run: yarn --ignore-scripts 25 | 26 | - name: Setup mdBook 27 | uses: peaceiris/actions-mdbook@v1 28 | with: 29 | mdbook-version: '0.4.11' 30 | 31 | - run: mdbook build 32 | 33 | - name: Deploy latest 34 | uses: peaceiris/actions-gh-pages@v3 35 | # Only push if this is main, otherwise we just want to build 36 | if: github.ref == 'refs/heads/main' 37 | with: 38 | github_token: ${{ secrets.GITHUB_TOKEN }} 39 | keep_files: true 40 | publish_dir: ./book 41 | destination_dir: ./latest 42 | -------------------------------------------------------------------------------- /.github/workflows/docs-release.yml: -------------------------------------------------------------------------------- 1 | name: Build docs 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | concurrency: 8 | group: ${{ github.workflow }}-${{ github.ref }} 9 | cancel-in-progress: true 10 | 11 | jobs: 12 | deploy: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v3 16 | 17 | - name: Use Node.js 18 | uses: actions/setup-node@v3 19 | with: 20 | node-version-file: .node-version 21 | 22 | # We want to install matrix-appservice-bridge, which we depend on. 23 | - run: yarn --ignore-scripts 24 | 25 | - name: Get release tag 26 | run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV 27 | 28 | - name: Set up mdBook 29 | uses: peaceiris/actions-mdbook@v1 30 | with: 31 | mdbook-version: '0.4.11' 32 | 33 | - name: Set version of docs 34 | run: echo 'window.HOOKSHOT_VERSION = "${{ env.RELEASE_VERSION }}";' > ./docs/_site/version.js 35 | 36 | - run: mdbook build 37 | 38 | - name: Deploy latest 39 | uses: peaceiris/actions-gh-pages@v3 40 | with: 41 | github_token: ${{ secrets.GITHUB_TOKEN }} 42 | keep_files: true 43 | publish_dir: ./book 44 | destination_dir: ./${{ env.RELEASE_VERSION }} 45 | -------------------------------------------------------------------------------- /.github/workflows/helm-lint.yml: -------------------------------------------------------------------------------- 1 | name: Helm Chart - Validate 2 | on: 3 | push: 4 | branches: [ main ] 5 | paths-ignore: 6 | - changelog.d/**' 7 | pull_request: 8 | branches: [ main ] 9 | paths-ignore: 10 | - changelog.d/**' 11 | 12 | workflow_dispatch: 13 | jobs: 14 | lint-helm: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v3 18 | 19 | - name: Lint Helm 20 | uses: WyriHaximus/github-action-helm3@v3 21 | with: 22 | exec: helm lint ./helm/hookshot/ 23 | 24 | - name: Validate 25 | uses: nlamirault/helm-kubeconform-action@v0.1.0 26 | with: 27 | charts: ./helm/ -------------------------------------------------------------------------------- /.github/workflows/helm.yml: -------------------------------------------------------------------------------- 1 | name: Helm Chart - Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths: 8 | - 'helm/**' # only execute if we have helm chart changes 9 | workflow_dispatch: 10 | 11 | jobs: 12 | release: 13 | # depending on default permission settings for your org (contents being read-only or read-write for workloads), you will have to add permissions 14 | # see: https://docs.github.com/en/actions/security-guides/automatic-token-authentication#modifying-the-permissions-for-the-github_token 15 | permissions: 16 | contents: write 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: Checkout 20 | uses: actions/checkout@v3 21 | with: 22 | fetch-depth: 0 23 | 24 | - name: Configure Git 25 | run: | 26 | git config user.name "$GITHUB_ACTOR" 27 | git config user.email "$GITHUB_ACTOR@users.noreply.github.com" 28 | - name: Install Helm 29 | uses: azure/setup-helm@v3 30 | with: 31 | version: v3.10.0 32 | 33 | - name: "Get app version from package.json" 34 | id: get_hookshot_version 35 | run: | 36 | echo "hookshot_version=$(cat package.json | yq .version)" >> "$GITHUB_OUTPUT" 37 | 38 | - name: Set chart appVersion to current package.json version 39 | uses: mikefarah/yq@v4.34.1 40 | with: 41 | cmd: | 42 | yq -i '.appVersion="${{steps.get_hookshot_version.outputs.hookshot_version}}"' helm/hookshot/Chart.yaml 43 | 44 | - name: Set values hookshot config to current config.sample.yml contents 45 | uses: mikefarah/yq@v4.34.1 46 | with: 47 | cmd: | 48 | yq -i eval-all 'select(fileIndex==0).hookshot.config = select(fileIndex==1) | select(fileIndex==0)' helm/hookshot/values.yaml config.sample.yml 49 | 50 | - name: Run chart-releaser 51 | uses: helm/chart-releaser-action@v1.5.0 52 | env: 53 | CR_TOKEN: "${{ secrets.GITHUB_TOKEN }}" 54 | with: 55 | config: helm/cr.yaml 56 | charts_dir: helm/ -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | paths-ignore: 7 | - changelog.d/**' 8 | pull_request: 9 | branches: [ main ] 10 | paths-ignore: 11 | - changelog.d/**' 12 | 13 | workflow_dispatch: 14 | merge_group: 15 | 16 | 17 | concurrency: 18 | group: ${{ github.workflow }}-${{ github.ref }} 19 | cancel-in-progress: true 20 | 21 | jobs: 22 | lint-node: 23 | runs-on: ubuntu-latest 24 | steps: 25 | - uses: actions/checkout@v3 26 | - name: Use Node.js 27 | uses: actions/setup-node@v3 28 | with: 29 | node-version-file: .node-version 30 | - run: yarn --ignore-scripts 31 | - run: yarn lint:js 32 | 33 | lint-rust: 34 | runs-on: ubuntu-latest 35 | steps: 36 | - uses: actions/checkout@v3 37 | - uses: actions-rust-lang/setup-rust-toolchain@v1 38 | with: 39 | toolchain: stable 40 | components: rustfmt 41 | - run: cargo fmt --all -- --check 42 | - run: cargo clippy -- -Dwarnings 43 | 44 | config: 45 | runs-on: ubuntu-latest 46 | steps: 47 | - uses: actions/checkout@v3 48 | - name: Use Node.js 49 | uses: actions/setup-node@v3 50 | with: 51 | node-version-file: .node-version 52 | - run: yarn # Need to build scripts to get rust bindings 53 | - run: yarn --silent ts-node src/config/Defaults.ts --config | diff config.sample.yml - 54 | 55 | metrics-docs: 56 | runs-on: ubuntu-latest 57 | steps: 58 | - uses: actions/checkout@v3 59 | - name: Use Node.js 60 | uses: actions/setup-node@v3 61 | with: 62 | node-version-file: .node-version 63 | - run: yarn # Need to build scripts to get rust bindings 64 | - run: yarn --silent ts-node ./scripts/build-metrics-docs.ts | diff docs/metrics.md - 65 | 66 | test: 67 | # Test on LTS-1 68 | runs-on: ubuntu-22.04 69 | strategy: 70 | matrix: 71 | node_version: [22, 23] 72 | steps: 73 | - uses: actions/checkout@v4 74 | - name: Use Node.js ${{ matrix.node_version }} 75 | uses: actions/setup-node@v3 76 | with: 77 | node-version: ${{ matrix.node_version }} 78 | - uses: actions-rust-lang/setup-rust-toolchain@v1 79 | with: 80 | toolchain: stable 81 | - uses: Swatinem/rust-cache@v2 82 | with: 83 | shared-key: ubuntu-2204-rust-cache 84 | - run: yarn 85 | - run: yarn test:cover 86 | 87 | integration-test: 88 | # Test on LTS-1 89 | runs-on: ubuntu-22.04 90 | timeout-minutes: 30 91 | needs: 92 | - test 93 | steps: 94 | - uses: actions/checkout@v4 95 | - uses: actions/setup-node@v3 96 | with: 97 | node-version-file: .node-version 98 | - uses: actions-rust-lang/setup-rust-toolchain@v1 99 | with: 100 | toolchain: stable 101 | - uses: Swatinem/rust-cache@v2 102 | with: 103 | shared-key: ubuntu-2204-rust-cache 104 | - run: yarn 105 | - run: yarn test:e2e -------------------------------------------------------------------------------- /.github/workflows/newsfile.yml: -------------------------------------------------------------------------------- 1 | name: Newsfile 2 | 3 | on: 4 | pull_request: 5 | branches: [ main ] 6 | merge_group: 7 | 8 | jobs: 9 | changelog: 10 | runs-on: ubuntu-latest 11 | # No newsfile required for dependabot 12 | if: ${{ github.actor != 'dependabot[bot]' }} 13 | steps: 14 | - uses: actions/checkout@v3 15 | with: # Needed for comparison 16 | fetch-depth: 0 17 | - uses: actions/setup-python@v1 18 | with: 19 | python-version: '3.9' 20 | - run: pip install towncrier==21.9.0 21 | - name: ":newspaper: Newsfile" 22 | run: python3 -m towncrier.check --compare-with=origin/main 23 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | tags: ["[0-9].[0-9].[0-9]"] 4 | 5 | jobs: 6 | draft-release: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v3 10 | - name: Get changelog 11 | id: extract-changelog 12 | env: 13 | REF_NAME: ${{ github.ref_name }} 14 | run: | 15 | git fetch --tags --force 16 | RELEASE_NAME="${REF_NAME} $(date +'%Y-%m-%d')" 17 | git tag -l --format='%(contents:body)' "${REF_NAME}" > next-changelog.txt 18 | echo "RELEASE_NAME=$RELEASE_NAME" >> $GITHUB_OUTPUT 19 | - name: Create Release 20 | uses: softprops/action-gh-release@v1 21 | with: 22 | tag_name: ${{ github.ref }} 23 | name: ${{ steps.extract-changelog.outputs.RELEASE_NAME }} 24 | body_path: next-changelog.txt 25 | draft: true 26 | token: ${{ secrets.GITHUB_TOKEN }} 27 | -------------------------------------------------------------------------------- /.github/workflows/sign-off.yml: -------------------------------------------------------------------------------- 1 | name: Contribution requirements 2 | 3 | on: 4 | pull_request: 5 | types: [opened, edited, synchronize] 6 | 7 | jobs: 8 | signoff: 9 | uses: matrix-org/backend-meta/.github/workflows/sign-off.yml@v2 10 | -------------------------------------------------------------------------------- /.github/workflows/triage-incoming.yml: -------------------------------------------------------------------------------- 1 | name: Move new issues into the issue triage board 2 | 3 | on: 4 | issues: 5 | types: [opened] 6 | 7 | jobs: 8 | triage: 9 | uses: matrix-org/backend-meta/.github/workflows/triage-incoming.yml@v1 10 | with: 11 | project_id: 'PVT_kwDOAIB0Bs4AG0bY' 12 | content_id: ${{ github.event.issue.node_id }} 13 | secrets: 14 | github_access_token: ${{ secrets.ELEMENT_BOT_TOKEN }} 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | config.yml 2 | lib/ 3 | node_modules/ 4 | public/ 5 | registration.yml 6 | tsconfig.tsbuildinfo 7 | public/ 8 | 9 | .idea 10 | # Added by cargo 11 | /target 12 | 13 | # Generated during build 14 | /src/libRs.d.ts 15 | 16 | # Generated during test run 17 | /spec-lib 18 | /hookshot-int-* 19 | 20 | book 21 | *.cer 22 | *.pem 23 | *.pcks8 24 | 25 | # Coverage 26 | coverage 27 | .nyc_output 28 | 29 | # Devenv 30 | .devenv* 31 | devenv.local.nix 32 | 33 | # direnv 34 | .direnv -------------------------------------------------------------------------------- /.node-version: -------------------------------------------------------------------------------- 1 | 22 2 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "rust-analyzer.linkedProjects": [ 3 | "./Cargo.toml" 4 | ] 5 | } -------------------------------------------------------------------------------- /.well-known/funding-manifest-urls: -------------------------------------------------------------------------------- 1 | https://matrix.org/funding.json 2 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Hi there! Please read the [CONTRIBUTING.md](https://github.com/matrix-org/matrix-appservice-bridge/blob/develop/CONTRIBUTING.md) guide for all matrix.org bridge 2 | projects. 3 | 4 | ## Hookshot Guidelines 5 | 6 | - Hookshot presently uses the `main` branch as the default branch (instead of `develop`). 7 | - Since Hookshot caters to a range of services, we have additional tags such as `GitHub`, `GitLab` 8 | which help us determine which services are affected by an issue. 9 | - In addition to Typescript, Hookshot makes use of [Rust](https://rust-lang.org). You will need to setup Rust in order to build the project. https://rustup.rs/ is a good option for most people. 10 | - The official hookshot support/development room is [#hookshot:half-shot.uk](https://matrix.to/#/#hookshot:half-shot.uk) 11 | 12 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "matrix-hookshot" 3 | version = "7.0.0" 4 | edition = "2021" 5 | 6 | [lib] 7 | crate-type = ["cdylib"] 8 | 9 | [dependencies] 10 | napi = {version="2", features=["serde-json", "async"]} 11 | napi-derive = "2" 12 | url = "2" 13 | serde_json = "1" 14 | serde = "1" 15 | serde_derive = "1" 16 | contrast = "0" 17 | rgb = "0" 18 | md-5 = "0.10" 19 | hex = "0.4" 20 | rss = "2.0" 21 | atom_syndication = "0.12" 22 | ruma = { version = "0.9", features = ["events", "html"] } 23 | reqwest = "0.12.9" 24 | rand = "0.8.5" 25 | rsa = { version = "0.9.6", features = ["sha2"] } 26 | base64ct = { version = "1.6.0", features = ["alloc"] } 27 | sha1 = "0.10.6" 28 | [build-dependencies] 29 | napi-build = "2" 30 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Stage 0: Build the thing 2 | # Need debian based image to build the native rust module 3 | # as musl doesn't support cdylib 4 | FROM node:22-slim AS builder 5 | 6 | # Needed in order to build rust FFI bindings. 7 | RUN apt-get update && apt-get install -y build-essential cmake curl pkg-config pkg-config libssl-dev 8 | 9 | RUN curl https://sh.rustup.rs -sSf | sh -s -- -y --profile minimal 10 | ENV PATH="/root/.cargo/bin:${PATH}" 11 | 12 | # arm64 builds consume a lot of memory if `CARGO_NET_GIT_FETCH_WITH_CLI` is not 13 | # set to true, so we expose it as a build-arg. 14 | ARG CARGO_NET_GIT_FETCH_WITH_CLI=false 15 | ENV CARGO_NET_GIT_FETCH_WITH_CLI=$CARGO_NET_GIT_FETCH_WITH_CLI 16 | 17 | 18 | WORKDIR /src 19 | 20 | COPY package.json yarn.lock ./ 21 | RUN yarn config set yarn-offline-mirror /cache/yarn 22 | RUN yarn --ignore-scripts --pure-lockfile --network-timeout 600000 23 | 24 | COPY . ./ 25 | 26 | # Workaround: Need to install esbuild manually https://github.com/evanw/esbuild/issues/462#issuecomment-771328459 27 | RUN node node_modules/esbuild/install.js 28 | RUN yarn build 29 | 30 | 31 | # Stage 1: The actual container 32 | FROM node:22-slim 33 | 34 | WORKDIR /bin/matrix-hookshot 35 | 36 | RUN apt-get update && apt-get install -y openssl ca-certificates 37 | 38 | COPY --from=builder /src/yarn.lock /src/package.json ./ 39 | COPY --from=builder /cache/yarn /cache/yarn 40 | RUN yarn config set yarn-offline-mirror /cache/yarn 41 | 42 | RUN yarn --network-timeout 600000 --production --pure-lockfile && yarn cache clean 43 | 44 | COPY --from=builder /src/lib ./ 45 | COPY --from=builder /src/public ./public 46 | COPY --from=builder /src/assets ./assets 47 | 48 | ENV NODE_ENV="production" 49 | 50 | VOLUME /data 51 | EXPOSE 9993 52 | EXPOSE 7775 53 | 54 | CMD ["node", "/bin/matrix-hookshot/App/BridgeApp.js", "/data/config.yml", "/data/registration.yml"] 55 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![logo](logo.png) Matrix Hookshot 2 | =============== 3 | 4 | [![#hookshot:half-shot.uk](https://img.shields.io/matrix/hookshot:half-shot.uk.svg?server_fqdn=chaotic.half-shot.uk&label=%23hookshot:half-shot.uk&logo=matrix)](https://matrix.to/#/#hookshot:half-shot.uk) 5 | [![Docker Image Version (latest by date)](https://img.shields.io/docker/v/halfshot/matrix-hookshot?sort=semver)](https://hub.docker.com/r/halfshot/matrix-hookshot) 6 | 7 | ![screenshot](screenshot.png) 8 | 9 | A Matrix bot for connecting to external services like GitHub, GitLab, JIRA, and more. 10 | 11 | - Several services are supported out of the box. 12 | - [Webhooks](https://matrix-org.github.io/matrix-hookshot/latest/setup/webhooks.html) let you connect all kinds of services, with the ability to write rich templates using JavaScript. 13 | - **No external database** is required, instead using Matrix state as a persistent store. 14 | - End-to-Bridge encryption allows bots to be used in encrypted Matrix rooms. 15 | - [Powerful widgets](https://matrix-org.github.io/matrix-hookshot/latest/advanced/widgets.html) let you configure Hookshot from a room or the Element Extensions Store. 16 | 17 | We richly support the following integrations: 18 | 19 | - [Challenge Hound](https://matrix-org.github.io/matrix-hookshot/latest/setup/challengehound.html) 20 | - [Figma](https://matrix-org.github.io/matrix-hookshot/latest/setup/figma.html) 21 | - [Generic Webhooks](https://matrix-org.github.io/matrix-hookshot/latest/setup/webhooks.html) 22 | - [GitHub](https://matrix-org.github.io/matrix-hookshot/latest/setup/github.html) 23 | - [GitLab](https://matrix-org.github.io/matrix-hookshot/latest/setup/gitlab.html) 24 | - [Jira](https://matrix-org.github.io/matrix-hookshot/latest/setup/jira.html) 25 | - [OpenProject](https://matrix-org.github.io/matrix-hookshot/latest/setup/openproject.html) 26 | - [RSS/Atom feeds](https://matrix-org.github.io/matrix-hookshot/latest/setup/feeds.html) 27 | 28 | Get started by reading [the setup guide](https://matrix-org.github.io/matrix-hookshot/latest/setup.html)! 29 | 30 | 31 | ## Documentation 32 | 33 | Documentation can be found on [GitHub Pages](https://matrix-org.github.io/matrix-hookshot). 34 | 35 | You can build the documentation yourself by typing: 36 | ```sh 37 | # cargo install mdbook 38 | mdbook build 39 | sensible-browser book/index.html 40 | ``` 41 | 42 | ## Contact 43 | 44 | We have a Matrix support room ([#hookshot:half-shot.uk](https://matrix.to/#/#hookshot:half-shot.uk)). 45 | -------------------------------------------------------------------------------- /assets/feeds_avatar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matrix-org/matrix-hookshot/e69195d13f6025f4f22964028676ee18816a3930/assets/feeds_avatar.png -------------------------------------------------------------------------------- /assets/figma_avatar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matrix-org/matrix-hookshot/e69195d13f6025f4f22964028676ee18816a3930/assets/figma_avatar.png -------------------------------------------------------------------------------- /assets/github_avatar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matrix-org/matrix-hookshot/e69195d13f6025f4f22964028676ee18816a3930/assets/github_avatar.png -------------------------------------------------------------------------------- /assets/gitlab_avatar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matrix-org/matrix-hookshot/e69195d13f6025f4f22964028676ee18816a3930/assets/gitlab_avatar.png -------------------------------------------------------------------------------- /assets/jira_avatar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matrix-org/matrix-hookshot/e69195d13f6025f4f22964028676ee18816a3930/assets/jira_avatar.png -------------------------------------------------------------------------------- /assets/openproject_avatar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matrix-org/matrix-hookshot/e69195d13f6025f4f22964028676ee18816a3930/assets/openproject_avatar.png -------------------------------------------------------------------------------- /assets/src/feeds_avatar.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 16 | 36 | 38 | 42 | 49 | 56 | 61 | 66 | 67 | 68 | -------------------------------------------------------------------------------- /assets/src/gitlab_avatar.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 16 | 36 | 38 | 42 | 49 | 73 | 74 | 75 | -------------------------------------------------------------------------------- /assets/src/jira_avatar.svg: -------------------------------------------------------------------------------- 1 | 2 | 15 | 34 | 41 | 45 | 49 | 53 | 55 | 62 | 67 | 71 | 72 | 79 | 84 | 88 | 89 | 90 | 91 | -------------------------------------------------------------------------------- /assets/webhooks_avatar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matrix-org/matrix-hookshot/e69195d13f6025f4f22964028676ee18816a3930/assets/webhooks_avatar.png -------------------------------------------------------------------------------- /book.toml: -------------------------------------------------------------------------------- 1 | [book] 2 | authors = ["matrix.org "] 3 | language = "en" 4 | multilingual = false 5 | src = "docs" 6 | title = "Matrix Hookshot" 7 | 8 | [build] 9 | # Prevent markdown pages from being automatically generated when they're 10 | # linked to in SUMMARY.md 11 | create-missing = false 12 | 13 | 14 | [output.html] 15 | # The URL visitors will be directed to when they try to edit a page 16 | edit-url-template = "https://github.com/matrix-org/matrix-hookshot/edit/main/{path}" 17 | 18 | # The source code URL of the repository 19 | git-repository-url = "https://github.com/matrix-org/matrix-hookshot" 20 | 21 | 22 | additional-css = [ 23 | "docs/_site/style.css" 24 | ] 25 | 26 | additional-js = [ 27 | "docs/_site/main.js", 28 | "docs/_site/version.js" 29 | ] -------------------------------------------------------------------------------- /build.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | use napi_build::setup; 3 | setup(); 4 | } 5 | -------------------------------------------------------------------------------- /changelog.d/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matrix-org/matrix-hookshot/e69195d13f6025f4f22964028676ee18816a3930/changelog.d/.gitkeep -------------------------------------------------------------------------------- /changelog.d/1004.feature: -------------------------------------------------------------------------------- 1 | Add an option in the config to disable hook bodies in Matrix messages. 2 | -------------------------------------------------------------------------------- /changelog.d/1062.feature: -------------------------------------------------------------------------------- 1 | Hookshot will now ping the homeserver on startup to ensure it can be reached. 2 | -------------------------------------------------------------------------------- /changelog.d/1063.misc: -------------------------------------------------------------------------------- 1 | GitHub and GitLab webhook requests should now be directed to /github and /gitlab respectively. `/` and `/oauth` is now deprecated and will be removed in a future release. 2 | -------------------------------------------------------------------------------- /contrib/jsTransformationFunctions/alertmanager.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This is a transformation function for Prometheus Alertmanager webhooks. 3 | * https://prometheus.io/docs/alerting/latest/configuration/#webhook_config 4 | * 5 | * Creates a formatted `m.text` message with plaintext fallback, containing: 6 | * - alert status and severity 7 | * - alert name and description 8 | * - URL to the entity that caused the alert 9 | * The formatted message also contains a clickable link that silences the alert. 10 | */ 11 | 12 | /** 13 | * @param status resolved or firing 14 | * @param severity from the labels of the alert 15 | * @returns colored text rendering of the status and severity 16 | */ 17 | function statusBadge(status, severity) { 18 | let statusColor; 19 | if (status === "resolved") { 20 | return `[RESOLVED]`; 21 | } 22 | 23 | switch(severity) { 24 | case 'resolved': 25 | case 'critical': 26 | return `[FIRING - CRITICAL]`; 27 | case 'warning': 28 | return `[FIRING - WARNING]`; 29 | default: 30 | return `[${status.toUpperCase()}]`; 31 | } 32 | } 33 | 34 | /** 35 | * @param alert object from the webhook payload 36 | * @param externalURL from the webhook payload 37 | * @returns a formatted link that will silence the alert when clicked 38 | */ 39 | function silenceLink(alert, externalURL) { 40 | filters = [] 41 | for (const [label, val] of Object.entries(alert.labels)) { 42 | filters.push(encodeURIComponent(`${label}="${val}"`)); 43 | } 44 | return `silence`; 45 | } 46 | 47 | if (!data.alerts) { 48 | result = { 49 | version: 'v2', 50 | empty: true, 51 | }; 52 | return; 53 | } 54 | 55 | const plainErrors = []; 56 | const htmlErrors = []; 57 | const { externalURL, alerts } = data; 58 | 59 | for (const alert of data.alerts) { 60 | plainErrors.push(`**[${alert.status.toUpperCase()} - ${alert.labels.severity}]** - ${alert.labels.alertname}: ${alert.annotations.description} [source](${alert.generatorURL})`); 61 | htmlErrors.push(`

${statusBadge(alert.status, alert.labels.severity)}

${alert.labels.alertname}: ${alert.annotations.description.replaceAll("\n","")}

source | ${silenceLink(alert, externalURL)}

`) 62 | result = { 63 | version: 'v2', 64 | plain: plainErrors.join(`\n\n`), 65 | html: htmlErrors.join(`

`), 66 | msgtype: 'm.text' 67 | }; 68 | } 69 | -------------------------------------------------------------------------------- /devenv.nix: -------------------------------------------------------------------------------- 1 | { pkgs, lib, config, inputs, ... }: 2 | let 3 | pkgs-upstream = import inputs.nixpkgs-upstream { system = pkgs.stdenv.system; }; 4 | in 5 | { 6 | packages = [ pkgs.git pkgs.gcc pkgs.pkg-config pkgs.openssl ]; 7 | 8 | # https://devenv.sh/tests/ 9 | enterTest = '' 10 | echo "Running tests" 11 | yarn 12 | ''; 13 | 14 | # https://devenv.sh/services/ 15 | services.redis.enable = true; 16 | 17 | # https://devenv.sh/languages/ 18 | languages.typescript.enable = true; 19 | languages.javascript.yarn.enable = true; 20 | languages.javascript.enable = true; 21 | languages.javascript.package = pkgs-upstream.nodejs_22; 22 | languages.rust.enable = true; 23 | languages.rust.channel = "stable"; 24 | } 25 | -------------------------------------------------------------------------------- /devenv.yaml: -------------------------------------------------------------------------------- 1 | inputs: 2 | nixpkgs: 3 | url: github:cachix/devenv-nixpkgs/rolling 4 | fenix: 5 | url: github:nix-community/fenix 6 | inputs: 7 | nixpkgs: 8 | follows: nixpkgs 9 | nixpkgs-upstream: 10 | url: github:NixOS/nixpkgs/nixos-24.11 -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.7' 2 | services: 3 | redis: 4 | image: redis 5 | webhook: 6 | build: 7 | context: . 8 | dockerfile: ./Dockerfile 9 | ports: 10 | - "127.0.0.1:7775:7775" 11 | volumes: 12 | - ./data:/data 13 | links: 14 | - redis 15 | environment: 16 | CFG_QUEUE_MONOLITHIC: "false" 17 | CFG_QUEUE_HOST: "redis" 18 | command: node /bin/matrix-hookshot/App/GithubWebhookApp.js /data/config.yml /data/registration.yml 19 | sender: 20 | build: 21 | context: . 22 | dockerfile: ./Dockerfile 23 | volumes: 24 | - ./data:/data 25 | links: 26 | - redis 27 | environment: 28 | CFG_QUEUE_MONOLITHIC: "false" 29 | CFG_QUEUE_HOST: "redis" 30 | command: node /bin/matrix-hookshot/App/MatrixSenderApp.js /data/config.yml /data/registration.yml 31 | app: 32 | build: 33 | context: . 34 | dockerfile: ./Dockerfile 35 | ports: 36 | - "127.0.0.1:9993:9993" 37 | volumes: 38 | - ./data:/data 39 | links: 40 | - redis 41 | environment: 42 | CFG_QUEUE_MONOLITHIC: "false" 43 | CFG_QUEUE_HOST: "redis" 44 | command: node /bin/matrix-hookshot/App/BridgeApp.js /data/config.yml /data/registration.yml 45 | -------------------------------------------------------------------------------- /docs/SUMMARY.md: -------------------------------------------------------------------------------- 1 | # Summary 2 | 3 | - [ℹ️ Hookshot](./hookshot.md) 4 | - [⚙️ Setup](./setup.md) 5 | - [📃 Sample Configuration](./setup/sample-configuration.md) 6 | - [Feeds](./setup/feeds.md) 7 | - [Figma](./setup/figma.md) 8 | - [GitHub](./setup/github.md) 9 | - [GitLab](./setup/gitlab.md) 10 | - [JIRA](./setup/jira.md) 11 | - [Webhooks](./setup/webhooks.md) 12 | - [ChallengeHound](./setup/challengehound.md) 13 | - [OpenProject](./setup/openproject.md) 14 | - [👤 Usage](./usage.md) 15 | - [Dynamic Rooms](./usage/dynamic_rooms.md) 16 | - [Authenticating](./usage/auth.md) 17 | - [Room Configuration](./usage/room_configuration.md) 18 | - [GitHub Repo](./usage/room_configuration/github_repo.md) 19 | - [GitLab Project](./usage/room_configuration/gitlab_project.md) 20 | - [JIRA Project](./usage/room_configuration/jira_project.md) 21 | - [📊 Metrics](./metrics.md) 22 | - [Sentry](./sentry.md) 23 | - [🆘 Troubleshooting](./troubleshooting.md) 24 | 25 | # 🧑‍💻 Development 26 | 27 | - [Contributing](./contributing.md) 28 | 29 | # 🥼 Advanced 30 | 31 | - [Workers](./advanced/workers.md) 32 | - [🔒 Encryption](./advanced/encryption.md) 33 | - [🪀 Widgets](./advanced/widgets.md) 34 | - [Service Bots](./advanced/service_bots.md) 35 | -------------------------------------------------------------------------------- /docs/_site/icons/feeds.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matrix-org/matrix-hookshot/e69195d13f6025f4f22964028676ee18816a3930/docs/_site/icons/feeds.png -------------------------------------------------------------------------------- /docs/_site/icons/figma.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matrix-org/matrix-hookshot/e69195d13f6025f4f22964028676ee18816a3930/docs/_site/icons/figma.png -------------------------------------------------------------------------------- /docs/_site/icons/github.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matrix-org/matrix-hookshot/e69195d13f6025f4f22964028676ee18816a3930/docs/_site/icons/github.png -------------------------------------------------------------------------------- /docs/_site/icons/gitlab.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matrix-org/matrix-hookshot/e69195d13f6025f4f22964028676ee18816a3930/docs/_site/icons/gitlab.png -------------------------------------------------------------------------------- /docs/_site/icons/hound.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matrix-org/matrix-hookshot/e69195d13f6025f4f22964028676ee18816a3930/docs/_site/icons/hound.png -------------------------------------------------------------------------------- /docs/_site/icons/jira.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matrix-org/matrix-hookshot/e69195d13f6025f4f22964028676ee18816a3930/docs/_site/icons/jira.png -------------------------------------------------------------------------------- /docs/_site/icons/sentry.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matrix-org/matrix-hookshot/e69195d13f6025f4f22964028676ee18816a3930/docs/_site/icons/sentry.png -------------------------------------------------------------------------------- /docs/_site/icons/webhooks.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matrix-org/matrix-hookshot/e69195d13f6025f4f22964028676ee18816a3930/docs/_site/icons/webhooks.png -------------------------------------------------------------------------------- /docs/_site/main.js: -------------------------------------------------------------------------------- 1 | window.addEventListener("load", () => { 2 | const scrollbox = document.querySelector(".sidebar-scrollbox"); 3 | scrollbox.innerHTML = `
Version:
${scrollbox.innerHTML}`; 4 | const currentVersion = window.HOOKSHOT_VERSION || "latest"; 5 | 6 | const selectElement = document.createElement("select"); 7 | 8 | fetch("https://api.github.com/repos/matrix-org/matrix-hookshot/releases", { 9 | cache: "force-cache", 10 | }) 11 | .then((res) => res.json()) 12 | .then((releases) => { 13 | selectElement.innerHTML = ""; 14 | for (const version of [ 15 | "latest", 16 | ...releases 17 | .map((r) => r.tag_name) 18 | .filter((s) => s !== "0.1.0" && !s.startsWith("helm-")), 19 | ]) { 20 | const option = document.createElement("option"); 21 | option.innerHTML = version; 22 | selectElement.add(option); 23 | if (currentVersion === version) { 24 | option.setAttribute("selected", ""); 25 | } 26 | } 27 | }) 28 | .catch((ex) => { 29 | console.error("Failed to fetch version data", ex); 30 | }); 31 | 32 | const option = document.createElement("option"); 33 | option.innerHTML = "loading..."; 34 | selectElement.add(option); 35 | 36 | selectElement.addEventListener("change", (event) => { 37 | const path = [ 38 | ...window.location.pathname.split("/").slice(0, 2), 39 | event.target.value, 40 | ...window.location.pathname.split("/").slice(3), 41 | ].join("/"); 42 | window.location = `${window.location.origin}${path}`; 43 | }); 44 | 45 | document.querySelector(".version-box").appendChild(selectElement); 46 | }); 47 | -------------------------------------------------------------------------------- /docs/_site/version.js: -------------------------------------------------------------------------------- 1 | // This is modified by the build script 2 | window.HOOKSHOT_VERSION = "latest"; 3 | -------------------------------------------------------------------------------- /docs/advanced/encryption.md: -------------------------------------------------------------------------------- 1 | # Encryption 2 | 3 |
4 | Support for encryption is considered stable, but the underlying specification changes are not yet. 5 | 6 | Hookshot supports end-to-bridge encryption via [MSC3202](https://github.com/matrix-org/matrix-spec-proposals/pull/3202), and [MSC4203](https://github.com/matrix-org/matrix-spec-proposals/pull/4203). Hookshot needs to be configured against a a homeserver that supports these features, such as [Synapse](#running-with-synapse). 7 | 8 | Please check with your homeserver implementation before reporting bugs against matrix-hookshot. 9 | 10 |
11 | 12 | ## Enabling encryption in Hookshot 13 | 14 | In order for Hookshot to use encryption, it must be configured as follows: 15 | 16 | - The `encryption.storagePath` setting must point to a directory that Hookshot has permissions to write files into. If running with Docker, this path should be within a volume (for persistency). Hookshot uses this directory for its crypto store (i.e. long-lived state relating to its encryption keys). 17 | - Once a crypto store has been initialized, its files must not be modified, and Hookshot cannot be configured to use another crypto store of the same type as one it has used before. If a crypto store's files get lost or corrupted, Hookshot may fail to start up, or may be unable to decrypt command messages. To fix such issues, stop Hookshot, then reset its crypto store by running `yarn start:resetcrypto`. 18 | - [Redis](./workers.md) must be enabled. Note that worker mode is not yet supported with encryption, so `queue` MUST **NOT be configured**. 19 | 20 | If you ever reset your homeserver's state, ensure you also reset Hookshot's encryption state. This includes clearing the `storagePath` directory and all worker state stored in your redis instance. Otherwise, Hookshot may fail on start up with registration errors. 21 | 22 | Also ensure that Hookshot's appservice registration file contains every line from `registration.sample.yml` that appears after the `If enabling encryption` comment. Note that changing the registration file may require restarting the homeserver that Hookshot is connected to. 23 | 24 | ## Running with Synapse 25 | 26 | [Synapse](https://github.com/matrix-org/synapse/) has functional support for MSC3202 and MSC4203 as of [v1.63.0](https://github.com/matrix-org/synapse/releases/tag/v1.63.0). To enable it, add the following section to Synapse's configuration file (typically named `homeserver.yaml`): 27 | 28 | You may notice that MSC2409 is not listed above. Due to the changes being split out from MSC2409, `msc2409_to_device_messages_enabled` refers to MSC4203. 29 | 30 | ```yaml 31 | experimental_features: 32 | msc3202_device_masquerading: true 33 | msc3202_transaction_extensions: true 34 | msc2409_to_device_messages_enabled: true 35 | ``` 36 | -------------------------------------------------------------------------------- /docs/advanced/service_bots.md: -------------------------------------------------------------------------------- 1 | # Service Bots 2 | 3 | Hookshot supports additional bot users called "service bots" which handle a particular connection type 4 | (in addition to the default bot user which can handle any connection type). 5 | These bots can coexist in a room, each handling a different service. 6 | 7 | ## Configuration 8 | 9 | Service bots can be given a different localpart, display name, avatar, and command prefix. 10 | They will only handle connections for the specified service, which can be one of: 11 | 12 | - `feeds` - [Feeds](../setup/feeds.md) 13 | - `figma` - [Figma](../setup/figma.md) 14 | - `generic` - [Webhooks](../setup/webhooks.md) 15 | - `github` - [GitHub](../setup/github.md) 16 | - `gitlab` - [GitLab](../setup/gitlab.md) 17 | - `jira` - [Jira](../setup/jira.md) 18 | - `openproject` - [OpenProject](../setup/openproject.md) 19 | 20 | For example with this configuration: 21 | 22 | ```yaml 23 | serviceBots: 24 | - localpart: feeds 25 | displayname: Feeds 26 | avatar: "./assets/feeds_avatar.png" 27 | prefix: "!feeds" 28 | service: feeds 29 | ``` 30 | 31 | There will be a bot user `@feeds:example.com` which responds to commands prefixed with `!feeds`, and only handles feeds connections. 32 | 33 | 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. 34 | 35 | In the example above, you would need to add these lines: 36 | 37 | ```yaml 38 | - regex: "@feeds:example.com" # Where example.com is your homeserver's domain 39 | exclusive: true 40 | ``` 41 | -------------------------------------------------------------------------------- /docs/advanced/widgets.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matrix-org/matrix-hookshot/e69195d13f6025f4f22964028676ee18816a3930/docs/advanced/widgets.png -------------------------------------------------------------------------------- /docs/advanced/workers.md: -------------------------------------------------------------------------------- 1 | # Workers 2 | 3 | Hookshot supports running in a worker configuration, using Redis as the middleman process to handle traffic between processes. 4 | 5 |
6 | This feature is experimental and should only be used when you are reaching natural limits in the monolith process. 7 |
8 | 9 | ## Running in multi-process mode 10 | 11 | You must first have a working Redis instance somewhere which can talk between processes. For example, in Docker you can run: 12 | 13 | `docker run --name redis-host -p 6379:6379 -d redis`. 14 | 15 | The processes should all share the same config, which should contain the correct config to enable Redis: 16 | 17 | ```yaml 18 | queue: 19 | redisUri: "redis://redis-host:6379" 20 | cache: 21 | redisUri: "redis://redis-host:6379" 22 | ``` 23 | 24 | Note that if [encryption](./encryption.md) is enabled, you MUST enable the `cache` config but NOT the `queue` config. Workers require persistent 25 | storage in Redis, but cannot make use of worker-mode queues. 26 | 27 | Once that is done, you can simply start the processes by name using yarn: 28 | 29 | ``` 30 | yarn start:webhooks 31 | yarn start:matrixsender 32 | yarn start:app 33 | ``` 34 | 35 | Be aware that you will need to start all worker types when running in worker mode, as the service does not allow a hybrid worker approach. 36 | -------------------------------------------------------------------------------- /docs/contributing.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | ## Matrix.org Bridges 4 | 5 | {{#include ../node_modules/matrix-appservice-bridge/CONTRIBUTING.md}} 6 | 7 | ## Hookshot 8 | 9 | {{#include ../CONTRIBUTING.md}} 10 | -------------------------------------------------------------------------------- /docs/hookshot.md: -------------------------------------------------------------------------------- 1 | {{#include ../README.md}} 2 | -------------------------------------------------------------------------------- /docs/sentry.md: -------------------------------------------------------------------------------- 1 | # Sentry 2 | 3 | Hookshot supports [Sentry](https://sentry.io/welcome/) error reporting. 4 | 5 | You can configure Sentry by adding the following to your config: 6 | 7 | ```yaml 8 | sentry: 9 | dsn: https://examplePublicKey@o0.ingest.sentry.io/0 # The DSN for your Sentry project. 10 | environment: production # The environment sentry is being used in. Can be omitted. 11 | ``` 12 | 13 | Sentry will automatically include the name of your homeserver as the `serverName` reported. 14 | -------------------------------------------------------------------------------- /docs/setup/challengehound.md: -------------------------------------------------------------------------------- 1 | # ChallengeHound 2 | 3 | You can configure Hookshot to bridge [ChallengeHound](https://www.challengehound.com/) activites 4 | into Matrix. 5 | 6 | ### Getting the API secret. 7 | 8 | You will need to email ChallengeHound support for an API token. They seem happy to provide one 9 | as long as you are an admin of a challenge. See [this support article](https://support.challengehound.com/article/69-does-challenge-hound-have-an-api) 10 | 11 | ```yaml 12 | challengeHound: 13 | token: 14 | ``` 15 | 16 | ## Usage 17 | 18 | You can add a new challenge hound challenge by command: 19 | 20 | ``` 21 | challengehound add https://www.challengehound.com/challenge/abc-def 22 | ``` 23 | 24 | and remove it with the same command 25 | 26 | ``` 27 | challengehound remove https://www.challengehound.com/challenge/abc-def 28 | ``` 29 | 30 | Hookshot will periodically refetch activities from the challenge and send a notice when a new 31 | one is completed. Note that Hookshot uses your configured cache to store seen activities. If 32 | you have not configured Redis caching, it will default to in-memory storage which means activites 33 | **will** repeat on restart. 34 | -------------------------------------------------------------------------------- /docs/setup/feeds.md: -------------------------------------------------------------------------------- 1 | # Feeds 2 | 3 | You can configure hookshot to bridge RSS/Atom feeds into Matrix. 4 | 5 | ## Configuration 6 | 7 | ```yaml 8 | feeds: 9 | # (Optional) Configure this to enable RSS/Atom feed support 10 | # 11 | enabled: true 12 | pollIntervalSeconds: 600 13 | ``` 14 | 15 | `pollIntervalSeconds` specifies how often each feed will be checked for updates. 16 | It may be checked less often if under exceptional load, but it will never be checked more often than every `pollIntervalSeconds`. 17 | 18 | Each feed will only be checked once, regardless of the number of rooms to which it's bridged. 19 | 20 | No entries will be bridged upon the “initial sync” -- all entries that exist at the moment of setup will be considered to be already seen. 21 | 22 | Please note that Hookshot **must** be configured with Redis to retain seen entries between restarts. By default, Hookshot will 23 | run an "initial sync" on each startup and will not process any entries from feeds from before the first sync. 24 | 25 | ## Usage 26 | 27 | ### Adding new feeds 28 | 29 | To add a feed to your room: 30 | 31 | - Invite the bot user to the room. 32 | - Make sure the bot able to send state events (usually the Moderator power level in clients) 33 | - Say `!hookshot feed ` where `` links to an RSS/Atom feed you want to subscribe to. 34 | 35 | ### Listing feeds 36 | 37 | You can list all feeds that a room you're in is currently subscribed to with `!hookshot feed list`. 38 | It requires no special permissions from the user issuing the command. Optionally you can format the list as `json` or 39 | `yaml` with `!hookshot feed list `. 40 | 41 | ### Removing feeds 42 | 43 | To remove a feed from a room, say `!hookshot feed remove `, with the URL specifying which feed you want to unsubscribe from. 44 | 45 | ### Feed templates 46 | 47 | You can optionally give a feed a specific template to use when sending a message into a room. A template 48 | may include any of the following tokens: 49 | 50 | | Token | Description | 51 | | ---------- | ---------------------------------------------------------- | 52 | | $FEEDNAME | Either the label, title or url of the feed. | 53 | | $FEEDURL | The URL of the feed. | 54 | | $FEEDTITLE | The title of the feed. | 55 | | $TITLE | The title of the feed entry. | 56 | | $URL | The URL of the feed entry. | 57 | | $LINK | The link of the feed entry. Formatted as `[$TITLE]($URL)`. | 58 | | $AUTHOR | The author of the feed entry. | 59 | | $DATE | The publish date (`pubDate`) of the entry. | 60 | | $SUMMARY | The summary of the entry. | 61 | 62 | If not specified, the default template is `New post in $FEEDNAME: $LINK`. 63 | -------------------------------------------------------------------------------- /docs/setup/figma.md: -------------------------------------------------------------------------------- 1 | # Figma 2 | 3 | ## Setting up 4 | 5 | To bridge Figma webhooks with Hookshot, you will need: 6 | 7 | - A personal access token with admin access to the team you intend to bridge. 8 | - A figma account that is on the professional tier, as the free tier does provide webhook access. 9 | - Your team ID. You can get this by going to the team page on Figma, and looking for the ID in the url (e.g. 12345 in `https://www.figma.com/files/team/12345/...`) 10 | 11 | ## Configuration 12 | 13 | You can now set some configuration in the bridge `config.yml` 14 | 15 | ```yaml 16 | figma: 17 | publicUrl: https://example.com/hookshot/ 18 | instances: 19 | your-instance: 20 | teamId: your-team-id 21 | accessToken: your-personal-access-token 22 | passcode: your-webhook-passcode 23 | ``` 24 | 25 | `your-instance` should be a friendly name for your instance E.g. `matrix-dot-org`. 26 | 27 | The `publicUrl` value must be the **public** path to `/figma/webhook` on the webhooks listener. E.g. if your load balancer points `https://example.com/hookshot` to the bridge's webhooks listener, you should use the path `https://example.com/hookshot/figma/webhook`. 28 | 29 | The `accessToken` should be the personal access token for your account. 30 | 31 | The `passcode` should be a randomly generated code which is used to authenticate requests from Figma. 32 | 33 | The bridge will automatically set up a webhook on Figma for you upon startup, and will automatically reconfigure that webhook if the `publicUrl` or `passcode` changes. 34 | 35 | ## Next steps 36 | 37 | If you have followed these steps correctly, Figma should now be configured with hookshot 🥳. 38 | 39 | To bridge a figma file into your room, you should: 40 | 41 | - Invite the bot user to the room. 42 | - Make sure the bot able to send state events (usually the Moderator power level in clients) 43 | - Say `!hookshot figma file fileUrl` where `fileUrl` is the URL to the figma file e.g `https://www.figma.com/files/project/12345/...` 44 | - Figma comments will now be bridged into the room. 45 | -------------------------------------------------------------------------------- /docs/setup/gitlab.md: -------------------------------------------------------------------------------- 1 | # GitLab 2 | 3 | ## Configuration 4 | 5 | GitLab configuration is fairly straight-forward: 6 | 7 | ```yaml 8 | gitlab: 9 | # (Optional) Configure this to enable GitLab support 10 | # 11 | instances: 12 | gitlab.com: 13 | url: https://gitlab.com 14 | webhook: 15 | secret: secrettoken 16 | publicUrl: https://example.com/webhooks/ 17 | ``` 18 | 19 | You need to list all the instances you plan to connect to in the `config.yml`. This is 20 | used so that users can give a short name like `gitlab` or `matrix.org` when they want 21 | to specify an instance. 22 | 23 | You should generate a webhook `secret` (e.g. `pwgen -n 64 -s 1`) and then use this as your 24 | "Secret token" when adding webhooks. 25 | 26 |
27 | Previously Hookshot supported / as the public path for webhook delivery. This path is now deprecated and /gitlab/webhook should be used wherever possible. 28 |
29 | 30 | The `publicUrl` must be the URL where GitLab webhook events are received (i.e. the path to `/gitlab/webhook` 31 | for your `webhooks` listener). 32 | 33 |
34 | The GitLab hooks "test" button allows you to check that your webhooks are configured properly. The 35 | merge request, issue and release notifications lack a action field in the body of the 36 | request which means they won't show up in the room. You can check the logs to see 37 | if the request made it through, however. 38 |
39 | 40 | ## Adding a repository 41 | 42 | You can now follow the guide on [authenticating with GitLab](../usage/auth.md#gitlab), and then [bridging a room](../usage/room_configuration/gitlab_project.md#setting-up) 43 | -------------------------------------------------------------------------------- /docs/setup/openproject.md: -------------------------------------------------------------------------------- 1 | # OpenProject 2 | 3 | Setting up Hookshot for OpenProject requires setting up webhooks, and configuring 4 | an OAuth2 application so that users may login. 5 | 6 | ### OpenProject 7 | 8 | Set up OpenProject to send Webhook requests to hookshot, following [the documentation](https://www.openproject.org/docs/system-admin-guide/api-and-webhooks/#webhooks). Please note the following: 9 | 10 | 1. The payload URL will be the address of your [`webhooks` listener](https://matrix-org.github.io/matrix-hookshot/latest/setup.html#listeners-configuration), with the path of `/openproject/webhook`. 11 | 2. The secret **must** be set, and must match the value of `webhook.secret` below. 12 | 3. Hookshot currently uses the "Work packages" events, although more will follow in the future. 13 | 4. You may enable as many projects as you like, but Hookshot must be configured to route the projects via 14 | it's connections principle. 15 | 16 | You must also setup an OAuth application, following [the documentation](https://www.openproject.org/docs/system-admin-guide/authentication/oauth-applications/). Please note the following: 17 | 18 | 1. The Redirect URL will be the address of your [`webhooks` listener](https://matrix-org.github.io/matrix-hookshot/latest/setup.html#listeners-configuration), with the path of `/openproject/oauth`. 19 | 2. Only the scope `api_v3` is used. 20 | 3. Confidential access should be enabled. 21 | 4. Do not set a Client Credentials User ID. 22 | 23 | Please keep a record of the Client ID and Client Secret to be used in the next step. 24 | 25 | ### Hookshot 26 | 27 | You can now set some configuration in the bridge `config.yml`: 28 | 29 | ```yaml 30 | openProject: 31 | baseUrl: https://your-open-project.com 32 | webhook: 33 | secret: secrettoken 34 | oauth: 35 | clientId: foo 36 | clientSecret: bar 37 | redirectUri: https://example.com/oauth/ 38 | ``` 39 | 40 | ## Next steps 41 | 42 | If you have followed these steps correctly, OpenProject should now be configured 🥳. 43 | 44 | You can now follow the guide on [authenticating with OpenProject](../usage/auth.md#openproject). 45 | -------------------------------------------------------------------------------- /docs/setup/sample-configuration.md: -------------------------------------------------------------------------------- 1 | # Sample Configuration 2 | 3 | Below is a sample bridge configuration file. The configuration file 4 | can be tweaked to change the behaviour of your bridge. A bridge of the server is 5 | required to apply any changes made to this file. 6 | 7 | ```yaml 8 | {{#include ../../config.sample.yml}} 9 | ``` 10 | -------------------------------------------------------------------------------- /docs/troubleshooting.md: -------------------------------------------------------------------------------- 1 | # Troubleshooting 2 | 3 | If you are having difficulties getting set up with hookshot, the advice below might be able to resolve common issues. 4 | 5 | If none of these help, please come chat to us in ([#hookshot:half-shot.uk](https://matrix.to/#/#hookshot:half-shot.uk)). Please 6 | try to follow these steps first, as live support is best effort. 7 | 8 | ## 1. The hookshot bot doesn't acknowledge an invite. 9 | 10 | In 99% of cases, this is because the homeserver cannot reach the appservice. Synapse for example will log an error like: 11 | 12 | ```log 13 | synapse.http.client - 422 - INFO - as-recoverer-339 - Error sending request to PUT http://yourhookshoturl/_matrix/app/v1/transactions/123: ConnectionRefusedError Connection refused 14 | synapse.appservice.api - 405 - WARNING - as-recoverer-339 - push_bulk to http://yourhookshoturl threw exception(ConnectionRefusedError) Connection was refused by other side: 111: Connection refused. args=('Connection refused',) 15 | synapse.appservice.scheduler - 480 - INFO - as-recoverer-339 - Scheduling retries on hookshot in Ns 16 | ``` 17 | 18 | It's hard to offer targeted advice on resolving networking issues, but a good thing to try is to check whether 19 | you can reach hookshot at all from the homeservers environment. For instance: 20 | 21 | ```sh 22 | $ curl http://yourhookshoturl/_matrix/app/ 23 | ``` 24 | 25 | should give you a response (even if it's an error). 26 | 27 | ### Docker 28 | 29 | It is also worth noting that if you are in a docker environment, the `url` in your registration YAML file **must** match the 30 | path Synapse expects to reach the service on. So if your container is called `hookshot` and it's configured to listen 31 | on port `9993`, then you should configure the `url` to be `http://hookshot:9993`. 32 | 33 | ## 2. The bot joins, but doesn't respond to my messages. 34 | 35 | Check that you are sending the right format message. `!hookshot help` should always work. 36 | Otherwise, check whether the room is encrypted and you haven't [enabled encryption](./advanced/encryption.html) for the bot. 37 | The bot will ignore any messages in encrypted rooms. 38 | 39 | You'll need to either create the room as unencrypted or enable encryption for the bot. 40 | 41 | If this doesn't resolve your issue, check [Problem #1](#1-the-hookshot-bot-doesnt-acknowledge-an-invite) to make 42 | sure it's not a networking issue. 43 | 44 | ## 3. The bot works, but is offline in my client. 45 | 46 | This is expected. Hookshot doesn't support "presence" which is how Matrix determines online/offline status. 47 | -------------------------------------------------------------------------------- /docs/usage.md: -------------------------------------------------------------------------------- 1 | # Usage 2 | 3 | This section covers how to use the bridge, once it's set up. We break these 4 | down into categories: 5 | 6 | - Authentication: How to connect your user account to a remote service to use 7 | rich commands. 8 | - Room Connections: How to start connecting rooms to services. 9 | 10 | If you are looking for help on what bot commands you can run in a room, you can 11 | refer to the help information by saying\*: 12 | 13 | - `!gh help` in rooms connected to GitHub. 14 | - `!gl help` in rooms connected to GitLab. 15 | - `!jira help` in rooms connected to JIRA. 16 | 17 | \*the prefix may vary if the `commandPrefix` configuration in the room is set. 18 | -------------------------------------------------------------------------------- /docs/usage/dynamic_rooms.md: -------------------------------------------------------------------------------- 1 | # Dynamic Rooms 2 | 3 |
4 | Anyone who has access to your homeserver can query these aliases (even over federation), and aliases 5 | do not support checking if a user is authorised to view the content before creation. If you are bridging non-public 6 | content, it is advisable to disable this feature. 7 |
8 | 9 | Some bridges support dynamically creating rooms that point to resources based on an alias given by a user. 10 | 11 | Presently, the following are supported: 12 | 13 | - `#github_$owner:example.com` - For a Matrix space containing a user's discussions and repositories 14 | - `#github_$owner_$repo:example.com` - For GitHub repositories 15 | - `#github_$owner_$repo_$issuenumber:example.com` - For GitHub issues 16 | - `#github_disc_$owner_$repo:example.com` - For GitHub discussions for a repository 17 | 18 | Where $word is replaced by the appropriate value. 19 | 20 | (Some of these may not be supported, depending on bridge configuration and registration file changes) 21 | 22 | ## Disabling support 23 | 24 | This feature can be disabled simply by removing alias fields from the registration file. 25 | -------------------------------------------------------------------------------- /docs/usage/room_configuration.md: -------------------------------------------------------------------------------- 1 | # Room Configuration 2 | 3 | Hookshot works off the principle of **Connections**. 4 | 5 | A room can have many connections to different services. The connections are defined 6 | in the room state of a room. A connection defines the service it connects to, the 7 | type of integration (e.g. GitHub repo, Jira Project) and any additional configuration. 8 | 9 |
10 | {{#include ./room_configuration/connections1.svg}} 11 |
Figure 1. An example of a room connected to GitHub and JIRA
12 |
13 | 14 | Hookshot supports several connection types, which are defined under the Room Configuration 15 | heading. 16 | 17 | The availability of connection types depends on the configuration provided to hookshot. 18 | 19 | ### The `!hookshot` command 20 | 21 | Rooms can be bridged by inviting the hookshot bot into a room, and then running the 22 | `!hookshot` command. Running `!hookshot help` will give you some details, but you should 23 | see the documentation provided for information on each connection type. 24 | -------------------------------------------------------------------------------- /docs/usage/room_configuration/jira_project.md: -------------------------------------------------------------------------------- 1 | # JIRA Project 2 | 3 | This connection type connects a JIRA project to a room. 4 | 5 | You can run commands to create and assign issues, and receive notifications when issues are created. 6 | 7 | ## Setting up 8 | 9 | To set up a connection to a JIRA project in a new room: 10 | 11 | (NB you must have permission to bridge JIRA projects before you can use this command, see [auth](../auth.html#jira).) 12 | 13 | 1. Create a new, unencrypted room. It can be public or private. 14 | 1. Invite the bridge bot (e.g. `@hookshot:example.com`). 15 | 1. Give the bridge bot moderator permissions or higher (power level 50) (or otherwise configure the room so the bot can edit room state). 16 | 1. Send the command `!hookshot jira project https://jira-instance/.../projects/PROJECTKEY/...`. 17 | 1. If you have permission to bridge this repo, the bridge will respond with a confirmation message. 18 | 19 | ## Managing connections 20 | 21 | Send the command `!hookshot jira list project` to list all of a room's connections to JIRA projects. 22 | 23 | Send the command `!hookshot jira remove project ` to remove a room's connection to a JIRA project at a given URL. 24 | 25 | ## Configuration 26 | 27 | This connection supports a few options which can be defined in the room state: 28 | 29 | | Option | Description | Allowed values | Default | 30 | | ------------- | --------------------------------------------------------- | --------------------------------------------------------- | --------------- | 31 | | events | Choose to include notifications for some event types | Array of: [Supported event types](#supported-event-types) | `issue_created` | 32 | | commandPrefix | Choose the prefix to use when sending commands to the bot | A string, ideally starts with "!" | `!jira` | 33 | 34 | ### Supported event types 35 | 36 | This connection supports sending messages when the following actions happen on the project. 37 | 38 | - issue 39 | - issue_created 40 | - issue_updated 41 | - version 42 | - version_created 43 | - version_updated 44 | - version_released 45 | -------------------------------------------------------------------------------- /helm/cr.yaml: -------------------------------------------------------------------------------- 1 | 2 | release-name-template: "helm-{{ .Name }}-{{ .Version }}" -------------------------------------------------------------------------------- /helm/ct.yaml: -------------------------------------------------------------------------------- 1 | remote: origin 2 | target-branch: main 3 | chart-repos: [] 4 | chart-dirs: 5 | - helm 6 | validate-maintainers: false -------------------------------------------------------------------------------- /helm/hookshot/.gitignore: -------------------------------------------------------------------------------- 1 | *.tgz 2 | -------------------------------------------------------------------------------- /helm/hookshot/.helmignore: -------------------------------------------------------------------------------- 1 | # Patterns to ignore when building packages. 2 | # This supports shell glob matching, relative path matching, and 3 | # negation (prefixed with !). Only one pattern per line. 4 | .DS_Store 5 | # Common VCS dirs 6 | .git/ 7 | .gitignore 8 | .bzr/ 9 | .bzrignore 10 | .hg/ 11 | .hgignore 12 | .svn/ 13 | # Common backup files 14 | *.swp 15 | *.bak 16 | *.tmp 17 | *.orig 18 | *~ 19 | # Various IDEs 20 | .project 21 | .idea/ 22 | *.tmproj 23 | .vscode/ 24 | *.tgz 25 | -------------------------------------------------------------------------------- /helm/hookshot/.yamllint: -------------------------------------------------------------------------------- 1 | --- 2 | extends: default 3 | rules: 4 | line-length: 5 | level: warning 6 | max: 120 7 | braces: disable 8 | -------------------------------------------------------------------------------- /helm/hookshot/Chart.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: v2 3 | name: hookshot 4 | description: Deploy a Matrix Hookshot instance to Kubernetes 5 | # A chart can be either an 'application' or a 'library' chart. 6 | # 7 | # Application charts are a collection of templates that can be packaged into versioned archives 8 | # to be deployed. 9 | # 10 | # Library charts provide useful utilities or functions for the chart developer. They're included as 11 | # a dependency of application charts to inject those utilities and functions into the rendering 12 | # pipeline. Library charts do not define any templates and therefore cannot be deployed. 13 | type: application 14 | # This is the chart version. This version number should be incremented each time you make changes 15 | # to the chart and its templates, including the app version. 16 | # Versions are expected to follow Semantic Versioning (https://semver.org/) 17 | version: 0.1.16 18 | # This is the version number of the application being deployed. This version number should be 19 | # incremented each time you make changes to the application. Versions are not expected to 20 | # follow Semantic Versioning. They should reflect the version the application is using. 21 | # It is recommended to use it with quotes. 22 | appVersion: "0.0.0-replaced-by-ci" 23 | -------------------------------------------------------------------------------- /helm/hookshot/templates/NOTES.txt: -------------------------------------------------------------------------------- 1 | 1. Get the application URL by running these commands: 2 | {{- if .Values.ingress.enabled }} 3 | {{- range $host := .Values.ingress.hosts }} 4 | {{- range .paths }} 5 | http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}{{ .path }} 6 | {{- end }} 7 | {{- end }} 8 | {{- else if contains "NodePort" .Values.service.type }} 9 | export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "hookshot.fullname" . }}) 10 | export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}") 11 | echo http://$NODE_IP:$NODE_PORT 12 | {{- else if contains "LoadBalancer" .Values.service.type }} 13 | NOTE: It may take a few minutes for the LoadBalancer IP to be available. 14 | You can watch the status of by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "hookshot.fullname" . }}' 15 | export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "hookshot.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}") 16 | echo http://$SERVICE_IP:{{ .Values.service.port }} 17 | {{- else if contains "ClusterIP" .Values.service.type }} 18 | export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "hookshot.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}") 19 | export CONTAINER_PORT=$(kubectl get pod --namespace {{ .Release.Namespace }} $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}") 20 | echo "Visit http://127.0.0.1:8080 to use your application" 21 | kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:$CONTAINER_PORT 22 | {{- end }} 23 | -------------------------------------------------------------------------------- /helm/hookshot/templates/_helpers.tpl: -------------------------------------------------------------------------------- 1 | {{/* 2 | Expand the name of the chart. 3 | */}} 4 | {{- define "hookshot.name" -}} 5 | {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} 6 | {{- end }} 7 | 8 | 9 | {{/* 10 | Create a default fully qualified app name. 11 | We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). 12 | If release name contains chart name it will be used as a full name. 13 | */}} 14 | {{- define "hookshot.fullname" -}} 15 | {{- if .Values.fullnameOverride }} 16 | {{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} 17 | {{- else }} 18 | {{- $name := default .Chart.Name .Values.nameOverride }} 19 | {{- if contains $name .Release.Name }} 20 | {{- .Release.Name | trunc 63 | trimSuffix "-" }} 21 | {{- else }} 22 | {{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} 23 | {{- end }} 24 | {{- end }} 25 | {{- end }} 26 | 27 | {{/* 28 | Helper for configmap name 29 | */}} 30 | {{- define "hookshot.configMapName" -}} 31 | {{- if .Values.hookshot.existingConfigMap }} 32 | {{- printf "%s" .Values.hookshot.existingConfigMap -}} 33 | {{- else }} 34 | {{- printf "%s-config" (include "hookshot.fullname" .) | trunc 63 | trimSuffix "-" }} 35 | {{- end }} 36 | {{- end }} 37 | 38 | {{/* 39 | Create chart name and version as used by the chart label. 40 | */}} 41 | {{- define "hookshot.chart" -}} 42 | {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} 43 | {{- end }} 44 | 45 | {{/* 46 | Common labels 47 | */}} 48 | {{- define "hookshot.labels" -}} 49 | helm.sh/chart: {{ include "hookshot.chart" . }} 50 | {{ include "hookshot.selectorLabels" . }} 51 | {{- if .Chart.AppVersion }} 52 | app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} 53 | {{- end }} 54 | app.kubernetes.io/managed-by: {{ .Release.Service }} 55 | {{- end }} 56 | 57 | {{/* 58 | Selector labels 59 | */}} 60 | {{- define "hookshot.selectorLabels" -}} 61 | app.kubernetes.io/name: {{ include "hookshot.name" . }} 62 | app.kubernetes.io/instance: {{ .Release.Name }} 63 | {{- end }} 64 | 65 | {{/* 66 | Create the name of the service account to use 67 | */}} 68 | {{- define "hookshot.serviceAccountName" -}} 69 | {{- if .Values.serviceAccount.create }} 70 | {{- default (include "hookshot.fullname" .) .Values.serviceAccount.name }} 71 | {{- else }} 72 | {{- default "default" .Values.serviceAccount.name }} 73 | {{- end }} 74 | {{- end }} 75 | 76 | {{/* 77 | Allow the release namespace to be overridden for multi-namespace deployments in combined charts 78 | */}} 79 | {{- define "hookshot.namespace" -}} 80 | {{- if .Values.namespaceOverride -}} 81 | {{- .Values.namespaceOverride -}} 82 | {{- else -}} 83 | {{- .Release.Namespace -}} 84 | {{- end -}} 85 | {{- end -}} 86 | -------------------------------------------------------------------------------- /helm/hookshot/templates/configmap.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | {{- if not .Values.hookshot.existingConfigMap }} 3 | apiVersion: v1 4 | kind: ConfigMap 5 | metadata: 6 | name: {{ template "hookshot.configMapName" . }} 7 | namespace: {{ template "hookshot.namespace" . }} 8 | labels: 9 | {{- include "hookshot.labels" . | nindent 4 }} 10 | {{- with .Values.annotations }} 11 | annotations: 12 | {{ toYaml . | indent 4 }} 13 | {{- end }} 14 | data: 15 | config.yml: | 16 | {{ toYaml .Values.hookshot.config | indent 4 }} 17 | registration.yml: | 18 | {{ toYaml .Values.hookshot.registration | indent 4 }} 19 | passkey.pem: | 20 | {{ .Values.hookshot.passkey | indent 4 }} 21 | {{- end }} 22 | -------------------------------------------------------------------------------- /helm/hookshot/templates/deployment.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: apps/v1 3 | kind: Deployment 4 | metadata: 5 | name: {{ include "hookshot.fullname" . }} 6 | namespace: {{ template "hookshot.namespace" . }} 7 | labels: 8 | {{- include "hookshot.labels" . | nindent 4 }} 9 | spec: 10 | {{- if not .Values.autoscaling.enabled }} 11 | replicas: {{ .Values.replicaCount }} 12 | {{- end }} 13 | selector: 14 | matchLabels: 15 | {{- include "hookshot.selectorLabels" . | nindent 6 }} 16 | template: 17 | metadata: 18 | {{- with .Values.podAnnotations }} 19 | annotations: 20 | {{- toYaml . | nindent 8 }} 21 | {{- end }} 22 | labels: 23 | {{- include "hookshot.selectorLabels" . | nindent 8 }} 24 | spec: 25 | {{- include "hookshot.pod" . | nindent 6 }} -------------------------------------------------------------------------------- /helm/hookshot/templates/hpa.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | {{- if .Values.autoscaling.enabled }} 3 | apiVersion: autoscaling/v2beta1 4 | kind: HorizontalPodAutoscaler 5 | metadata: 6 | name: {{ include "hookshot.fullname" . }} 7 | namespace: {{ template "hookshot.namespace" . }} 8 | labels: 9 | {{- include "hookshot.labels" . | nindent 4 }} 10 | spec: 11 | scaleTargetRef: 12 | apiVersion: apps/v1 13 | kind: Deployment 14 | name: {{ include "hookshot.fullname" . }} 15 | minReplicas: {{ .Values.autoscaling.minReplicas }} 16 | maxReplicas: {{ .Values.autoscaling.maxReplicas }} 17 | metrics: 18 | {{- if .Values.autoscaling.targetCPUUtilizationPercentage }} 19 | - type: Resource 20 | resource: 21 | name: cpu 22 | targetAverageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }} 23 | {{- end }} 24 | {{- if .Values.autoscaling.targetMemoryUtilizationPercentage }} 25 | - type: Resource 26 | resource: 27 | name: memory 28 | targetAverageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }} 29 | {{- end }} 30 | {{- end }} 31 | -------------------------------------------------------------------------------- /helm/hookshot/templates/ingress-appservice.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | {{- if .Values.ingress.appservice.enabled -}} 3 | {{- $fullName := include "hookshot.fullname" . -}} 4 | {{- $svcPort := .Values.service.port -}} 5 | {{- if and .Values.ingress.appservice.className (not (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion)) }} 6 | {{- if not (hasKey .Values.ingress.appservice.annotations "kubernetes.io/ingress.class") }} 7 | {{- $_ := set .Values.ingress.appservice.annotations "kubernetes.io/ingress.class" .Values.ingress.appservice.className}} 8 | {{- end }} 9 | {{- end }} 10 | {{- if semverCompare ">=1.19-0" .Capabilities.KubeVersion.GitVersion -}} 11 | apiVersion: networking.k8s.io/v1 12 | {{- else if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}} 13 | apiVersion: networking.k8s.io/v1beta1 14 | {{- else -}} 15 | apiVersion: extensions/v1beta1 16 | {{- end }} 17 | kind: Ingress 18 | metadata: 19 | name: {{ $fullName }}-appservice 20 | namespace: {{ template "hookshot.namespace" . }} 21 | labels: 22 | {{- include "hookshot.labels" . | nindent 4 }} 23 | {{- with .Values.ingress.appservice.annotations }} 24 | annotations: 25 | {{- toYaml . | nindent 4 }} 26 | {{- end }} 27 | spec: 28 | {{- if and .Values.ingress.appservice.className (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion) }} 29 | ingressClassName: {{ .Values.ingress.appservice.className }} 30 | {{- end }} 31 | {{- if .Values.ingress.appservice.tls }} 32 | tls: 33 | {{- range .Values.ingress.appservice.tls }} 34 | - hosts: 35 | {{- range .hosts }} 36 | - {{ . | quote }} 37 | {{- end }} 38 | secretName: {{ .secretName }} 39 | {{- end }} 40 | {{- end }} 41 | rules: 42 | {{- range .Values.ingress.appservice.hosts }} 43 | - host: {{ .host | quote }} 44 | http: 45 | paths: 46 | {{- range .paths }} 47 | - path: {{ .path }} 48 | {{- if and .pathType (semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion) }} 49 | pathType: {{ .pathType }} 50 | {{- end }} 51 | backend: 52 | {{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }} 53 | service: 54 | name: {{ $fullName }} 55 | port: 56 | number: {{ .port }} 57 | {{- else }} 58 | serviceName: {{ $fullName }} 59 | servicePort: {{ .port }} 60 | {{- end }} 61 | {{- end }} 62 | {{- end }} 63 | {{- end }} 64 | -------------------------------------------------------------------------------- /helm/hookshot/templates/ingress.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | {{- if .Values.ingress.webhook.enabled -}} 3 | {{- $fullName := include "hookshot.fullname" . -}} 4 | {{- $svcPort := .Values.service.port -}} 5 | {{- if and .Values.ingress.webhook.className (not (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion)) }} 6 | {{- if not (hasKey .Values.ingress.webhook.annotations "kubernetes.io/ingress.class") }} 7 | {{- $_ := set .Values.ingress.webhook.annotations "kubernetes.io/ingress.class" .Values.ingress.webhook.className}} 8 | {{- end }} 9 | {{- end }} 10 | {{- if semverCompare ">=1.19-0" .Capabilities.KubeVersion.GitVersion -}} 11 | apiVersion: networking.k8s.io/v1 12 | {{- else if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}} 13 | apiVersion: networking.k8s.io/v1beta1 14 | {{- else -}} 15 | apiVersion: extensions/v1beta1 16 | {{- end }} 17 | kind: Ingress 18 | metadata: 19 | name: {{ $fullName }} 20 | namespace: {{ template "hookshot.namespace" . }} 21 | labels: 22 | {{- include "hookshot.labels" . | nindent 4 }} 23 | {{- with .Values.ingress.webhook.annotations }} 24 | annotations: 25 | {{- toYaml . | nindent 4 }} 26 | {{- end }} 27 | spec: 28 | {{- if and .Values.ingress.webhook.className (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion) }} 29 | ingressClassName: {{ .Values.ingress.webhook.className }} 30 | {{- end }} 31 | {{- if .Values.ingress.webhook.tls }} 32 | tls: 33 | {{- range .Values.ingress.webhook.tls }} 34 | - hosts: 35 | {{- range .hosts }} 36 | - {{ . | quote }} 37 | {{- end }} 38 | secretName: {{ .secretName }} 39 | {{- end }} 40 | {{- end }} 41 | rules: 42 | {{- range .Values.ingress.webhook.hosts }} 43 | - host: {{ .host | quote }} 44 | http: 45 | paths: 46 | {{- range .paths }} 47 | - path: {{ .path }} 48 | {{- if and .pathType (semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion) }} 49 | pathType: {{ .pathType }} 50 | {{- end }} 51 | backend: 52 | {{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }} 53 | service: 54 | name: {{ $fullName }} 55 | port: 56 | number: {{ .port }} 57 | {{- else }} 58 | serviceName: {{ $fullName }} 59 | servicePort: {{ .port }} 60 | {{- end }} 61 | {{- end }} 62 | {{- end }} 63 | {{- end }} 64 | -------------------------------------------------------------------------------- /helm/hookshot/templates/service.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: v1 3 | kind: Service 4 | metadata: 5 | name: {{ include "hookshot.fullname" . }} 6 | namespace: {{ template "hookshot.namespace" . }} 7 | {{- with .Values.service.annotations }} 8 | annotations: 9 | {{ toYaml . | indent 4 }} 10 | {{- end }} 11 | labels: 12 | {{- include "hookshot.labels" . | nindent 4 }} 13 | {{- with .Values.service.labels }} 14 | {{ toYaml . | indent 4 }} 15 | {{- end }} 16 | spec: 17 | type: {{ .Values.service.type }} 18 | ports: 19 | - port: {{ .Values.service.webhook.port }} 20 | targetPort: webhook 21 | protocol: TCP 22 | name: webhook 23 | - port: {{ .Values.service.metrics.port }} 24 | targetPort: metrics 25 | protocol: TCP 26 | name: metrics 27 | - port: {{ .Values.service.appservice.port }} 28 | targetPort: appservice 29 | protocol: TCP 30 | name: appservice 31 | selector: 32 | {{- include "hookshot.selectorLabels" . | nindent 4 }} 33 | -------------------------------------------------------------------------------- /helm/hookshot/templates/serviceaccount.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.serviceAccount.create -}} 2 | apiVersion: v1 3 | kind: ServiceAccount 4 | metadata: 5 | name: {{ include "hookshot.serviceAccountName" . }} 6 | namespace: {{ template "hookshot.namespace" . }} 7 | labels: 8 | {{- include "hookshot.labels" . | nindent 4 }} 9 | {{- with .Values.serviceAccount.annotations }} 10 | annotations: 11 | {{- toYaml . | nindent 4 }} 12 | {{- end }} 13 | {{- end }} 14 | -------------------------------------------------------------------------------- /helm/hookshot/templates/tests/test-connection.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: v1 3 | kind: Pod 4 | metadata: 5 | name: "{{ include "hookshot.fullname" . }}-test-connection" 6 | labels: 7 | {{- include "hookshot.labels" . | nindent 4 }} 8 | annotations: 9 | "helm.sh/hook": test 10 | spec: 11 | containers: 12 | - name: wget 13 | image: busybox 14 | command: ['wget'] 15 | args: ['{{ include "hookshot.fullname" . }}:{{ .Values.service.webhook.port }}'] 16 | restartPolicy: Never 17 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matrix-org/matrix-hookshot/e69195d13f6025f4f22964028676ee18816a3930/logo.png -------------------------------------------------------------------------------- /logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 55 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.towncrier] 2 | filename = "CHANGELOG.md" 3 | directory = "changelog.d" 4 | issue_format = "[\\#{issue}](https://github.com/matrix-org/matrix-hookshot/issues/{issue})" 5 | 6 | [[tool.towncrier.type]] 7 | directory = "feature" 8 | name = "Features" 9 | showcontent = true 10 | 11 | [[tool.towncrier.type]] 12 | directory = "bugfix" 13 | name = "Bugfixes" 14 | showcontent = true 15 | 16 | [[tool.towncrier.type]] 17 | directory = "doc" 18 | name = "Improved Documentation" 19 | showcontent = true 20 | 21 | [[tool.towncrier.type]] 22 | directory = "removal" 23 | name = "Deprecations and Removals" 24 | showcontent = true 25 | 26 | [[tool.towncrier.type]] 27 | directory = "misc" 28 | name = "Internal Changes" 29 | showcontent = true 30 | -------------------------------------------------------------------------------- /registration.sample.yml: -------------------------------------------------------------------------------- 1 | id: matrix-hookshot # This can be anything, but must be unique within your homeserver 2 | as_token: aaaaaaaaaa # This again can be a random string 3 | hs_token: aaaaaaaaaa # ..as can this 4 | namespaces: 5 | rooms: [] 6 | users: # In the following, foobar is your homeserver's domain 7 | - regex: "@_github_.*:foobar" 8 | exclusive: true 9 | - regex: "@_gitlab_.*:foobar" 10 | exclusive: true 11 | - regex: "@_jira_.*:foobar" 12 | exclusive: true 13 | - regex: "@_webhooks_.*:foobar" # Where _webhooks_ is set by userIdPrefix in config.yml 14 | exclusive: true 15 | - regex: "@feeds:foobar" # Matches the localpart of all serviceBots in config.yml 16 | exclusive: true 17 | aliases: 18 | - regex: "#github_.+:foobar" # Where foobar is your homeserver's domain 19 | exclusive: true 20 | 21 | sender_localpart: hookshot 22 | url: "http://localhost:9993" # This should match the bridge.port in your config file 23 | rate_limited: false 24 | 25 | # If enabling encryption 26 | de.sorunome.msc2409.push_ephemeral: true 27 | push_ephemeral: true 28 | org.matrix.msc3202: true 29 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matrix-org/matrix-hookshot/e69195d13f6025f4f22964028676ee18816a3930/screenshot.png -------------------------------------------------------------------------------- /scripts/build-app.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # exit when any command fails 4 | set -e 5 | 6 | echo "Building Rust layer" 7 | yarn run build:app:rs 8 | echo "Running rust-typescript definitions fix" 9 | yarn run build:app:fix-defs 10 | echo "Building Typescript layer" 11 | yarn run build:app 12 | echo "Building web" 13 | yarn run build:web 14 | -------------------------------------------------------------------------------- /scripts/build-metrics-docs.ts: -------------------------------------------------------------------------------- 1 | import Metrics from "../src/Metrics"; 2 | import { register } from "prom-client"; 3 | import prettier from "prettier"; 4 | 5 | // This is just used to ensure we create a singleton. 6 | Metrics.getMetrics(); 7 | 8 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 9 | const anyRegister = register as any as { 10 | _metrics: { 11 | [metricName: string]: { labelNames: string[]; name: string; help: string }; 12 | }; 13 | }; 14 | 15 | const categories: { 16 | [title: string]: { name: string; labels: string[]; help: string }[]; 17 | } = {}; 18 | 19 | Object.entries(anyRegister._metrics).map(([key, value]) => { 20 | const [categoryName] = key.split("_"); 21 | categories[categoryName] = categories[categoryName] || []; 22 | categories[categoryName].push({ 23 | name: key, 24 | labels: value.labelNames, 25 | help: value.help, 26 | }); 27 | }); 28 | 29 | // Generate some markdown 30 | 31 | const output = 32 | `Prometheus Metrics 33 | ================== 34 | 35 | You can configure metrics support by adding the following to your config: 36 | 37 | \`\`\`yaml 38 | metrics: 39 | enabled: true 40 | bindAddress: 127.0.0.1 41 | port: 9002 42 | \`\`\` 43 | 44 | Hookshot will then provide metrics on \`127.0.0.1\` at port \`9002\`. 45 | 46 | An example dashboard that can be used with [Grafana](https://grafana.com) can be found at [/contrib/hookshot-dashboard.json](https://github.com/matrix-org/matrix-hookshot/blob/main/contrib/hookshot-dashboard.json). 47 | There are 3 variables at the top of the dashboard: 48 | 49 | ![image](https://user-images.githubusercontent.com/2803622/179366574-1bb83e30-05c6-4558-9e66-e813e85b3a6e.png) 50 | 51 | Select the Prometheus instance with your Hookshot metrics as Data Source. Set Interval to your scraping interval. Set 2x Interval to twice the Interval value ([why?](https://github.com/matrix-org/matrix-hookshot/pull/407#issuecomment-1186251618)). 52 | 53 | Below is the generated list of Prometheus metrics for Hookshot. 54 | 55 | ` + 56 | Object.entries(categories) 57 | .map( 58 | ([name, entries]) => 59 | `## ${name} 60 | | Metric | Help | Labels | 61 | |--------|------|--------|` + 62 | entries 63 | .map((e) => `| ${e.name} | ${e.help} | ${e.labels.join(", ")} |`) 64 | .join("\n"), 65 | ) 66 | .join("\n"); 67 | 68 | prettier 69 | .format(output, { parser: "markdown" }) 70 | .then((s: string) => process.stdout.write(s)); 71 | -------------------------------------------------------------------------------- /scripts/changelog.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | VERSION=`python3 -c "import json; f = open('./package.json', 'r'); v = json.loads(f.read())['version']; f.close(); print(v)"` 3 | towncrier build --version $VERSION $1 4 | -------------------------------------------------------------------------------- /scripts/clean.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # exit when any command fails 4 | set -e 5 | 6 | echo "Cleaning web" 7 | yarn run clean:web 8 | echo "Cleaning Typescript layer" 9 | yarn run clean:app 10 | echo "Cleaning Rust layer" 11 | yarn run clean:app:rs 12 | -------------------------------------------------------------------------------- /scripts/definitions-fixer.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | // Workaround to https://github.com/napi-rs/napi-rs/issues/986 3 | import { promises as fs } from "fs"; 4 | 5 | async function processDefFile() { 6 | const path = process.argv[process.argv.length - 1]; 7 | 8 | // Read the whole file in to prevent us writing over ourselves. 9 | const file = await fs.readFile(path, "utf-8"); 10 | const out = await fs.open(path, "w"); 11 | for (const line of file.split("\n")) { 12 | const match = / {2}(\w+\.[\w.-]+):/g.exec(line); 13 | await out.write( 14 | (match ? line.replace(match[1], `"${match[1]}"`) : line) + "\n", 15 | ); 16 | } 17 | await out.close(); 18 | } 19 | 20 | processDefFile().catch((ex) => { 21 | console.error("Failed to process def file!", ex); 22 | process.exit(1); 23 | }); 24 | -------------------------------------------------------------------------------- /scripts/release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # This script will run towncrier to and generate a release commit, tag and push to the origin. 3 | 4 | if ! command -v jq &> /dev/null 5 | then 6 | echo "You must install jq to use this script" >&2 7 | exit 1 8 | fi 9 | 10 | VERSION=`jq -r .version <(git show :package.json)` 11 | 12 | function parseCargoVersion { 13 | awk '$1 == "version" {gsub("\"", "", $3); print $3}' $1 14 | } 15 | CARGO_TOML_VERSION=`parseCargoVersion <(git show :Cargo.toml)` 16 | if [[ $VERSION != $CARGO_TOML_VERSION ]]; then 17 | echo "Node & Rust package versions do not match." >&2 18 | echo "Node version (package.json): ${VERSION}" >&2 19 | echo "Rust version (Cargo.toml): ${CARGO_TOML_VERSION}" >&2 20 | exit 2 21 | fi 22 | CARGO_LOCK_VERSION=`parseCargoVersion <(grep -A1 matrix-hookshot <(git show :Cargo.lock))` 23 | if [[ $CARGO_TOML_VERSION != $CARGO_LOCK_VERSION ]]; then 24 | echo "Rust package version does not match the lockfile." >&2 25 | echo "Rust version (Cargo.toml): ${CARGO_TOML_VERSION}" >&2 26 | echo "Lockfile version (Cargo.lock): ${CARGO_LOCK_VERSION}" >&2 27 | exit 3 28 | fi 29 | TAG="$VERSION" 30 | HEAD_BRANCH=`git remote show origin | sed -n '/HEAD branch/s/.*: //p'` 31 | REPO_NAME=`git remote show origin -n | grep -m 1 -oP '(?<=git@github.com:)(.*)(?=.git)'` 32 | 33 | if [[ "`git branch --show-current`" != $HEAD_BRANCH ]]; then 34 | echo "You must be on the $HEAD_BRANCH branch to run this command." >&2 35 | exit 4 36 | fi 37 | 38 | if [ $(git tag -l "$TAG") ]; then 39 | echo "Tag $TAG already exists, not continuing." >&2 40 | exit 5 41 | fi 42 | 43 | echo "Drafting a new release" 44 | towncrier build --draft --version $VERSION> draft-release.txt 45 | cat draft-release.txt 46 | 47 | read -p "Happy with the changelog? " prompt 48 | if [[ $prompt != "y" && $prompt != "Y" && $prompt != "yes" && $prompt != "Yes" ]] 49 | then 50 | rm draft-release.txt 51 | exit 0 52 | fi 53 | 54 | echo "Committing version" 55 | towncrier build --version $VERSION 56 | git commit CHANGELOG.md changelog.d/ package.json -m $TAG 57 | 58 | echo "Proceeding to generate tags" 59 | git tag -F draft-release.txt -s $TAG 60 | rm draft-release.txt 61 | echo "Generated tag $TAG" 62 | 63 | echo "Pushing to origin" 64 | git push origin $TAG 65 | git push 66 | 67 | echo "The CI to generate a release is now running. Check https://github.com/$REPO_NAME/releases and publish the release when it's ready." -------------------------------------------------------------------------------- /spec/basic.spec.ts: -------------------------------------------------------------------------------- 1 | import { MessageEventContent } from "matrix-bot-sdk"; 2 | import { E2ESetupTestTimeout, E2ETestEnv } from "./util/e2e-test"; 3 | import { expect } from "chai"; 4 | import { describe, test, beforeEach, afterEach } from "vitest"; 5 | 6 | describe("Basic test setup", () => { 7 | let testEnv: E2ETestEnv; 8 | 9 | beforeEach(async () => { 10 | testEnv = await E2ETestEnv.createTestEnv({ matrixLocalparts: ["user"] }); 11 | await testEnv.setUp(); 12 | }, E2ESetupTestTimeout); 13 | 14 | afterEach(() => { 15 | return testEnv?.tearDown(); 16 | }); 17 | 18 | test("should be able to invite the bot to a room", async () => { 19 | const user = testEnv.getUser("user"); 20 | const roomId = await user.createRoom({ 21 | name: "Test room", 22 | invite: [testEnv.botMxid], 23 | }); 24 | await user.waitForRoomJoin({ sender: testEnv.botMxid, roomId }); 25 | const msg = user.waitForRoomEvent({ 26 | eventType: "m.room.message", 27 | sender: testEnv.botMxid, 28 | roomId, 29 | }); 30 | await user.sendText(roomId, "!hookshot help"); 31 | // Expect help text. 32 | expect((await msg).data.content.body).to.include( 33 | "!hookshot help` - This help text\n", 34 | ); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /spec/util/bridge-api.ts: -------------------------------------------------------------------------------- 1 | import { MatrixClient } from "matrix-bot-sdk"; 2 | import { BridgeAPI } from "../../web/BridgeAPI"; 3 | import { WidgetApi } from "matrix-widget-api"; 4 | 5 | export async function getBridgeApi(publicUrl: string, user: MatrixClient) { 6 | return BridgeAPI.getBridgeAPI( 7 | publicUrl, 8 | { 9 | requestOpenIDConnectToken: () => { 10 | return user.getOpenIDConnectToken(); 11 | }, 12 | } as unknown as WidgetApi, 13 | { 14 | getItem() { 15 | return null; 16 | }, 17 | setItem() {}, 18 | } as unknown as Storage, 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /spec/util/helpers.ts: -------------------------------------------------------------------------------- 1 | export async function waitFor( 2 | condition: () => Promise, 3 | delay = 100, 4 | maxRetries = 10, 5 | ) { 6 | let retries = 0; 7 | while (!(await condition()) && retries++ < maxRetries) { 8 | await new Promise((r) => setTimeout(r, delay)); 9 | } 10 | if (retries === maxRetries) { 11 | throw Error("Hit retry limit"); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /spec/widgets.spec.ts: -------------------------------------------------------------------------------- 1 | import { E2ESetupTestTimeout, E2ETestEnv } from "./util/e2e-test"; 2 | import { describe, test, beforeAll, afterAll, expect } from "vitest"; 3 | import { getBridgeApi } from "./util/bridge-api"; 4 | 5 | describe("Widgets", () => { 6 | let testEnv: E2ETestEnv; 7 | 8 | beforeAll(async () => { 9 | const webhooksPort = 9500 + E2ETestEnv.workerId; 10 | testEnv = await E2ETestEnv.createTestEnv({ 11 | matrixLocalparts: ["user"], 12 | config: { 13 | widgets: { 14 | publicUrl: `http://localhost:${webhooksPort}`, 15 | }, 16 | listeners: [ 17 | { 18 | port: webhooksPort, 19 | bindAddress: "0.0.0.0", 20 | // Bind to the SAME listener to ensure we don't have conflicts. 21 | resources: ["webhooks", "widgets"], 22 | }, 23 | ], 24 | }, 25 | }); 26 | await testEnv.setUp(); 27 | }, E2ESetupTestTimeout); 28 | 29 | afterAll(() => { 30 | return testEnv?.tearDown(); 31 | }); 32 | 33 | test("should be able to authenticate with the widget API", async () => { 34 | const user = testEnv.getUser("user"); 35 | const bridgeApi = await getBridgeApi( 36 | testEnv.opts.config?.widgets?.publicUrl!, 37 | user, 38 | ); 39 | expect(await bridgeApi.verify()).toEqual({ 40 | type: "widget", 41 | userId: "@user:hookshot", 42 | }); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /src/AdminRoomCommandHandler.ts: -------------------------------------------------------------------------------- 1 | import EventEmitter from "events"; 2 | import { Intent } from "matrix-bot-sdk"; 3 | import { BridgeConfig } from "./config/Config"; 4 | import { UserTokenStore } from "./tokens/UserTokenStore"; 5 | 6 | export enum Category { 7 | ConnectionManagement = "Connection Management", 8 | Github = "Github", 9 | Gitlab = "Gitlab", 10 | Jira = "Jira", 11 | OpenProject = "OpenProject", 12 | } 13 | 14 | export interface AdminAccountData { 15 | admin_user: string; 16 | github?: { 17 | notifications?: { 18 | enabled: boolean; 19 | participating?: boolean; 20 | }; 21 | }; 22 | gitlab?: { 23 | [instanceUrl: string]: { 24 | notifications: { 25 | enabled: boolean; 26 | }; 27 | }; 28 | }; 29 | } 30 | 31 | export abstract class AdminRoomCommandHandler extends EventEmitter { 32 | public get accountData() { 33 | return { ...this.data }; 34 | } 35 | 36 | public get userId() { 37 | return this.data.admin_user; 38 | } 39 | 40 | constructor( 41 | protected readonly botIntent: Intent, 42 | public readonly roomId: string, 43 | protected tokenStore: UserTokenStore, 44 | protected readonly config: BridgeConfig, 45 | protected data: AdminAccountData, 46 | ) { 47 | super(); 48 | } 49 | public async sendNotice(noticeText: string) { 50 | return this.botIntent.sendText(this.roomId, noticeText, "m.notice"); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/App/GithubWebhookApp.ts: -------------------------------------------------------------------------------- 1 | import { BridgeConfig } from "../config/Config"; 2 | import { Webhooks } from "../Webhooks"; 3 | import { Logger } from "matrix-appservice-bridge"; 4 | import { UserNotificationWatcher } from "../notifications/UserNotificationWatcher"; 5 | import Metrics from "../Metrics"; 6 | import { ListenerService } from "../ListenerService"; 7 | import { LogService } from "matrix-bot-sdk"; 8 | 9 | const log = new Logger("App"); 10 | 11 | async function start() { 12 | const configFile = process.argv[2] || "./config.yml"; 13 | const config = await BridgeConfig.parseConfig(configFile, process.env); 14 | Logger.configure({ 15 | console: config.logging.level, 16 | colorize: config.logging.colorize, 17 | json: config.logging.json, 18 | timestampFormat: config.logging.timestampFormat, 19 | }); 20 | LogService.setLogger(Logger.botSdkLogger); 21 | const listener = new ListenerService(config.listeners); 22 | listener.start(); 23 | if (config.metrics) { 24 | if (!config.metrics.port) { 25 | log.warn(`Not running metrics for service, no port specified`); 26 | } else { 27 | listener.bindResource("metrics", Metrics.expressRouter); 28 | } 29 | } 30 | const webhookHandler = new Webhooks(config); 31 | listener.bindResource("webhooks", webhookHandler.expressRouter); 32 | listener.finaliseListeners(); 33 | const userWatcher = new UserNotificationWatcher(config); 34 | userWatcher.start(); 35 | process.once("SIGTERM", () => { 36 | log.error("Got SIGTERM"); 37 | webhookHandler.stop(); 38 | listener.stop(); 39 | userWatcher.stop(); 40 | }); 41 | } 42 | 43 | start().catch((ex) => { 44 | log.error("GithubWebhookApp encountered an error and has stopped:", ex); 45 | }); 46 | -------------------------------------------------------------------------------- /src/App/MatrixSenderApp.ts: -------------------------------------------------------------------------------- 1 | import { BridgeConfig, parseRegistrationFile } from "../config/Config"; 2 | import { MatrixSender } from "../MatrixSender"; 3 | import { Logger } from "matrix-appservice-bridge"; 4 | import Metrics from "../Metrics"; 5 | import { ListenerService } from "../ListenerService"; 6 | import { LogService } from "matrix-bot-sdk"; 7 | import { getAppservice } from "../Appservice"; 8 | 9 | const log = new Logger("App"); 10 | 11 | async function start() { 12 | const configFile = process.argv[2] || "./config.yml"; 13 | const registrationFile = process.argv[3] || "./registration.yml"; 14 | const config = await BridgeConfig.parseConfig(configFile, process.env); 15 | const registration = await parseRegistrationFile(registrationFile); 16 | Logger.configure({ 17 | console: config.logging.level, 18 | colorize: config.logging.colorize, 19 | json: config.logging.json, 20 | timestampFormat: config.logging.timestampFormat, 21 | }); 22 | LogService.setLogger(Logger.botSdkLogger); 23 | const listener = new ListenerService(config.listeners); 24 | listener.start(); 25 | const { appservice, storage } = getAppservice(config, registration); 26 | const sender = new MatrixSender(config, appservice); 27 | if (config.metrics) { 28 | if (!config.metrics.port) { 29 | log.warn(`Not running metrics for service, no port specified`); 30 | } else { 31 | listener.bindResource("metrics", Metrics.expressRouter); 32 | } 33 | } 34 | listener.finaliseListeners(); 35 | sender.listen(); 36 | process.once("SIGTERM", () => { 37 | log.error("Got SIGTERM"); 38 | sender.stop(); 39 | listener.stop(); 40 | storage.disconnect?.(); 41 | }); 42 | } 43 | 44 | start().catch((ex) => { 45 | log.error("MatrixSenderApp encountered an error and has stopped:", ex); 46 | }); 47 | -------------------------------------------------------------------------------- /src/Appservice.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from "matrix-appservice-bridge"; 2 | import { 3 | Appservice, 4 | IAppserviceCryptoStorageProvider, 5 | IAppserviceRegistration, 6 | RustSdkAppserviceCryptoStorageProvider, 7 | RustSdkCryptoStoreType, 8 | } from "matrix-bot-sdk"; 9 | import { BridgeConfig } from "./config/Config"; 10 | import Metrics from "./Metrics"; 11 | import { MemoryStorageProvider } from "./stores/MemoryStorageProvider"; 12 | import { RedisStorageProvider } from "./stores/RedisStorageProvider"; 13 | import { IBridgeStorageProvider } from "./stores/StorageProvider"; 14 | const log = new Logger("Appservice"); 15 | 16 | export function getAppservice( 17 | config: BridgeConfig, 18 | registration: IAppserviceRegistration, 19 | ) { 20 | let storage: IBridgeStorageProvider; 21 | if (config.cache) { 22 | log.info(`Initialising Redis storage`); 23 | storage = new RedisStorageProvider(config.cache); 24 | } else { 25 | log.info("Initialising memory storage"); 26 | storage = new MemoryStorageProvider(); 27 | } 28 | 29 | let cryptoStorage: IAppserviceCryptoStorageProvider | undefined; 30 | if (config.encryption?.storagePath) { 31 | log.info("Initialising crypto storage"); 32 | cryptoStorage = new RustSdkAppserviceCryptoStorageProvider( 33 | config.encryption.storagePath, 34 | 0, 35 | ); 36 | } 37 | 38 | const appservice = new Appservice({ 39 | homeserverName: config.bridge.domain, 40 | homeserverUrl: config.bridge.url, 41 | port: config.bridge.port, 42 | bindAddress: config.bridge.bindAddress, 43 | registration: { 44 | ...registration, 45 | namespaces: { 46 | // Support multiple users 47 | users: [ 48 | { 49 | regex: 50 | "(" + 51 | registration.namespaces.users.map((r) => r.regex).join(")|(") + 52 | ")", 53 | exclusive: true, 54 | }, 55 | ], 56 | aliases: registration.namespaces.aliases, 57 | rooms: registration.namespaces.rooms, 58 | }, 59 | }, 60 | storage: storage, 61 | intentOptions: { 62 | encryption: !!cryptoStorage, 63 | }, 64 | cryptoStorage: cryptoStorage, 65 | }); 66 | 67 | Metrics.registerMatrixSdkMetrics(appservice); 68 | 69 | return { appservice, storage, cryptoStorage }; 70 | } 71 | -------------------------------------------------------------------------------- /src/Connections/BaseConnection.ts: -------------------------------------------------------------------------------- 1 | import { FormatUtil } from "../FormatUtil"; 2 | 3 | /** 4 | * Base connection class from which all connections should extend from. 5 | */ 6 | export abstract class BaseConnection { 7 | constructor( 8 | public readonly roomId: string, 9 | public readonly stateKey: string, 10 | public readonly canonicalStateType: string, 11 | ) {} 12 | 13 | public get connectionId(): string { 14 | return FormatUtil.hashId( 15 | `${this.roomId}/${this.canonicalStateType}/${this.stateKey}`, 16 | ); 17 | } 18 | 19 | public get priority(): number { 20 | return -1; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Connections/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./GenericHook"; 2 | export * from "./GithubDiscussion"; 3 | export * from "./GithubDiscussionSpace"; 4 | export * from "./GithubIssue"; 5 | export * from "./GithubProject"; 6 | export * from "./GithubRepo"; 7 | export * from "./GithubUserSpace"; 8 | export * from "./GitlabIssue"; 9 | export * from "./GitlabRepo"; 10 | export * from "./IConnection"; 11 | export * from "./JiraProject"; 12 | export * from "./FigmaFileConnection"; 13 | export * from "./FeedConnection"; 14 | export * from "./OutboundHook"; 15 | -------------------------------------------------------------------------------- /src/Errors.ts: -------------------------------------------------------------------------------- 1 | export class CommandError extends Error { 2 | constructor( 3 | public readonly message: string, 4 | public readonly humanError?: string, 5 | ) { 6 | super(message); 7 | } 8 | } 9 | 10 | export class NotLoggedInError extends CommandError { 11 | constructor() { 12 | super("User is not logged in", "You are not logged in"); 13 | } 14 | } 15 | 16 | export class ConfigError extends Error { 17 | constructor( 18 | public readonly configPath: string, 19 | public readonly msg?: string, 20 | ) { 21 | super(`There was an error in the config (${configPath}): ${msg}`); 22 | } 23 | } 24 | 25 | export enum TokenErrorCode { 26 | EXPIRED = "The token has expired.", 27 | } 28 | export class TokenError extends Error { 29 | constructor( 30 | public readonly code: TokenErrorCode, 31 | public readonly innerError: string, 32 | ) { 33 | super(code); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/HookFilter.ts: -------------------------------------------------------------------------------- 1 | export class HookFilter { 2 | static convertIgnoredHooksToEnabledHooks( 3 | explicitlyEnabledHooks: T[] = [], 4 | ignoredHooks: T[], 5 | defaultHooks: T[], 6 | ): T[] { 7 | const resultHookSet = new Set([...explicitlyEnabledHooks, ...defaultHooks]); 8 | 9 | // For each ignored hook, remove anything that matches. 10 | for (const ignoredHook of ignoredHooks) { 11 | resultHookSet.delete(ignoredHook); 12 | // If the hook is a "root" hook name, remove all children. 13 | for (const enabledHook of resultHookSet) { 14 | if (enabledHook.startsWith(`${ignoredHook}.`)) { 15 | resultHookSet.delete(enabledHook); 16 | } 17 | } 18 | } 19 | 20 | return [...resultHookSet]; 21 | } 22 | 23 | constructor(public enabledHooks: T[] = []) {} 24 | 25 | public shouldSkip(...hookName: T[]) { 26 | // Should skip if all of the hook names are missing 27 | return hookName.every((name) => !this.enabledHooks.includes(name)); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/MatrixEvent.ts: -------------------------------------------------------------------------------- 1 | export interface MatrixEvent { 2 | content: T; 3 | event_id: string; 4 | origin_server_ts: number; 5 | sender: string; 6 | state_key: string | undefined; 7 | type: string; 8 | } 9 | 10 | type MatrixEventContent = object; 11 | 12 | export interface MatrixMemberContent extends MatrixEventContent { 13 | avatar_url: string | null; 14 | displayname: string | null; 15 | membership: "invite" | "join" | "knock" | "leave" | "ban"; 16 | is_direct?: boolean; 17 | // Deliberately ignoring third_party_invite, unsigned 18 | } 19 | 20 | export interface MatrixMessageContent extends MatrixEventContent { 21 | body: string; 22 | formatted_body?: string; 23 | format?: string; 24 | msgtype: 25 | | "m.text" 26 | | "m.notice" 27 | | "m.image" 28 | | "m.video" 29 | | "m.audio" 30 | | "m.emote"; 31 | "m.relates_to"?: { 32 | "m.in_reply_to"?: { 33 | event_id: string; 34 | }; 35 | }; 36 | } 37 | 38 | export interface MatrixReactionContent extends MatrixEventContent { 39 | "m.relates_to": { 40 | event_id: string; 41 | key: string; 42 | rel_type: "m.annotation"; 43 | }; 44 | } 45 | -------------------------------------------------------------------------------- /src/NotificationFilters.ts: -------------------------------------------------------------------------------- 1 | interface FilterContent { 2 | users: string[]; 3 | repos: string[]; 4 | orgs: string[]; 5 | } 6 | 7 | export interface NotificationFilterStateContent { 8 | filters: { 9 | [name: string]: FilterContent; 10 | }; 11 | forNotifications: string[]; 12 | forInvites: string[]; 13 | } 14 | 15 | /** 16 | * A notification filter is a set of keys that define what should be sent to the user. 17 | */ 18 | export class NotifFilter { 19 | static readonly StateType = "uk.half-shot.matrix-github.notif-filter"; 20 | static readonly LegacyStateType = "uk.half-shot.matrix-hookshot.notif-filter"; 21 | 22 | static getDefaultContent(): NotificationFilterStateContent { 23 | return { 24 | filters: {}, 25 | forNotifications: [], 26 | forInvites: [], 27 | }; 28 | } 29 | 30 | public readonly forNotifications: Set; 31 | public readonly forInvites: Set; 32 | public filters: Record; 33 | constructor(stateContent: NotificationFilterStateContent) { 34 | this.forNotifications = new Set(stateContent.forNotifications); 35 | this.forInvites = new Set(stateContent.forInvites); 36 | this.filters = stateContent.filters; 37 | } 38 | 39 | public get empty() { 40 | return Object.values(this.filters).length === 0; 41 | } 42 | 43 | public getStateContent(): NotificationFilterStateContent { 44 | return { 45 | filters: this.filters, 46 | forInvites: [...this.forInvites], 47 | forNotifications: [...this.forNotifications], 48 | }; 49 | } 50 | 51 | public shouldInviteToRoom(): boolean { 52 | return false; 53 | } 54 | 55 | public shouldSendNotification( 56 | user?: string, 57 | repo?: string, 58 | org?: string, 59 | ): boolean { 60 | if (this.forNotifications.size === 0) { 61 | // Default on. 62 | return true; 63 | } 64 | for (const filterName of this.forNotifications) { 65 | const filter = this.filters[filterName]; 66 | if (!filter) { 67 | // Filter with this name exists. 68 | continue; 69 | } 70 | if (user && filter.users.includes(user.toLowerCase())) { 71 | // We have a user in this notif and we are filtering on users. 72 | return true; 73 | } 74 | if (repo && filter.repos.includes(repo.toLowerCase())) { 75 | // We have a repo in this notif and we are filtering on repos. 76 | return true; 77 | } 78 | if (org && filter.orgs.includes(org.toLowerCase())) { 79 | // We have an org in this notif and we are filtering on orgs. 80 | return true; 81 | } 82 | // None of the filters matched, so exclude the result. 83 | return false; 84 | } 85 | return false; 86 | } 87 | 88 | public setFilter(name: string, filter: FilterContent) { 89 | this.filters[name] = filter; 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/PromiseUtil.ts: -------------------------------------------------------------------------------- 1 | import { StatusCodes } from "http-status-codes"; 2 | 3 | const SLEEP_TIME_MS = 1000; 4 | const EXPONENT_DIVISOR = 20; 5 | const DEFAULT_RETRY = () => true; 6 | 7 | type RetryFn = (error: Error) => boolean | number; 8 | 9 | /** 10 | * Checks errors returned from a Matrix API request, and determines 11 | * if the error should be retried. 12 | * @param err An Error object, which may be a MatrixError 13 | * @returns - `true` if the action should be reried. 14 | * - A `number` if the action should be retried with a specific wait period. 15 | * - `false` if the action should not be retried.. 16 | */ 17 | export function retryMatrixErrorFilter( 18 | err: Error | { statusCode: number; retryAfterMs?: number }, 19 | ) { 20 | if ("statusCode" in err && err.statusCode >= 400 && err.statusCode <= 499) { 21 | if (err.statusCode === StatusCodes.TOO_MANY_REQUESTS) { 22 | return err.retryAfterMs ?? true; 23 | } 24 | return false; 25 | } 26 | return true; 27 | } 28 | 29 | /** 30 | * Runs a function, and retries it if the filter function permits it. 31 | * @param actionFn The action to run 32 | * @param maxAttempts The number of attempts to make before giving up. 33 | * @param waitFor The number of milliseconds to wait between attempts. May be overrideb by filterFn. 34 | * @param filterFn A function that checks the error on failure, and determines if the action should be retried. By default, this retries ALL failures. 35 | * @returns The result of actionFn 36 | * @throws If the `maxAttempts` limit is exceeded, or the `filterFn` returns false. 37 | */ 38 | export async function retry( 39 | actionFn: () => PromiseLike, 40 | maxAttempts: number, 41 | waitFor: number = SLEEP_TIME_MS, 42 | filterFn: RetryFn = DEFAULT_RETRY, 43 | ): Promise { 44 | let attempts = 0; 45 | while (attempts < maxAttempts) { 46 | attempts++; 47 | try { 48 | return await actionFn(); 49 | } catch (ex) { 50 | const shouldRetry = filterFn(ex); 51 | if (shouldRetry) { 52 | // If the filter returns a retry ms, use that. 53 | const timeMs = 54 | typeof shouldRetry === "number" 55 | ? // 56 | shouldRetry 57 | : Math.pow(waitFor, 1 + attempts / EXPONENT_DIVISOR); 58 | await new Promise((r) => setTimeout(r, timeMs)); 59 | } else { 60 | throw ex; 61 | } 62 | } 63 | } 64 | throw Error("Timed out"); 65 | } 66 | -------------------------------------------------------------------------------- /src/UserAgent.ts: -------------------------------------------------------------------------------- 1 | import { getBridgeVersion } from "matrix-appservice-bridge"; 2 | 3 | const UserAgent = `matrix-hookshot/${getBridgeVersion()} (+https://github.com/matrix-org/matrix-hookshot)`; 4 | export default UserAgent; 5 | -------------------------------------------------------------------------------- /src/api/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./Error"; 2 | -------------------------------------------------------------------------------- /src/config/Decorators.ts: -------------------------------------------------------------------------------- 1 | import "reflect-metadata"; 2 | 3 | const configKeyMetadataKey = Symbol("configKey"); 4 | 5 | export function configKey(comment?: string, optional = false) { 6 | return Reflect.metadata(configKeyMetadataKey, [comment, optional]); 7 | } 8 | 9 | export function getConfigKeyMetadata( 10 | target: any, 11 | propertyKey: string, 12 | ): [string, boolean] | null { 13 | return Reflect.getMetadata(configKeyMetadataKey, target, propertyKey); 14 | } 15 | 16 | const hideKeyMetadataKey = Symbol("hideKey"); 17 | export function hideKey() { 18 | return Reflect.metadata(hideKeyMetadataKey, true); 19 | } 20 | 21 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 22 | export function keyIsHidden(target: any, propertyKey: string): boolean { 23 | return ( 24 | Reflect.getMetadata(hideKeyMetadataKey, target, propertyKey) !== undefined 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /src/config/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod permissions; 2 | -------------------------------------------------------------------------------- /src/config/sections/Cache.ts: -------------------------------------------------------------------------------- 1 | export interface BridgeConfigCache { 2 | /** 3 | * A redis URI string 4 | * @example `redis://user:password@host:port/dbnum` 5 | */ 6 | redisUri: string; 7 | } 8 | -------------------------------------------------------------------------------- /src/config/sections/Encryption.ts: -------------------------------------------------------------------------------- 1 | import { ConfigError } from "../../Errors"; 2 | import { configKey } from "../Decorators"; 3 | 4 | interface BridgeConfigEncryptionYAML { 5 | storagePath: string; 6 | } 7 | 8 | export class BridgeConfigEncryption { 9 | @configKey( 10 | "Path to the directory used to store encryption files. These files must be persist between restarts of the service.", 11 | ) 12 | public readonly storagePath: string; 13 | 14 | constructor( 15 | config: BridgeConfigEncryptionYAML, 16 | cache: unknown | undefined, 17 | queue: unknown | undefined, 18 | ) { 19 | if (typeof config.storagePath !== "string" || !config.storagePath) { 20 | throw new ConfigError( 21 | "encryption.storagePath", 22 | "The crypto storage path must not be empty.", 23 | ); 24 | } 25 | this.storagePath = config.storagePath; 26 | 27 | if (!cache) { 28 | throw new ConfigError( 29 | "cache", 30 | "Encryption requires the Redis cache to be enabled.", 31 | ); 32 | } 33 | if (queue) { 34 | throw new ConfigError( 35 | "queue", 36 | "Encryption does not support message queues.", 37 | ); 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/config/sections/GenericHooks.ts: -------------------------------------------------------------------------------- 1 | import { GenericHookServiceConfig } from "../../Connections"; 2 | import { ConfigError } from "../../Errors"; 3 | import { hideKey } from "../Decorators"; 4 | const parseDurationImport = import("parse-duration"); 5 | 6 | function makePrefixedUrl(urlString: string): URL { 7 | return new URL(urlString.endsWith("/") ? urlString : urlString + "/"); 8 | } 9 | 10 | export interface BridgeGenericWebhooksConfigYAML { 11 | enabled: boolean; 12 | urlPrefix: string; 13 | userIdPrefix?: string; 14 | allowJsTransformationFunctions?: boolean; 15 | waitForComplete?: boolean; 16 | enableHttpGet?: boolean; 17 | outbound?: boolean; 18 | maxExpiryTime?: string; 19 | sendExpiryNotice?: boolean; 20 | requireExpiryTime?: boolean; 21 | includeHookBody?: boolean; 22 | } 23 | 24 | export class BridgeConfigGenericWebhooks { 25 | public readonly enabled: boolean; 26 | public readonly outbound: boolean; 27 | 28 | @hideKey() 29 | public readonly parsedUrlPrefix: URL; 30 | public readonly urlPrefix: () => string; 31 | 32 | public readonly userIdPrefix?: string; 33 | public readonly allowJsTransformationFunctions?: boolean; 34 | public readonly waitForComplete?: boolean; 35 | public readonly enableHttpGet: boolean; 36 | 37 | @hideKey() 38 | public readonly maxExpiryTimeMs?: Promise; 39 | public readonly sendExpiryNotice: boolean; 40 | public readonly requireExpiryTime: boolean; 41 | // Public facing value for config generator 42 | public readonly maxExpiryTime?: string; 43 | public readonly includeHookBody: boolean; 44 | 45 | constructor(yaml: BridgeGenericWebhooksConfigYAML) { 46 | this.enabled = yaml.enabled || false; 47 | this.outbound = yaml.outbound || false; 48 | this.enableHttpGet = yaml.enableHttpGet || false; 49 | this.sendExpiryNotice = yaml.sendExpiryNotice || false; 50 | this.requireExpiryTime = yaml.requireExpiryTime || false; 51 | this.includeHookBody = yaml.includeHookBody ?? true; 52 | try { 53 | this.parsedUrlPrefix = makePrefixedUrl(yaml.urlPrefix); 54 | this.urlPrefix = () => { 55 | return this.parsedUrlPrefix.href; 56 | }; 57 | } catch { 58 | throw new ConfigError( 59 | "generic.urlPrefix", 60 | "is not defined or not a valid URL", 61 | ); 62 | } 63 | this.userIdPrefix = yaml.userIdPrefix; 64 | this.allowJsTransformationFunctions = yaml.allowJsTransformationFunctions; 65 | this.waitForComplete = yaml.waitForComplete; 66 | this.maxExpiryTime = yaml.maxExpiryTime; 67 | this.maxExpiryTimeMs = yaml.maxExpiryTime 68 | ? parseDurationImport.then( 69 | (v) => v.default(yaml.maxExpiryTime!) ?? undefined, 70 | ) 71 | : undefined; 72 | } 73 | 74 | @hideKey() 75 | public get publicConfig(): Promise { 76 | return (async () => ({ 77 | userIdPrefix: this.userIdPrefix, 78 | allowJsTransformationFunctions: this.allowJsTransformationFunctions, 79 | waitForComplete: this.waitForComplete, 80 | maxExpiryTime: await this.maxExpiryTimeMs, 81 | requireExpiryTime: this.requireExpiryTime, 82 | }))(); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/config/sections/OpenProject.ts: -------------------------------------------------------------------------------- 1 | import assert from "assert"; 2 | import { hideKey } from "../Decorators"; 3 | import { OpenProjectServiceConfig } from "../../Connections/OpenProjectConnection"; 4 | 5 | export interface BridgeOpenProjectOAuthConfig { 6 | clientId: string; 7 | clientSecret: string; 8 | redirectUri: string; 9 | } 10 | 11 | export interface BridgeOpenProjectConfigYAML { 12 | webhook: { 13 | secret: string; 14 | }; 15 | baseUrl: string; 16 | oauth?: BridgeOpenProjectOAuthConfig; 17 | } 18 | 19 | function makePrefixedUrl(urlString?: string): URL { 20 | return new URL(urlString?.endsWith("/") ? urlString : urlString + "/"); 21 | } 22 | export class BridgeOpenProjectConfig { 23 | /** 24 | * @ignore For config generator only. 25 | */ 26 | public readonly baseUrl; 27 | 28 | /** 29 | * @ignore For config generator only. 30 | */ 31 | public readonly webhook: { secret: string }; 32 | 33 | @hideKey() 34 | baseURL: URL; 35 | oauth?: BridgeOpenProjectOAuthConfig; 36 | 37 | constructor(config: BridgeOpenProjectConfigYAML) { 38 | assert(config.webhook?.secret); 39 | this.webhook = config.webhook; 40 | this.baseURL = makePrefixedUrl(config.baseUrl); 41 | this.baseUrl = config.baseUrl; 42 | if (config.oauth) { 43 | assert(config.oauth.clientId); 44 | assert(config.oauth.clientSecret); 45 | assert(config.oauth.redirectUri); 46 | this.oauth = config.oauth; 47 | } 48 | } 49 | 50 | @hideKey() 51 | public get publicConfig(): OpenProjectServiceConfig { 52 | return { 53 | baseUrl: this.baseURL.origin + this.baseURL.pathname, 54 | }; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/config/sections/Queue.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Configuration for the message queue. 3 | */ 4 | interface BridgeConfigQueueBase { 5 | /** 6 | * Controls whether the queue config is used just for the cache (monolithic), 7 | * or the message queue as well. 8 | * @deprecated Use the `cache` config instead to control this seperately. 9 | */ 10 | monolithic?: boolean; 11 | } 12 | 13 | interface BridgeConfigQueueUri extends BridgeConfigQueueBase { 14 | redisUri: string; 15 | } 16 | 17 | interface BridgeConfigQueueLegacyOptions extends BridgeConfigQueueBase { 18 | port?: number; 19 | host?: string; 20 | } 21 | 22 | export type BridgeConfigQueue = 23 | | BridgeConfigQueueUri 24 | | BridgeConfigQueueLegacyOptions; 25 | -------------------------------------------------------------------------------- /src/config/sections/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./Cache"; 2 | export * from "./Queue"; 3 | export * from "./GenericHooks"; 4 | -------------------------------------------------------------------------------- /src/feeds/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod parser; 2 | -------------------------------------------------------------------------------- /src/figma/Router.ts: -------------------------------------------------------------------------------- 1 | import { BridgeConfigFigma } from "../config/Config"; 2 | import { MessageQueue } from "../messageQueue"; 3 | import { Request, Response, Router, json } from "express"; 4 | import { FigmaPayload } from "./Types"; 5 | import { Logger } from "matrix-appservice-bridge"; 6 | 7 | const log = new Logger("FigmaWebhooksRouter"); 8 | 9 | export class FigmaWebhooksRouter { 10 | constructor( 11 | private readonly config: BridgeConfigFigma, 12 | private readonly queue: MessageQueue, 13 | ) {} 14 | 15 | private onWebhook( 16 | req: Request, 17 | res: Response, 18 | ) { 19 | const payload = req.body; 20 | const instance = Object.entries(this.config.instances).find( 21 | ([, p]) => p.passcode === payload.passcode, 22 | ); 23 | log.debug(`Got figma webhook for instance ${instance?.[0]}`); 24 | if (!instance) { 25 | // No instance found 26 | res.sendStatus(401); 27 | return; 28 | } 29 | if ( 30 | typeof payload.file_name !== "string" || 31 | typeof payload.file_key !== "string" 32 | ) { 33 | res 34 | .status(400) 35 | .send({ error: "Missing required object keys file_name, file_key" }); 36 | return; 37 | } 38 | res.status(200).send(); 39 | this.queue.push({ 40 | eventName: "figma.payload", 41 | data: { 42 | payload, 43 | instanceName: instance[0], 44 | }, 45 | sender: "GithubWebhooks", 46 | }); 47 | } 48 | 49 | public getRouter() { 50 | const router = Router(); 51 | router.use(json()); 52 | router.post("/webhook", this.onWebhook.bind(this)); 53 | return router; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/figma/Types.ts: -------------------------------------------------------------------------------- 1 | export interface FigmaPayload { 2 | comment_id: string; 3 | comment: [{ text: string }]; 4 | created_at: string; 5 | event_type: string; 6 | file_key: string; 7 | file_name: string; 8 | mentions: unknown[]; 9 | order_id: string; 10 | parent_id?: string; 11 | passcode: string; 12 | protocol_version: string; 13 | resolved_at: string; 14 | retries: number; 15 | timestamp: string; 16 | triggered_by: { id: string; handle: string }; 17 | webhook_id: string; 18 | } 19 | 20 | export interface FigmaEvent { 21 | payload: FigmaPayload; 22 | instanceName: string; 23 | } 24 | -------------------------------------------------------------------------------- /src/generic/Types.ts: -------------------------------------------------------------------------------- 1 | import { ExecuteResultWebhookResponse } from "./WebhookTransformer"; 2 | 3 | export interface GenericWebhookEvent { 4 | hookData: unknown; 5 | hookId: string; 6 | } 7 | 8 | export type GenericWebhookEventResult = 9 | | GenericWebhookEventResultSuccess 10 | | GenericWebhookEventResultFailure; 11 | 12 | export interface GenericWebhookEventResultSuccess { 13 | successful: true | null; 14 | response?: ExecuteResultWebhookResponse; 15 | notFound?: boolean; 16 | } 17 | export interface GenericWebhookEventResultFailure { 18 | successful: false; 19 | statusCode?: number; 20 | error?: string; 21 | notFound?: boolean; 22 | } 23 | -------------------------------------------------------------------------------- /src/github/Discussion.ts: -------------------------------------------------------------------------------- 1 | export interface DiscussionQLResponse { 2 | id: string; 3 | number: number; 4 | author: { 5 | login: string; 6 | avatarUrl: string; 7 | }; 8 | bodyHTML: string; 9 | bodyText: string; 10 | category: { 11 | name: string; 12 | id: string; 13 | }; 14 | createdAt: string; 15 | locked: boolean; 16 | title: string; 17 | url: string; 18 | } 19 | 20 | export const DiscussionQL = ` 21 | id, 22 | number, 23 | answer { 24 | id, 25 | } 26 | author{ 27 | login, 28 | avatarUrl, 29 | } 30 | bodyHTML, 31 | bodyText, 32 | category { 33 | name, 34 | id, 35 | }, 36 | createdAt, 37 | locked, 38 | title, 39 | url, 40 | `; 41 | -------------------------------------------------------------------------------- /src/github/GrantChecker.ts: -------------------------------------------------------------------------------- 1 | import { Appservice } from "matrix-bot-sdk"; 2 | import { GitHubRepoConnection } from "../Connections"; 3 | import { GrantChecker } from "../grants/GrantCheck"; 4 | import { UserTokenStore } from "../tokens/UserTokenStore"; 5 | import { Logger } from "matrix-appservice-bridge"; 6 | 7 | const log = new Logger("GitHubGrantChecker"); 8 | 9 | interface GitHubGrantConnectionId { 10 | org: string; 11 | repo: string; 12 | } 13 | 14 | export class GitHubGrantChecker extends GrantChecker { 15 | constructor( 16 | private readonly as: Appservice, 17 | private readonly tokenStore: UserTokenStore, 18 | ) { 19 | super(as.botIntent, "github"); 20 | } 21 | 22 | protected async checkFallback( 23 | roomId: string, 24 | connectionId: GitHubGrantConnectionId, 25 | sender?: string, 26 | ) { 27 | if (!sender) { 28 | log.debug(`Tried to check fallback for ${roomId} with a missing sender`); 29 | // Cannot validate without a sender. 30 | return false; 31 | } 32 | if (this.as.isNamespacedUser(sender)) { 33 | // Bridge is always valid. 34 | return true; 35 | } 36 | try { 37 | await GitHubRepoConnection.assertUserHasAccessToRepo( 38 | sender, 39 | connectionId.org, 40 | connectionId.repo, 41 | this.tokenStore, 42 | ); 43 | return true; 44 | } catch (ex) { 45 | log.info( 46 | `Tried to check fallback for ${roomId}: ${sender} does not have access to ${connectionId.org}/${connectionId.repo}`, 47 | ex, 48 | ); 49 | return false; 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/github/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod types; 2 | -------------------------------------------------------------------------------- /src/github/types.rs: -------------------------------------------------------------------------------- 1 | #[derive(Serialize, Debug, Deserialize)] 2 | #[napi(object)] 3 | pub struct MinimalGitHubRepo { 4 | pub id: u32, 5 | #[napi(js_name = "full_name")] 6 | pub full_name: String, 7 | #[napi(js_name = "html_url")] 8 | pub html_url: String, 9 | pub description: Option, 10 | } 11 | 12 | #[derive(Serialize, Debug, Deserialize)] 13 | #[napi(object)] 14 | pub struct MinimalGitHubIssue { 15 | pub id: u32, 16 | #[napi(js_name = "html_url")] 17 | pub html_url: String, 18 | pub number: u32, 19 | pub title: String, 20 | } 21 | 22 | #[derive(Serialize, Debug, Deserialize)] 23 | #[napi(object)] 24 | pub struct GitHubIssueMessageBodyRepo { 25 | pub id: u32, 26 | pub name: String, 27 | pub url: String, 28 | } 29 | 30 | #[derive(Serialize, Debug, Deserialize)] 31 | #[napi(object)] 32 | pub struct GitHubIssueMessageBodyIssue { 33 | pub id: u32, 34 | pub number: u32, 35 | pub title: String, 36 | pub url: String, 37 | } 38 | 39 | #[derive(Serialize, Debug, Deserialize)] 40 | #[napi(object)] 41 | pub struct GitHubRepoMessageBody { 42 | #[serde(rename = "uk.half-shot.matrix-hookshot.github.repo")] 43 | #[napi(js_name = "uk.half-shot.matrix-hookshot.github.repo")] 44 | pub repo: GitHubIssueMessageBodyRepo, 45 | #[napi(js_name = "external_url")] 46 | pub external_url: String, 47 | } 48 | 49 | #[derive(Serialize, Debug, Deserialize)] 50 | #[napi(object)] 51 | pub struct GitHubIssueMessageBody { 52 | #[serde(rename = "uk.half-shot.matrix-hookshot.github.issue")] 53 | #[napi(js_name = "uk.half-shot.matrix-hookshot.github.issue")] 54 | pub issue: GitHubIssueMessageBodyIssue, 55 | #[serde(rename = "uk.half-shot.matrix-hookshot.github.repo")] 56 | #[napi(js_name = "uk.half-shot.matrix-hookshot.github.repo")] 57 | pub repo: GitHubIssueMessageBodyRepo, 58 | #[napi(js_name = "external_url")] 59 | pub external_url: String, 60 | } 61 | -------------------------------------------------------------------------------- /src/gitlab/GrantChecker.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from "matrix-appservice-bridge"; 2 | import { Appservice } from "matrix-bot-sdk"; 3 | import { BridgeConfigGitLab } from "../config/Config"; 4 | import { GitLabRepoConnection } from "../Connections"; 5 | import { GrantChecker } from "../grants/GrantCheck"; 6 | import { UserTokenStore } from "../tokens/UserTokenStore"; 7 | 8 | const log = new Logger("GitLabGrantChecker"); 9 | 10 | interface GitLabGrantConnectionId { 11 | instance: string; 12 | path: string; 13 | } 14 | 15 | export class GitLabGrantChecker extends GrantChecker { 16 | constructor( 17 | private readonly as: Appservice, 18 | private readonly config: BridgeConfigGitLab, 19 | private readonly tokenStore: UserTokenStore, 20 | ) { 21 | super(as.botIntent, "gitlab"); 22 | } 23 | 24 | protected async checkFallback( 25 | roomId: string, 26 | connectionId: GitLabGrantConnectionId, 27 | sender?: string, 28 | ) { 29 | if (!sender) { 30 | log.debug(`Tried to check fallback for ${roomId} with a missing sender`); 31 | // Cannot validate without a sender. 32 | return false; 33 | } 34 | if (this.as.isNamespacedUser(sender)) { 35 | // Bridge is always valid. 36 | return true; 37 | } 38 | try { 39 | await GitLabRepoConnection.assertUserHasAccessToProject( 40 | connectionId.instance, 41 | connectionId.path, 42 | sender, 43 | this.tokenStore, 44 | this.config, 45 | ); 46 | return true; 47 | } catch (ex) { 48 | log.info( 49 | `${sender} does not have access to ${connectionId.instance}/${connectionId.path}`, 50 | ex, 51 | ); 52 | return false; 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/jira/Client.ts: -------------------------------------------------------------------------------- 1 | import axios, { Method } from "axios"; 2 | import JiraApi from "jira-client"; 3 | import { JiraAccount, JiraAPIAccessibleResource, JiraProject } from "./Types"; 4 | 5 | export function isJiraCloudInstance(host: string) { 6 | return host.endsWith("atlassian.net"); 7 | } 8 | 9 | export interface JiraClient { 10 | getAccessibleResources(): Promise; 11 | getClientForUrl(url: URL): Promise; 12 | getClientForName(name: string): Promise; 13 | getClientForResource( 14 | res: JiraAPIAccessibleResource, 15 | ): Promise; 16 | } 17 | 18 | export class JiraApiError extends Error { 19 | constructor( 20 | readonly errorMessages: string[], 21 | readonly errors: { description: string }, 22 | ) { 23 | super(); 24 | } 25 | 26 | public get message() { 27 | return `JIRA API Error: ${this.errors.description}`; 28 | } 29 | } 30 | 31 | export abstract class HookshotJiraApi extends JiraApi { 32 | constructor( 33 | private options: JiraApi.JiraApiOptions, 34 | private readonly res: JiraAPIAccessibleResource, 35 | ) { 36 | super(options); 37 | } 38 | 39 | public get resource() { 40 | return this.res; 41 | } 42 | 43 | public abstract getAllProjects( 44 | query?: string, 45 | maxResults?: number, 46 | ): AsyncIterable; 47 | 48 | protected async apiRequest( 49 | path: string, 50 | method?: Method, 51 | data?: undefined, 52 | ): Promise; 53 | protected async apiRequest( 54 | path: string, 55 | method: Method, 56 | data?: R, 57 | ): Promise { 58 | const url = `https://${this.options.host}/${this.options.base}${path}`; 59 | const res = await axios.request({ 60 | url, 61 | method: method || "GET", 62 | data, 63 | headers: { 64 | Authorization: `Bearer ${this.options.bearer}`, 65 | }, 66 | responseType: "json", 67 | }); 68 | return res.data; 69 | } 70 | 71 | async getProject(projectIdOrKey: string): Promise { 72 | return (await super.getProject(projectIdOrKey)) as JiraProject; 73 | } 74 | 75 | async searchUsers( 76 | opts: 77 | | { query: string; maxResults?: number } 78 | | { username: string; maxResults?: number }, 79 | ): Promise { 80 | // Types are wrong here. 81 | return super.searchUsers(opts as never) as unknown as JiraAccount[]; 82 | } 83 | 84 | async addNewIssue(issue: JiraApi.IssueObject): Promise { 85 | const res = await super.addNewIssue(issue); 86 | if (res.errors) { 87 | throw new JiraApiError(res.errorMessages, res.errors); 88 | } 89 | return res; 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/jira/GrantChecker.ts: -------------------------------------------------------------------------------- 1 | import { Appservice } from "matrix-bot-sdk"; 2 | import { JiraProjectConnection } from "../Connections"; 3 | import { GrantChecker } from "../grants/GrantCheck"; 4 | import { UserTokenStore } from "../tokens/UserTokenStore"; 5 | 6 | interface JiraGrantConnectionId { 7 | url: string; 8 | } 9 | 10 | export class JiraGrantChecker extends GrantChecker { 11 | constructor( 12 | private readonly as: Appservice, 13 | private readonly tokenStore: UserTokenStore, 14 | ) { 15 | super(as.botIntent, "jira"); 16 | } 17 | 18 | protected async checkFallback( 19 | roomId: string, 20 | connectionId: JiraGrantConnectionId, 21 | sender?: string, 22 | ) { 23 | if (!sender) { 24 | // Cannot validate without a sender. 25 | return false; 26 | } 27 | if (this.as.isNamespacedUser(sender)) { 28 | // Bridge is always valid. 29 | return true; 30 | } 31 | try { 32 | await JiraProjectConnection.assertUserHasAccessToProject( 33 | this.tokenStore, 34 | sender, 35 | connectionId.url, 36 | ); 37 | return true; 38 | } catch { 39 | return false; 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/jira/OAuth.ts: -------------------------------------------------------------------------------- 1 | import { JiraOAuthResult } from "./Types"; 2 | 3 | export interface JiraOAuth { 4 | getAuthUrl(state: string): Promise; 5 | exchangeRequestForToken( 6 | codeOrToken: string, 7 | verifier?: string, 8 | ): Promise; 9 | } 10 | 11 | export interface JiraOAuthRequestOnPrem { 12 | state: string; 13 | oauthToken: string; 14 | oauthVerifier: string; 15 | } 16 | 17 | export function encodeJiraToken( 18 | oauthToken: string, 19 | oauthTokenSecret: string, 20 | ): string { 21 | return `jira-oauth1.0:${oauthToken}/${oauthTokenSecret}`; 22 | } 23 | 24 | export function decodeJiraToken(token: string): { 25 | oauthToken: string; 26 | oauthTokenSecret: string; 27 | } { 28 | const [oauthToken, oauthTokenSecret] = token 29 | .substring("jira-oauth1.0:".length) 30 | .split("/"); 31 | return { oauthToken, oauthTokenSecret }; 32 | } 33 | -------------------------------------------------------------------------------- /src/jira/Types.ts: -------------------------------------------------------------------------------- 1 | export interface JiraIssueType { 2 | self: string; 3 | id: string; 4 | description: string; 5 | iconUrl: string; 6 | name: string; 7 | subtask: boolean; 8 | avatarId: number; 9 | hierachyLevel: number; 10 | } 11 | export interface JiraProject { 12 | /** 13 | * URL 14 | */ 15 | self: string; 16 | id: string; 17 | key: string; 18 | name: string; 19 | projectTypeKey: string; 20 | avatarUrls?: Record; 21 | simplified?: boolean; 22 | issueTypes?: JiraIssueType[]; 23 | } 24 | 25 | export interface JiraAccount { 26 | /** 27 | * URL 28 | */ 29 | self: string; 30 | accountId: string; 31 | avatarUrls: Record; 32 | displayName: string; 33 | active: boolean; 34 | timeZone: string; 35 | /** 36 | * e.g. "atlassian" 37 | */ 38 | accountType: string; 39 | } 40 | 41 | export interface JiraComment { 42 | /** 43 | * URL 44 | */ 45 | self: string; 46 | id: string; 47 | author: JiraAccount; 48 | body: string; 49 | updateAuthor: JiraAccount; 50 | created: string; 51 | updated: string; 52 | jsdPublic: boolean; 53 | } 54 | 55 | export interface JiraIssue { 56 | /** 57 | * URL 58 | */ 59 | self: string; 60 | id: string; 61 | key: string; 62 | fields: { 63 | summary: string; 64 | issuetype: unknown; 65 | project: JiraProject; 66 | assignee: null | unknown; 67 | priority: unknown; 68 | status: unknown; 69 | creator?: JiraAccount; 70 | }; 71 | } 72 | 73 | export interface JiraVersion { 74 | /** 75 | * URL 76 | */ 77 | self: string; 78 | id: string; 79 | description: string; 80 | name: string; 81 | archived: boolean; 82 | released: boolean; 83 | startDate?: string; 84 | releaseDate?: string; 85 | overdue: boolean; 86 | userStartDate?: string; 87 | userReleaseDate?: string; 88 | project?: string; 89 | projectId: number; 90 | } 91 | 92 | export interface JiraStoredToken { 93 | expires_in?: number; 94 | access_token: string; 95 | refresh_token?: string; 96 | instance: string; 97 | } 98 | 99 | export interface JiraOAuthResult { 100 | state?: string; 101 | access_token: string; 102 | refresh_token?: string; 103 | expires_in?: number; 104 | scope: string; 105 | } 106 | 107 | export interface JiraAPIAccessibleResource { 108 | id: string; 109 | url: string; 110 | name: string; 111 | scopes?: string[]; 112 | avatarUrl?: string; 113 | } 114 | 115 | export interface JiraCloudProjectSearchResponse { 116 | nextPage: string; 117 | maxResults: number; 118 | startAt: number; 119 | isLast: boolean; 120 | values: JiraProject[]; 121 | } 122 | 123 | export type JiraOnPremProjectSearchResponse = JiraProject[]; 124 | -------------------------------------------------------------------------------- /src/jira/WebhookTypes.ts: -------------------------------------------------------------------------------- 1 | import { JiraAccount, JiraComment, JiraIssue, JiraVersion } from "./Types"; 2 | 3 | export interface IJiraWebhookEvent { 4 | timestamp: number; 5 | webhookEvent: string; 6 | } 7 | 8 | export interface JiraCommentCreatedEvent extends IJiraWebhookEvent { 9 | webhookEvent: "comment_created"; 10 | comment: JiraComment; 11 | issue: JiraIssue; 12 | } 13 | 14 | export interface JiraIssueEvent extends IJiraWebhookEvent { 15 | webhookEvent: "issue_updated" | "issue_created"; 16 | comment: JiraComment; 17 | issue: JiraIssue; 18 | } 19 | 20 | export interface JiraIssueUpdatedEvent extends JiraIssueEvent { 21 | webhookEvent: "issue_updated"; 22 | user: JiraAccount; 23 | changelog: { 24 | id: string; 25 | items: { 26 | field: string; 27 | fieldtype: string; 28 | fieldId: string; 29 | from: string | null; 30 | fromString: string | null; 31 | to: string | null; 32 | toString: null; 33 | }[]; 34 | }; 35 | } 36 | 37 | export interface JiraVersionEvent extends IJiraWebhookEvent { 38 | webhookEvent: "version_created" | "version_updated" | "version_released"; 39 | version: JiraVersion; 40 | } 41 | -------------------------------------------------------------------------------- /src/jira/index.ts: -------------------------------------------------------------------------------- 1 | import * as libRs from "../libRs"; 2 | 3 | export const generateJiraWebLinkFromIssue = libRs.generateJiraWeblinkFromIssue; 4 | export const generateJiraWebLinkFromVersion = 5 | libRs.generateJiraWeblinkFromVersion; 6 | -------------------------------------------------------------------------------- /src/jira/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod types; 2 | pub mod utils; 3 | -------------------------------------------------------------------------------- /src/jira/oauth/CloudOAuth.ts: -------------------------------------------------------------------------------- 1 | import { BridgeConfigJiraCloudOAuth } from "../../config/Config"; 2 | import { JiraOAuth } from "../OAuth"; 3 | import qs from "querystring"; 4 | import axios from "axios"; 5 | import { JiraOAuthResult } from "../Types"; 6 | 7 | const JiraOnPremOAuthScopes = [ 8 | // Reading issues, comments 9 | "read:jira-work", 10 | // Creating issues, comments 11 | "write:jira-work", 12 | // Reading user 13 | "read:jira-user", 14 | "read:me", 15 | "read:account", 16 | // To get a refresh token 17 | "offline_access", 18 | ]; 19 | 20 | export class JiraCloudOAuth implements JiraOAuth { 21 | constructor(private readonly config: BridgeConfigJiraCloudOAuth) {} 22 | public async getAuthUrl(state: string) { 23 | const options = { 24 | audience: "api.atlassian.com", 25 | client_id: this.config.client_id, 26 | scope: JiraOnPremOAuthScopes.join(" "), 27 | redirect_uri: this.config.redirect_uri, 28 | state: state, 29 | response_type: "code", 30 | prompt: "consent", 31 | }; 32 | return `https://auth.atlassian.com/authorize?${qs.stringify(options)}`; 33 | } 34 | 35 | public async exchangeRequestForToken(code: string): Promise { 36 | const accessTokenRes = await axios.post( 37 | "https://auth.atlassian.com/oauth/token", 38 | { 39 | client_id: this.config.client_id, 40 | client_secret: this.config.client_secret, 41 | code: code, 42 | grant_type: "authorization_code", 43 | redirect_uri: this.config.redirect_uri, 44 | }, 45 | ); 46 | const result = accessTokenRes.data as { 47 | access_token: string; 48 | scope: string; 49 | expires_in: number; 50 | refresh_token: string; 51 | }; 52 | result.expires_in = Date.now() + result.expires_in * 1000; 53 | return result; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/jira/types.rs: -------------------------------------------------------------------------------- 1 | #[derive(Serialize, Debug, Deserialize)] 2 | #[napi(object)] 3 | pub struct JiraProject { 4 | #[serde(rename = "self")] 5 | pub _self: String, 6 | pub id: String, 7 | pub key: String, 8 | } 9 | 10 | #[derive(Serialize, Debug, Deserialize)] 11 | #[napi(object)] 12 | 13 | pub struct JiraIssue { 14 | #[serde(rename = "self")] 15 | pub _self: String, 16 | pub id: String, 17 | pub key: String, 18 | pub fields: JiraIssueFields, 19 | } 20 | 21 | #[derive(Serialize, Debug, Deserialize)] 22 | #[napi(object)] 23 | pub struct JiraIssueFields { 24 | pub project: JiraProject, 25 | } 26 | 27 | #[derive(Serialize, Debug, Deserialize)] 28 | #[napi(object)] 29 | pub struct JiraIssueLight { 30 | #[serde(rename = "self")] 31 | pub _self: String, 32 | pub key: String, 33 | } 34 | 35 | #[derive(Serialize, Debug, Deserialize)] 36 | #[napi(object)] 37 | pub struct JiraIssueSimpleItem { 38 | pub id: String, 39 | pub key: String, 40 | #[napi(js_name = "api_url")] 41 | pub api_url: String, 42 | } 43 | #[derive(Serialize, Debug, Deserialize)] 44 | #[napi(object)] 45 | pub struct JiraIssueMessageBody { 46 | #[serde(rename = "uk.half-shot.matrix-hookshot.jira.issue")] 47 | #[napi(js_name = "uk.half-shot.matrix-hookshot.jira.issue")] 48 | pub jira_issue: JiraIssueSimpleItem, 49 | #[serde(rename = "uk.half-shot.matrix-hookshot.jira.project")] 50 | #[napi(js_name = "uk.half-shot.matrix-hookshot.jira.project")] 51 | pub jira_project: JiraIssueSimpleItem, 52 | #[napi(js_name = "external_url")] 53 | pub external_url: String, 54 | } 55 | 56 | #[derive(Serialize, Debug, Deserialize)] 57 | #[napi(object)] 58 | pub struct JiraVersion { 59 | #[serde(rename = "self")] 60 | pub _self: String, 61 | pub id: String, 62 | pub description: String, 63 | pub name: String, 64 | #[serde(rename = "projectId")] 65 | pub project_id: String, 66 | } 67 | -------------------------------------------------------------------------------- /src/jira/utils.rs: -------------------------------------------------------------------------------- 1 | use super::types::{JiraIssueLight, JiraVersion}; 2 | use napi::bindgen_prelude::*; 3 | use napi_derive::napi; 4 | use url::Url; 5 | 6 | /// Generate a URL for a given Jira Issue object. 7 | #[napi(js_name = "generateJiraWeblinkFromIssue")] 8 | pub fn js_generate_jira_web_link_from_issue(jira_issue: JiraIssueLight) -> Result { 9 | generate_jira_web_link_from_issue(&jira_issue) 10 | } 11 | 12 | pub fn generate_jira_web_link_from_issue(jira_issue: &JiraIssueLight) -> Result { 13 | let result = Url::parse(&jira_issue._self); 14 | match result { 15 | Ok(url) => Ok(format!( 16 | "{}://{}{}/browse/{}", 17 | url.scheme(), 18 | url.host_str().unwrap(), 19 | url.port() 20 | .map_or(String::new(), |port| format!(":{}", port)), 21 | jira_issue.key 22 | )), 23 | Err(err) => Err(Error::new(Status::Unknown, err.to_string())), 24 | } 25 | } 26 | 27 | /// Generate a URL for a given Jira Version object. 28 | #[napi(js_name = "generateJiraWeblinkFromVersion")] 29 | pub fn js_generate_jira_web_link_from_version(jira_version: JiraVersion) -> Result { 30 | generate_jira_web_link_from_version(&jira_version) 31 | } 32 | 33 | pub fn generate_jira_web_link_from_version(jira_version: &JiraVersion) -> Result { 34 | let result = Url::parse(&jira_version._self); 35 | match result { 36 | Ok(url) => Ok(format!( 37 | "{}://{}{}/projects/{}/versions/{}", 38 | url.scheme(), 39 | url.host_str().unwrap(), 40 | url.port() 41 | .map_or(String::new(), |port| format!(":{}", port)), 42 | jira_version.project_id, 43 | jira_version.id 44 | )), 45 | Err(err) => Err(Error::new(Status::Unknown, err.to_string())), 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod config; 2 | pub mod feeds; 3 | pub mod format_util; 4 | pub mod github; 5 | pub mod jira; 6 | pub mod tokens; 7 | pub mod util; 8 | 9 | #[macro_use] 10 | extern crate napi_derive; 11 | 12 | #[macro_use] 13 | extern crate serde_derive; 14 | -------------------------------------------------------------------------------- /src/libRs.js: -------------------------------------------------------------------------------- 1 | try { 2 | // In production, we expect it co-located 3 | module.exports = require("./matrix-hookshot-rs.node"); 4 | } catch (ex) { 5 | try { 6 | // When running under ts-node, it may not be co-located. 7 | module.exports = require("../lib/matrix-hookshot-rs.node"); 8 | } catch (ex) { 9 | // Or in a test environment. 10 | module.exports = require("../../lib/matrix-hookshot-rs.node"); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/messageQueue/LocalMQ.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from "events"; 2 | import { 3 | MessageQueue, 4 | MessageQueueMessage, 5 | DEFAULT_RES_TIMEOUT, 6 | } from "./Types"; 7 | import micromatch from "micromatch"; 8 | import { randomUUID } from "node:crypto"; 9 | import Metrics from "../Metrics"; 10 | 11 | export class LocalMQ extends EventEmitter implements MessageQueue { 12 | private subs: Set; 13 | constructor() { 14 | super(); 15 | this.subs = new Set(); 16 | } 17 | 18 | public subscribe(eventGlob: string) { 19 | this.subs.add(eventGlob); 20 | } 21 | 22 | public unsubscribe(eventGlob: string) { 23 | this.subs.delete(eventGlob); 24 | } 25 | 26 | public async push(message: MessageQueueMessage) { 27 | Metrics.messageQueuePushes.inc({ event: message.eventName }); 28 | if (!micromatch.match([...this.subs], message.eventName)) { 29 | return; 30 | } 31 | if (!message.messageId) { 32 | message.messageId = randomUUID(); 33 | } 34 | this.emit(message.eventName, message); 35 | } 36 | 37 | public async pushWait( 38 | message: MessageQueueMessage, 39 | timeout: number = DEFAULT_RES_TIMEOUT, 40 | ): Promise { 41 | let resolve: (value: X) => void; 42 | let timer: NodeJS.Timeout; 43 | 44 | const p = new Promise((res, rej) => { 45 | resolve = res; 46 | timer = setTimeout(() => { 47 | rej( 48 | new Error( 49 | `Timeout waiting for message queue response for ${message.eventName} / ${message.messageId}`, 50 | ), 51 | ); 52 | }, timeout); 53 | }); 54 | 55 | const awaitResponse = (response: MessageQueueMessage) => { 56 | if (response.messageId === message.messageId) { 57 | clearTimeout(timer); 58 | this.removeListener(`response.${message.eventName}`, awaitResponse); 59 | resolve(response.data); 60 | } 61 | }; 62 | 63 | this.addListener(`response.${message.eventName}`, awaitResponse); 64 | this.push(message); 65 | return p; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/messageQueue/MessageQueue.ts: -------------------------------------------------------------------------------- 1 | import { BridgeConfigQueue } from "../config/sections"; 2 | import { LocalMQ } from "./LocalMQ"; 3 | import { RedisMQ } from "./RedisQueue"; 4 | import { MessageQueue } from "./Types"; 5 | 6 | const staticLocalMq = new LocalMQ(); 7 | let staticRedisMq: RedisMQ | null = null; 8 | 9 | export function createMessageQueue(config?: BridgeConfigQueue): MessageQueue { 10 | if (!config) { 11 | return staticLocalMq; 12 | } 13 | if (staticRedisMq === null) { 14 | staticRedisMq = new RedisMQ(config); 15 | } 16 | return staticRedisMq; 17 | } 18 | -------------------------------------------------------------------------------- /src/messageQueue/Types.ts: -------------------------------------------------------------------------------- 1 | export interface MessageQueueMessage { 2 | sender: string; 3 | eventName: string; 4 | data: T; 5 | messageId?: string; 6 | for?: string; 7 | } 8 | 9 | export interface MessageQueueMessageOut extends MessageQueueMessage { 10 | ts: number; 11 | } 12 | 13 | export interface MessageQueue { 14 | subscribe: (eventGlob: string) => void; 15 | unsubscribe: (eventGlob: string) => void; 16 | push: (data: MessageQueueMessage, single?: boolean) => Promise; 17 | pushWait: ( 18 | data: MessageQueueMessage, 19 | timeout?: number, 20 | single?: boolean, 21 | ) => Promise; 22 | on: ( 23 | eventName: string, 24 | cb: (data: MessageQueueMessageOut) => void, 25 | ) => void; 26 | stop?(): Promise; 27 | connect?(): Promise; 28 | } 29 | 30 | export const DEFAULT_RES_TIMEOUT = 30000; 31 | -------------------------------------------------------------------------------- /src/messageQueue/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./Types"; 2 | export * from "./MessageQueue"; 3 | -------------------------------------------------------------------------------- /src/notifications/GitLabWatcher.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from "events"; 2 | import { GitLabClient } from "../gitlab/Client"; 3 | import { Logger } from "matrix-appservice-bridge"; 4 | import { NotificationWatcherTask } from "./NotificationWatcherTask"; 5 | 6 | const log = new Logger("GitLabWatcher"); 7 | 8 | export class GitLabWatcher 9 | extends EventEmitter 10 | implements NotificationWatcherTask 11 | { 12 | private client: GitLabClient; 13 | private interval?: NodeJS.Timeout; 14 | public readonly type = "gitlab"; 15 | public failureCount = 0; 16 | constructor( 17 | token: string, 18 | url: string, 19 | public userId: string, 20 | public roomId: string, 21 | public since: number, 22 | ) { 23 | super(); 24 | this.client = new GitLabClient(url, token); 25 | } 26 | 27 | public start(intervalMs: number) { 28 | this.interval = setInterval(() => { 29 | this.getNotifications(); 30 | }, intervalMs); 31 | } 32 | 33 | public stop() { 34 | if (this.interval) { 35 | clearInterval(this.interval); 36 | } 37 | } 38 | 39 | private async getNotifications() { 40 | log.info(`Fetching events from GitLab for ${this.userId}`); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/notifications/NotificationWatcherTask.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from "events"; 2 | 3 | type NotificationTypes = "github" | "gitlab"; 4 | 5 | export interface NotificationWatcherTask extends EventEmitter { 6 | userId: string; 7 | type: NotificationTypes; 8 | instanceUrl?: string; 9 | roomId: string; 10 | failureCount: number; 11 | since: number; 12 | start(intervalMs: number): void; 13 | stop(): void; 14 | } 15 | -------------------------------------------------------------------------------- /src/openproject/AdminCommands.ts: -------------------------------------------------------------------------------- 1 | import { AdminRoomCommandHandler, Category } from "../AdminRoomCommandHandler"; 2 | import { botCommand } from "../BotCommands"; 3 | import { Logger } from "matrix-appservice-bridge"; 4 | import { BridgePermissionLevel } from "../config/Config"; 5 | 6 | const log = new Logger("OpenProjectBotCommands"); 7 | 8 | export class OpenProjectBotCommands extends AdminRoomCommandHandler { 9 | @botCommand("openproject login", { 10 | help: "Log in to OpenProject", 11 | category: Category.OpenProject, 12 | permissionLevel: BridgePermissionLevel.login, 13 | }) 14 | public async loginCommand() { 15 | if (!this.tokenStore.openProjectOAuth) { 16 | this.sendNotice(`Bot is not configured with JIRA OAuth support.`); 17 | return; 18 | } 19 | const state = this.tokenStore.createStateForOAuth(this.userId); 20 | const url = await this.tokenStore.openProjectOAuth.getAuthUrl(state); 21 | await this.sendNotice(`Open ${url} to link your account to the bridge.`); 22 | } 23 | 24 | @botCommand("openproject logout", { 25 | help: "Log out of OpenProject", 26 | category: Category.OpenProject, 27 | permissionLevel: BridgePermissionLevel.login, 28 | }) 29 | public async logoutCommand() { 30 | if (await this.tokenStore.clearUserToken("openproject", this.userId)) { 31 | return this.sendNotice(`You have been logged out of OpenProject.`); 32 | } 33 | return this.sendNotice(`You are not logged into OpenProject.`); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/openproject/GrantChecker.ts: -------------------------------------------------------------------------------- 1 | import { Appservice } from "matrix-bot-sdk"; 2 | import { JiraProjectConnection } from "../Connections"; 3 | import { GrantChecker } from "../grants/GrantCheck"; 4 | import { UserTokenStore } from "../tokens/UserTokenStore"; 5 | 6 | interface OpenProjectGrantConnectionId { 7 | url: string; 8 | } 9 | 10 | export class OpenProjectGrantChecker extends GrantChecker { 11 | constructor( 12 | private readonly as: Appservice, 13 | private readonly tokenStore: UserTokenStore, 14 | ) { 15 | super(as.botIntent, "openproject"); 16 | } 17 | 18 | protected async checkFallback( 19 | roomId: string, 20 | connectionId: OpenProjectGrantConnectionId, 21 | sender?: string, 22 | ) { 23 | if (!sender) { 24 | // Cannot validate without a sender. 25 | return false; 26 | } 27 | if (this.as.isNamespacedUser(sender)) { 28 | // Bridge is always valid. 29 | return true; 30 | } 31 | try { 32 | await JiraProjectConnection.assertUserHasAccessToProject( 33 | this.tokenStore, 34 | sender, 35 | connectionId.url, 36 | ); 37 | return true; 38 | } catch { 39 | return false; 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/openproject/State.ts: -------------------------------------------------------------------------------- 1 | import { OpenProjectWorkPackage } from "./Types"; 2 | 3 | export function workPackageToCacheState( 4 | pkg: OpenProjectWorkPackage, 5 | ): OpenProjectWorkPackageCacheState { 6 | return { 7 | subject: pkg.subject, 8 | description: pkg.description, 9 | status: pkg._embedded.status, 10 | assignee: pkg._embedded.assignee?.id, 11 | responsible: pkg._embedded.responsible?.id, 12 | priority: pkg._embedded.priority, 13 | type: pkg._embedded.type.id, 14 | project: pkg._embedded.project.id, 15 | dueDate: pkg.dueDate, 16 | percentageDone: pkg.percentageDone, 17 | }; 18 | } 19 | 20 | export interface OpenProjectWorkPackageCacheState { 21 | subject: string; 22 | description: OpenProjectWorkPackage["description"]; 23 | status: OpenProjectWorkPackage["_embedded"]["status"]; 24 | assignee?: number; 25 | responsible?: number; 26 | priority?: OpenProjectWorkPackage["_embedded"]["priority"]; 27 | type: number; 28 | project: number; 29 | dueDate: string | null; 30 | percentageDone: number | null; 31 | } 32 | -------------------------------------------------------------------------------- /src/openproject/WidgetApi.ts: -------------------------------------------------------------------------------- 1 | import { ProvisioningRequest } from "matrix-appservice-bridge"; 2 | import { GetAuthResponse } from "../widgets/BridgeWidgetInterface"; 3 | import { UserTokenStore } from "../tokens/UserTokenStore"; 4 | import { Response } from "express"; 5 | import { ApiError, ErrCode } from "../api"; 6 | 7 | export class OpenProjectWidgetAPI { 8 | public static async getAuth( 9 | req: ProvisioningRequest, 10 | res: Response, 11 | tokenStore: UserTokenStore, 12 | ) { 13 | if (!req.userId) { 14 | throw new ApiError("Missing userId"); 15 | } 16 | const user = await tokenStore.getOpenProjectForUser(req.userId); 17 | if (user) { 18 | const ident = await user.getIdentity(); 19 | res.json({ user: { name: ident.name }, authenticated: true }); 20 | } else { 21 | if (!tokenStore.openProjectOAuth) { 22 | throw new ApiError( 23 | "OAuth is not supported", 24 | ErrCode.UnsupportedOperation, 25 | ); 26 | } 27 | const stateId = tokenStore.createStateForOAuth(req.userId); 28 | const authUrl = await tokenStore.openProjectOAuth.getAuthUrl(stateId); 29 | res.json({ authUrl, authenticated: false, stateId }); 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/tokens/Oauth.ts: -------------------------------------------------------------------------------- 1 | export interface OAuthRequest { 2 | state: string; 3 | code: string; 4 | } 5 | export enum OAuthRequestResult { 6 | UnknownFailure, 7 | Success, 8 | InvalidScope, 9 | UserNotFound, 10 | } 11 | -------------------------------------------------------------------------------- /src/widgets/BridgeWidgetInterface.ts: -------------------------------------------------------------------------------- 1 | import { GetConnectionsResponseItem } from "./Api"; 2 | 3 | export interface BridgeRoomStateGitHub { 4 | enabled: boolean; 5 | tokenStored: boolean; 6 | identity: { 7 | name: string | null; 8 | avatarUrl: string | null; 9 | } | null; 10 | notifications: boolean; 11 | } 12 | export interface BridgeRoomState { 13 | title: string; 14 | github: BridgeRoomStateGitHub; 15 | } 16 | 17 | export enum WidgetConfigurationType { 18 | String, 19 | OAuthUrl, 20 | } 21 | 22 | export interface WidgetConfigurationOption { 23 | key: string; 24 | type: WidgetConfigurationType; 25 | currentValue: string | null; 26 | defaultValue?: string; 27 | additionalData?: Record; 28 | } 29 | 30 | export interface WidgetConfigurationSection { 31 | name: string; 32 | options: WidgetConfigurationOption[]; 33 | } 34 | 35 | export interface GetConnectionsForServiceResponse< 36 | T extends GetConnectionsResponseItem, 37 | > { 38 | connections: T[]; 39 | canEdit: boolean; 40 | } 41 | 42 | export interface GetAuthResponseAuthenticated { 43 | authenticated: true; 44 | user: { 45 | name: string; 46 | }; 47 | } 48 | 49 | export interface GetAuthResponseUnauthenticated { 50 | authenticated: false; 51 | authUrl: string; 52 | stateId: string; 53 | } 54 | 55 | export type GetAuthResponse = 56 | | GetAuthResponseAuthenticated 57 | | GetAuthResponseUnauthenticated; 58 | 59 | export interface GetAuthPollResponse { 60 | state: "complete" | "waiting"; 61 | } 62 | -------------------------------------------------------------------------------- /src/widgets/WidgetKind.ts: -------------------------------------------------------------------------------- 1 | export enum HookshotWidgetKind { 2 | Settings = "admin", 3 | BridgeInvites = "invite", 4 | RoomConfiguration = "roomConfig", 5 | } 6 | -------------------------------------------------------------------------------- /tests/AdminRoomTest.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import { AdminRoom } from "../src/AdminRoom"; 3 | import { DefaultConfig } from "../src/config/Defaults"; 4 | import { ConnectionManager } from "../src/ConnectionManager"; 5 | import { NotifFilter } from "../src/NotificationFilters"; 6 | import { UserTokenStore } from "../src/tokens/UserTokenStore"; 7 | import { IntentMock } from "./utils/IntentMock"; 8 | 9 | const ROOM_ID = "!foo:bar"; 10 | 11 | function createAdminRoom( 12 | data: any = { admin_user: "@admin:bar" }, 13 | ): [AdminRoom, IntentMock] { 14 | const intent = IntentMock.create("@admin:bar"); 15 | if (!data.admin_user) { 16 | data.admin_user = "@admin:bar"; 17 | } 18 | return [ 19 | new AdminRoom( 20 | ROOM_ID, 21 | data, 22 | NotifFilter.getDefaultContent(), 23 | intent, 24 | {} as UserTokenStore, 25 | DefaultConfig, 26 | {} as ConnectionManager, 27 | ), 28 | intent, 29 | ]; 30 | } 31 | 32 | describe("AdminRoom", () => { 33 | it("will present help text", async () => { 34 | const [adminRoom, intent] = createAdminRoom(); 35 | await adminRoom.handleCommand("$foo:bar", "help"); 36 | expect(intent.sentEvents).to.have.lengthOf(1); 37 | expect(intent.sentEvents[0]).to.deep.equal({ 38 | roomId: ROOM_ID, 39 | content: AdminRoom.helpMessage(undefined, [ 40 | "Github", 41 | "Gitlab", 42 | "Jira", 43 | "OpenProject", 44 | ]), 45 | }); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /tests/HookFilter.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import { HookFilter } from "../src/HookFilter"; 3 | 4 | const DEFAULT_SET = ["default-allowed", "default-allowed-but-ignored"]; 5 | const ENABLED_SET = ["enabled-hook", "enabled-but-ignored"]; 6 | 7 | describe("HookFilter", () => { 8 | let filter: HookFilter; 9 | 10 | beforeEach(() => { 11 | filter = new HookFilter(ENABLED_SET); 12 | }); 13 | 14 | describe("shouldSkip", () => { 15 | it("should allow a hook named in enabled set", () => { 16 | expect(filter.shouldSkip("enabled-hook")).to.be.false; 17 | }); 18 | 19 | it("should not allow a hook not named in enabled set", () => { 20 | expect(filter.shouldSkip("not-enabled-hook")).to.be.true; 21 | }); 22 | }); 23 | 24 | describe("convertIgnoredHooksToEnabledHooks", () => { 25 | it("should correctly provide a list of default hooks", () => { 26 | expect( 27 | HookFilter.convertIgnoredHooksToEnabledHooks([], [], DEFAULT_SET), 28 | ).to.have.members(DEFAULT_SET); 29 | }); 30 | 31 | it("should correctly include default and enabled hooks when ignored hooks is set", () => { 32 | expect( 33 | HookFilter.convertIgnoredHooksToEnabledHooks( 34 | ENABLED_SET, 35 | ["my-ignored-hook"], 36 | DEFAULT_SET, 37 | ), 38 | ).to.have.members([...ENABLED_SET, ...DEFAULT_SET]); 39 | }); 40 | 41 | it("should deduplicate", () => { 42 | expect( 43 | HookFilter.convertIgnoredHooksToEnabledHooks( 44 | DEFAULT_SET, 45 | [], 46 | DEFAULT_SET, 47 | ), 48 | ).to.have.members(DEFAULT_SET); 49 | }); 50 | 51 | it("should correctly exclude ignored hooks", () => { 52 | expect( 53 | HookFilter.convertIgnoredHooksToEnabledHooks( 54 | [], 55 | [DEFAULT_SET[0]], 56 | DEFAULT_SET, 57 | ), 58 | ).to.not.include([DEFAULT_SET[0]]); 59 | }); 60 | 61 | it("should handle ignored root hooks", () => { 62 | const defaultHooks = ["myhook", "myhook.foo", "myhook.foo.bar"]; 63 | expect( 64 | HookFilter.convertIgnoredHooksToEnabledHooks( 65 | [], 66 | ["myhook.foo.bar"], 67 | defaultHooks, 68 | ), 69 | ).to.have.members(["myhook", "myhook.foo"]); 70 | expect( 71 | HookFilter.convertIgnoredHooksToEnabledHooks( 72 | [], 73 | ["myhook.foo"], 74 | defaultHooks, 75 | ), 76 | ).to.have.members(["myhook"]); 77 | expect( 78 | HookFilter.convertIgnoredHooksToEnabledHooks( 79 | [], 80 | ["myhook"], 81 | defaultHooks, 82 | ), 83 | ).to.be.empty; 84 | }); 85 | }); 86 | }); 87 | -------------------------------------------------------------------------------- /tests/IntentUtilsTest.ts: -------------------------------------------------------------------------------- 1 | import { IntentMock, MatrixClientMock } from "./utils/IntentMock"; 2 | import { ensureUserIsInRoom } from "../src/IntentUtils"; 3 | import { expect } from "chai"; 4 | import { MatrixError } from "matrix-bot-sdk"; 5 | 6 | const ROOM_ID = "!foo:bar"; 7 | const SENDER_USER_ID = "@my_target:foo"; 8 | 9 | describe("IntentUtils", () => { 10 | describe("ensureUserIsInRoom", () => { 11 | it("no-ops if the user is already joined to the room", () => { 12 | const targetIntent = IntentMock.create(SENDER_USER_ID); 13 | targetIntent.ensureJoined = () => { 14 | /* No-op */ 15 | }; 16 | const matrixClient = MatrixClientMock.create(); 17 | ensureUserIsInRoom(targetIntent, matrixClient, ROOM_ID); 18 | }); 19 | 20 | it("invites the user to the room and joins", () => { 21 | const targetIntent = IntentMock.create(SENDER_USER_ID); 22 | const matrixClient = MatrixClientMock.create(); 23 | let hasInvited = false; 24 | // This should fail the first time, then pass once we've tried to invite the user 25 | targetIntent.ensureJoined = (roomId: string) => { 26 | if (hasInvited) { 27 | return; 28 | } 29 | expect(roomId).to.equal(ROOM_ID); 30 | throw new MatrixError( 31 | { errcode: "M_FORBIDDEN", error: "Test forced error" }, 32 | 401, 33 | {}, 34 | ); 35 | }; 36 | 37 | // This should invite the puppet user. 38 | matrixClient.inviteUser = (userId: string, roomId: string) => { 39 | expect(userId).to.equal(SENDER_USER_ID); 40 | expect(roomId).to.equal(ROOM_ID); 41 | hasInvited = true; 42 | }; 43 | 44 | ensureUserIsInRoom(targetIntent, matrixClient, ROOM_ID); 45 | // Only pass if we've actually bothered to invite the bot. 46 | expect(hasInvited).to.be.true; 47 | }); 48 | 49 | it("invites the user to the room and handles the failure", () => { 50 | const targetIntent = IntentMock.create(SENDER_USER_ID); 51 | const matrixClient = MatrixClientMock.create(); 52 | 53 | // This should fail the first time, then pass once we've tried to invite the user 54 | targetIntent.ensureJoined = () => { 55 | throw new MatrixError( 56 | { errcode: "FORCED_FAILURE", error: "Test forced error" }, 57 | 500, 58 | {}, 59 | ); 60 | }; 61 | try { 62 | ensureUserIsInRoom(targetIntent, matrixClient, ROOM_ID); 63 | } catch (ex) { 64 | expect(ex.message).to.contain( 65 | `Could not ensure that ${SENDER_USER_ID} is in ${ROOM_ID}`, 66 | ); 67 | } 68 | }); 69 | }); 70 | }); 71 | -------------------------------------------------------------------------------- /tests/MessageQueueTest.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import { createMessageQueue } from "../src/messageQueue/MessageQueue"; 3 | 4 | const mq = createMessageQueue(); 5 | 6 | describe("MessageQueueTest", () => { 7 | describe("LocalMq", () => { 8 | it("should be able to push an event, and listen for it", (done) => { 9 | mq.subscribe("fakeevent"); 10 | mq.on("fakeevent", (msg) => { 11 | expect(msg).to.deep.equal({ 12 | sender: "foo", 13 | eventName: "fakeevent", 14 | messageId: "foooo", 15 | data: 51, 16 | }); 17 | done(); 18 | }); 19 | mq.push({ 20 | sender: "foo", 21 | eventName: "fakeevent", 22 | messageId: "foooo", 23 | data: 51, 24 | }); 25 | }); 26 | 27 | it("should be able to push an event, and respond to it", async () => { 28 | mq.subscribe("fakeevent2"); 29 | mq.subscribe("response.fakeevent2"); 30 | mq.on("fakeevent2", async (msg) => { 31 | expect(msg).to.deep.equal({ 32 | sender: "foo", 33 | eventName: "fakeevent2", 34 | messageId: "foooo", 35 | data: 49, 36 | }); 37 | await mq.push({ 38 | sender: "foo", 39 | eventName: "response.fakeevent2", 40 | messageId: "foooo", 41 | data: "worked", 42 | }); 43 | }); 44 | const response = await mq.pushWait({ 45 | sender: "foo", 46 | eventName: "fakeevent2", 47 | messageId: "foooo", 48 | data: 49, 49 | }); 50 | expect(response).to.equal("worked"); 51 | }); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /tests/github/AdminCommands.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import { GithubInstance } from "../../src/github/GithubInstance"; 3 | import { GITHUB_CLOUD_URL } from "../../src/github/GithubInstance"; 4 | 5 | describe("GitHub", () => { 6 | describe("AdminCommands", () => { 7 | it("can generate an authorize URL for the cloud URL", () => { 8 | expect( 9 | GithubInstance.generateOAuthUrl(GITHUB_CLOUD_URL, "authorize", { 10 | state: "my_state", 11 | client_id: "123", 12 | redirect_uri: "456", 13 | }), 14 | ).equals( 15 | "https://github.com/login/oauth/authorize?state=my_state&client_id=123&redirect_uri=456", 16 | ); 17 | }); 18 | 19 | it("can generate an authorize URL for enterprise URLs", () => { 20 | expect( 21 | GithubInstance.generateOAuthUrl( 22 | new URL("https://mygithuburl.com/foo/bar"), 23 | "authorize", 24 | { 25 | state: "my_state", 26 | client_id: "123", 27 | redirect_uri: "456", 28 | }, 29 | ), 30 | ).equals( 31 | "https://mygithuburl.com/foo/bar/login/oauth/authorize?state=my_state&client_id=123&redirect_uri=456", 32 | ); 33 | }); 34 | 35 | it("can generate an access_token URL for the cloud URL", () => { 36 | expect( 37 | GithubInstance.generateOAuthUrl(GITHUB_CLOUD_URL, "access_token", { 38 | client_id: "123", 39 | client_secret: "the-secret", 40 | code: "the-code", 41 | redirect_uri: "456", 42 | state: "my_state", 43 | }), 44 | ).equals( 45 | "https://github.com/login/oauth/access_token?client_id=123&client_secret=the-secret&code=the-code&redirect_uri=456&state=my_state", 46 | ); 47 | }); 48 | 49 | it("can generate an access_token URL for enterprise URLs", () => { 50 | expect( 51 | GithubInstance.generateOAuthUrl( 52 | new URL("https://mygithuburl.com/foo/bar"), 53 | "access_token", 54 | { 55 | client_id: "123", 56 | client_secret: "the-secret", 57 | code: "the-code", 58 | redirect_uri: "456", 59 | state: "my_state", 60 | }, 61 | ), 62 | ).equals( 63 | "https://mygithuburl.com/foo/bar/login/oauth/access_token?client_id=123&client_secret=the-secret&code=the-code&redirect_uri=456&state=my_state", 64 | ); 65 | }); 66 | }); 67 | }); 68 | -------------------------------------------------------------------------------- /tests/init.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from "matrix-appservice-bridge"; 2 | Logger.configure({ console: "info" }); 3 | -------------------------------------------------------------------------------- /tests/jira/Utils.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import { 3 | generateJiraWebLinkFromIssue, 4 | generateJiraWebLinkFromVersion, 5 | } from "../../src/jira"; 6 | 7 | describe("Jira", () => { 8 | describe("Utils", () => { 9 | it("processes a jira issue into a URL", () => { 10 | expect( 11 | generateJiraWebLinkFromIssue({ 12 | self: "https://my-test-jira/", 13 | key: "TEST-111", 14 | }), 15 | ).to.equal("https://my-test-jira/browse/TEST-111"); 16 | }); 17 | 18 | it("processes a jira issue into a URL with a port", () => { 19 | expect( 20 | generateJiraWebLinkFromIssue({ 21 | self: "https://my-test-jira:9995/", 22 | key: "TEST-111", 23 | }), 24 | ).to.equal("https://my-test-jira:9995/browse/TEST-111"); 25 | }); 26 | 27 | it("processes a jira issue into a URL with a port and a version", () => { 28 | expect( 29 | generateJiraWebLinkFromVersion({ 30 | self: "https://my-test-jira:9995/", 31 | description: "foo", 32 | name: "bar", 33 | projectId: "TEST-111", 34 | id: "v1.0.0", 35 | }), 36 | ).to.equal("https://my-test-jira:9995/projects/TEST-111/versions/v1.0.0"); 37 | }); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /tests/utils/AppserviceMock.ts: -------------------------------------------------------------------------------- 1 | import { IntentMock } from "./IntentMock"; 2 | 3 | export class AppserviceMock { 4 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 5 | public readonly intentMap = new Map(); 6 | public readonly botIntent = IntentMock.create(`@bot:example.com`); 7 | public namespace = "@hookshot_"; 8 | 9 | static create() { 10 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 11 | return new this() as any; 12 | } 13 | 14 | get botUserId() { 15 | return this.botIntent.userId; 16 | } 17 | 18 | get botClient() { 19 | return this.botIntent.underlyingClient; 20 | } 21 | 22 | public getIntentForUserId(userId: string) { 23 | let intent = this.intentMap.get(userId); 24 | if (intent) { 25 | return intent; 26 | } 27 | intent = IntentMock.create(userId); 28 | this.intentMap.set(userId, intent); 29 | return intent; 30 | } 31 | 32 | public isNamespacedUser(userId: string) { 33 | return userId.startsWith(this.namespace); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/node22/tsconfig.json", 3 | "compilerOptions": { 4 | "incremental": true, 5 | "declaration": false, 6 | "outDir": "./lib", 7 | "rootDir": "./src", 8 | "noImplicitAny": true, 9 | "strictNullChecks": true, 10 | "strictFunctionTypes": true, 11 | "strictBindCallApply": true, 12 | "strictPropertyInitialization": true, 13 | "noImplicitThis": true, 14 | "alwaysStrict": true, 15 | /* Additional Checks */ 16 | "noUnusedLocals": false, 17 | "noUnusedParameters": false, 18 | "noImplicitReturns": true, 19 | "noFallthroughCasesInSwitch": true, 20 | "inlineSourceMap": true, 21 | "inlineSources": true, 22 | "allowJs": true, 23 | "experimentalDecorators": true, 24 | "emitDecoratorMetadata": true, 25 | // TODO: Type errors 26 | "useUnknownInCatchVariables": false 27 | }, 28 | "include": [ 29 | "src/**/*" 30 | ], 31 | "exclude": [ 32 | "tests/**/*", 33 | "web/**/*" 34 | ] 35 | } 36 | -------------------------------------------------------------------------------- /tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "incremental": false, 5 | "declaration": false, 6 | "outDir": "./spec-lib", 7 | "rootDir": "./", 8 | "allowJs": true 9 | }, 10 | "include": [ 11 | "spec/**/*" 12 | ], 13 | "exclude": [ 14 | "tests/**/*", 15 | "web/**/*" 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /vite.config.mjs: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import preact from '@preact/preset-vite' 3 | import { resolve } from 'path' 4 | import alias from '@rollup/plugin-alias' 5 | 6 | // https://vitejs.dev/config/ 7 | export default defineConfig({ 8 | plugins: [preact()], 9 | root: 'web', 10 | base: '', 11 | optimizeDeps: { 12 | exclude: ['@vector-im/compound-web'], 13 | }, 14 | build: { 15 | sourcemap: 'inline', 16 | outDir: '../public', 17 | rollupOptions: { 18 | input: { 19 | main: resolve('web', 'index.html'), 20 | oauth: resolve('web', 'oauth.html'), 21 | }, 22 | plugins: [ 23 | alias({ 24 | entries: [ 25 | { find: 'react', replacement: 'preact/compat' }, 26 | { find: 'react-dom/test-utils', replacement: 'preact/test-utils' }, 27 | { find: 'react-dom', replacement: 'preact/compat' }, 28 | { find: 'react/jsx-runtime', replacement: 'preact/jsx-runtime' } 29 | ] 30 | }) 31 | ] 32 | }, 33 | emptyOutDir: true, 34 | }, 35 | css: { 36 | preprocessorOptions: { 37 | scss: { 38 | api: 'modern' 39 | } 40 | } 41 | } 42 | }) 43 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | // Configure Vitest (https://vitest.dev/config/) 4 | 5 | import { defineConfig } from 'vite' 6 | 7 | export default defineConfig({ 8 | test: { 9 | /* for example, use global to avoid globals imports (describe, test, expect): */ 10 | // globals: true, 11 | typecheck: { 12 | enabled: true, 13 | }, 14 | retry: process.env.CI ? 3 : 1, 15 | include: ['spec/*.spec.ts'], 16 | }, 17 | }) 18 | -------------------------------------------------------------------------------- /web/components/AdminSettings.module.scss: -------------------------------------------------------------------------------- 1 | .root { 2 | margin-left: 30px; 3 | margin-right: 30px; 4 | padding-left: 10vw; 5 | padding-right: 10vw; 6 | padding-top: 10vh; 7 | } 8 | 9 | .root > .card { 10 | max-width: 95%; 11 | } 12 | 13 | .header { 14 | text-align: left; 15 | font-size: 24pt; 16 | margin-bottom: 30px; 17 | } 18 | 19 | .sidebar { 20 | min-height: 60vh; 21 | max-width: 10vw; 22 | flex: 200px; 23 | } 24 | 25 | .content { 26 | padding-left: 20px; 27 | h2 { 28 | font-size: 16pt; 29 | } 30 | } 31 | 32 | .contents { 33 | margin-top: 10px; 34 | display: flex; 35 | flex-direction: row; 36 | } 37 | 38 | .sidebar > ul { 39 | list-style: none; 40 | } 41 | 42 | .sidebar > ul > a > li { 43 | border-radius: 5px; 44 | margin-bottom: 5px; 45 | padding: 0.3em; 46 | } 47 | 48 | .sidebar > ul > a:hover { 49 | text-decoration: none; 50 | cursor: pointer; 51 | } 52 | 53 | .sidebar > ul > a > li.active { 54 | background-color: var(--foreground-color); 55 | color: var(--background-color); 56 | } 57 | -------------------------------------------------------------------------------- /web/components/ConnectionCard.module.scss: -------------------------------------------------------------------------------- 1 | .card { 2 | background: var(--cpd-color-bg-subtle-secondary); 3 | border: 1px solid var(--cpd-color-border-interactive-secondary); 4 | box-sizing: border-box; 5 | border-radius: 8px; 6 | 7 | display: flex; 8 | flex-direction: row; 9 | align-items: flex-start; 10 | padding: 12px; 11 | margin-top: 10px; 12 | 13 | cursor: pointer; 14 | 15 | @media (prefers-color-scheme: dark) { 16 | img.invert { 17 | filter: invert(100%); 18 | } 19 | } 20 | 21 | img { 22 | width: 52px; 23 | height: 52px; 24 | } 25 | 26 | div { 27 | margin-left: 12px; 28 | 29 | p { 30 | font-style: normal; 31 | font-weight: 400; 32 | font-size: 14px; 33 | line-height: 15px; 34 | display: flex; 35 | align-items: center; 36 | color: #737d8c; 37 | margin-top: 5px; 38 | } 39 | 40 | span { 41 | font-style: normal; 42 | font-weight: 600; 43 | font-size: 18px; 44 | line-height: 24px; 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /web/components/ConnectionCard.tsx: -------------------------------------------------------------------------------- 1 | import style from "./ConnectionCard.module.scss"; 2 | 3 | interface IProps { 4 | imageSrc: string; 5 | darkImage?: boolean; 6 | serviceName: string; 7 | description: string; 8 | key: string; 9 | onClick: () => void; 10 | } 11 | 12 | export function ConnectionCard(props: IProps) { 13 | return ( 14 |
15 | 20 |
21 | {props.serviceName} 22 |

{props.description}

23 |
24 |
25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /web/components/GitHubState.css: -------------------------------------------------------------------------------- 1 | .login-card img { 2 | height: 48px; 3 | } 4 | 5 | .login-card span { 6 | font-size: 1.5rem; 7 | } 8 | -------------------------------------------------------------------------------- /web/components/GitHubState.tsx: -------------------------------------------------------------------------------- 1 | import { FunctionComponent } from "preact"; 2 | import { BridgeRoomStateGitHub } from "../../src/widgets/BridgeWidgetInterface"; 3 | import "./GitHubState.css"; 4 | 5 | const GitHubState: FunctionComponent<{ config: BridgeRoomStateGitHub }> = ({ 6 | config, 7 | }) => { 8 | return ( 9 |
10 |
11 |
12 | GitHub avatar 13 |
14 |
15 | Logged in as {config.identity.name} 16 |

Notifications {config.notifications ? "Enabled" : "Disabled"}

17 |
18 |
19 |
20 | ); 21 | }; 22 | 23 | export default GitHubState; 24 | -------------------------------------------------------------------------------- /web/components/RoomConfigView.module.scss: -------------------------------------------------------------------------------- 1 | .root { 2 | h1 { 3 | font-style: normal; 4 | font-weight: 600; 5 | font-size: 24px; 6 | line-height: 29px; 7 | } 8 | 9 | h2 { 10 | font-size: 18px; 11 | font-style: normal; 12 | font-weight: 600; 13 | line-height: 22px; 14 | } 15 | 16 | section { 17 | margin-bottom: 25px; 18 | } 19 | 20 | header { 21 | // min-height: 3rem; 22 | margin-bottom: 8px; 23 | } 24 | } 25 | 26 | .backButton { 27 | font-weight: 500; 28 | cursor: pointer; 29 | } 30 | -------------------------------------------------------------------------------- /web/components/ServiceCard.module.scss: -------------------------------------------------------------------------------- 1 | .serviceCard { 2 | display: grid !important; 3 | grid-template-columns: 0.6fr 1fr 1fr; 4 | padding: 0.5rem; 5 | 6 | img { 7 | width: 48px; 8 | @media (prefers-color-scheme: dark) { 9 | &.invert { 10 | filter: invert(100%); 11 | } 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /web/components/ServiceCard.tsx: -------------------------------------------------------------------------------- 1 | import { FunctionComponent } from "preact"; 2 | import style from "./ServiceCard.module.scss"; 3 | 4 | export const ServiceCard: FunctionComponent<{ 5 | serviceName: string; 6 | iconUrl: string; 7 | onConfigure: () => void; 8 | }> = ({ serviceName, iconUrl, onConfigure }) => { 9 | return ( 10 |
11 | 12 |
13 | {serviceName} 14 | 15 |
16 |
17 | ); 18 | }; 19 | -------------------------------------------------------------------------------- /web/components/configs/GeneralConfig.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "../elements"; 2 | 3 | export default function GeneralConfig() { 4 | return ( 5 |
6 |

General Configuration

7 |
8 |
9 |

Filters

10 |

You have no configured filters.

11 | 12 |
13 |
14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /web/components/elements/Button.module.scss: -------------------------------------------------------------------------------- 1 | .button { 2 | background-color: var(--primary-color); 3 | color: var(--background-color); 4 | padding: 2px 10px; 5 | border-radius: 5px; 6 | border: none; 7 | font-family: "Inter"; 8 | font-style: normal; 9 | font-weight: 600; 10 | font-size: 15px; 11 | line-height: 24px; 12 | min-width: 150px; 13 | display: block; 14 | padding: 4px 15px; 15 | cursor: pointer; 16 | } 17 | 18 | .button:disabled { 19 | background-color: var(--primary-color-disabled); 20 | } 21 | 22 | .remove { 23 | color: #ff5b55; 24 | background-color: transparent; 25 | 26 | &:disabled { 27 | background-color: transparent; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /web/components/elements/Button.tsx: -------------------------------------------------------------------------------- 1 | import { FunctionComponent, h } from "preact"; 2 | import style from "./Button.module.scss"; 3 | 4 | interface ButtonProps extends h.JSX.HTMLAttributes { 5 | intent?: "remove"; 6 | } 7 | 8 | export const Button: FunctionComponent = (props) => { 9 | let className = style.button; 10 | if (props.intent === "remove") { 11 | className += ` ${style.remove}`; 12 | } 13 | return ; 87 | } 88 | return ( 89 |

90 | Logged in as {authState.user?.name ?? ""}.{" "} 91 | 92 | Logout 93 | 94 |

95 | ); 96 | }; 97 | -------------------------------------------------------------------------------- /web/components/roomConfig/FeedConnection.module.scss: -------------------------------------------------------------------------------- 1 | .resultListItem { 2 | list-style: none; 3 | padding-bottom: 1rem; 4 | } 5 | -------------------------------------------------------------------------------- /web/components/roomConfig/RoomConfig.module.scss: -------------------------------------------------------------------------------- 1 | .header { 2 | display: flex; 3 | flex-direction: row; 4 | align-items: flex-start; 5 | 6 | img { 7 | width: 52px; 8 | height: 52px; 9 | } 10 | 11 | @media (prefers-color-scheme: dark) { 12 | img.invert { 13 | filter: invert(100%); 14 | } 15 | } 16 | 17 | h1 { 18 | margin-left: 10px; 19 | font-weight: 600; 20 | font-size: 18px; 21 | line-height: 24px; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /web/context.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from "preact"; 2 | import type { BridgeAPI } from "./BridgeAPI"; 3 | 4 | interface IBridgeContext { 5 | bridgeApi: BridgeAPI; 6 | } 7 | 8 | const fakeBridgeContext = { 9 | get bridgeApi(): BridgeAPI { 10 | throw Error("No context provided"); 11 | }, 12 | }; 13 | 14 | export const BridgeContext = createContext(fakeBridgeContext); 15 | -------------------------------------------------------------------------------- /web/fonts/fonts.scss: -------------------------------------------------------------------------------- 1 | /* Inter unexpectedly contains various codepoints which collide with emoji, even 2 | when variation-16 is applied to request the emoji variant. From eyeballing 3 | the emoji picker, these are: 20e3, 23cf, 24c2, 25a0-25c1, 2665, 2764, 2b06, 2b1c. 4 | Therefore we define a unicode-range to load which excludes the glyphs 5 | (to avoid having to maintain a fork of Inter). */ 6 | 7 | // Borrowed from https://github.com/matrix-org/matrix-react-sdk/blob/020cdc99a2db1b6c7ac2b1be28a5ccd336011f6a/res/themes/light/css/_fonts.scss 8 | 9 | $inter-unicode-range: 10 | U+0000-20e2, U+20e4-23ce, U+23d0-24c1, U+24c3-259f, U+25c2-2664, U+2666-2763, 11 | U+2765-2b05, U+2b07-2b1b, U+2b1d-10FFFF; 12 | 13 | @import url("@fontsource/inter/400.css"); 14 | @import url("@fontsource/inter/500.css"); 15 | @import url("@fontsource/inter/600.css"); 16 | @import url("@fontsource/inter/700.css"); 17 | -------------------------------------------------------------------------------- /web/icons/feeds.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matrix-org/matrix-hookshot/e69195d13f6025f4f22964028676ee18816a3930/web/icons/feeds.png -------------------------------------------------------------------------------- /web/icons/github.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matrix-org/matrix-hookshot/e69195d13f6025f4f22964028676ee18816a3930/web/icons/github.png -------------------------------------------------------------------------------- /web/icons/gitlab.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matrix-org/matrix-hookshot/e69195d13f6025f4f22964028676ee18816a3930/web/icons/gitlab.png -------------------------------------------------------------------------------- /web/icons/jira.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matrix-org/matrix-hookshot/e69195d13f6025f4f22964028676ee18816a3930/web/icons/jira.png -------------------------------------------------------------------------------- /web/icons/openproject.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matrix-org/matrix-hookshot/e69195d13f6025f4f22964028676ee18816a3930/web/icons/openproject.png -------------------------------------------------------------------------------- /web/icons/webhook.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matrix-org/matrix-hookshot/e69195d13f6025f4f22964028676ee18816a3930/web/icons/webhook.png -------------------------------------------------------------------------------- /web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Hookshot Widget 7 | 8 | 9 |
10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /web/index.tsx: -------------------------------------------------------------------------------- 1 | import { render } from "preact"; 2 | import "preact/devtools"; 3 | import App from "./App"; 4 | import "./fonts/fonts.scss"; 5 | import "./styling.scss"; 6 | import "@vector-im/compound-design-tokens/assets/web/css/compound-design-tokens.css"; 7 | import "@vector-im/compound-web/dist/style.css"; 8 | 9 | const [root] = document.getElementsByTagName("main"); 10 | 11 | if (root) { 12 | render(, root); 13 | } 14 | -------------------------------------------------------------------------------- /web/oauth.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Account 7 | 8 | 9 |
10 | 13 |
14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /web/oauth.scss: -------------------------------------------------------------------------------- 1 | a { 2 | color: var(--primary-color); 3 | } 4 | 5 | a:visited { 6 | color: var(--primary-color); 7 | } 8 | 9 | body { 10 | margin-top: 5em; 11 | margin-left: 5em; 12 | margin-right: 5em; 13 | 14 | text-align: center; 15 | } 16 | 17 | h1 { 18 | font-size: 1.5em; 19 | } 20 | 21 | p { 22 | font-size: 1.25em; 23 | } 24 | -------------------------------------------------------------------------------- /web/oauth.tsx: -------------------------------------------------------------------------------- 1 | import "./fonts/fonts.scss"; 2 | import "./styling.scss"; 3 | import "./oauth.scss"; 4 | import { render } from "preact"; 5 | import "preact/devtools"; 6 | import type { OAuthPageParams } from "../src/Webhooks"; 7 | 8 | const root = document.getElementsByTagName("main")[0]; 9 | 10 | const ServiceToName: Record = { 11 | github: "GitHub", 12 | gitlab: "GitLab", 13 | default: "", 14 | }; 15 | 16 | function RenderOAuth() { 17 | const params = new URLSearchParams(window.location.search); 18 | const service = 19 | (params.get("service") as OAuthPageParams["service"]) ?? "default"; 20 | const error = params.get("error") as OAuthPageParams["error"]; 21 | const errcode = params.get("errcode") as OAuthPageParams["errcode"]; 22 | const oauthKind = 23 | (params.get("oauth-kind") as OAuthPageParams["oauth-kind"]) ?? "account"; 24 | const result = params.get("result") as OAuthPageParams["result"]; 25 | 26 | const serviceName = ServiceToName[service]; 27 | 28 | if (result === "error") { 29 | return ( 30 | <> 31 |

32 | Could not connect your {serviceName} {oauthKind} to Hookshot. 33 |

34 |

35 | {errcode} {error} 36 |

37 | 38 | ); 39 | // Pending / update are mostly the same thing. Pending means a new app install, update means updating the existing app install. 40 | } else if (result === "pending" || result === "update") { 41 | return ( 42 | <> 43 |

44 | The connection to your {serviceName} {oauthKind} is pending. 45 |

46 |

47 | You will need to wait for an administrator of the {serviceName}{" "} 48 | {oauthKind} instance to approve the new installation. If you think 49 | this is a mistake, contact the administrator of your organisation. 50 |

51 | 52 | ); 53 | } else if (result === "success") { 54 | return ( 55 | <> 56 |

57 | Your {serviceName} {oauthKind} has been connected. 58 |

59 |

You may close this window.

60 | 61 | ); 62 | } 63 | return ( 64 | <> 65 |

66 | The connection to your {serviceName} {oauthKind} is {result}. 67 |

68 |

69 | This is an unknown state, you may need to contact your systems 70 | administrator. 71 |

72 | 73 | ); 74 | } 75 | 76 | if (root) { 77 | render(, root); 78 | } 79 | -------------------------------------------------------------------------------- /web/styling.scss: -------------------------------------------------------------------------------- 1 | :root { 2 | --background-color: #ffffff; 3 | --foreground-color: #17191c; 4 | --primary-color: #0dbd8b; 5 | --primary-color-disabled: #0dbd8baf; 6 | 7 | background-color: #f4f6fa; 8 | color: var(--foreground-color); 9 | min-height: 100%; 10 | width: 100%; 11 | font-family: 12 | "Inter", "Twemoji", "Apple Color Emoji", "Segoe UI Emoji", "Arial", 13 | "Helvetica", sans-serif, "Noto Color Emoji"; 14 | } 15 | 16 | // @media (prefers-color-scheme: dark) { 17 | // :root { 18 | // --background-color: #111111; 19 | // --foreground-color: #f2f5f8; 20 | // } 21 | // } 22 | 23 | a { 24 | color: var(--primary-color); 25 | } 26 | 27 | a:visited { 28 | color: var(--primary-color); 29 | } 30 | 31 | body { 32 | margin: 0; 33 | height: fit-content; 34 | block-size: fit-content !important; 35 | } 36 | 37 | button { 38 | font-weight: 600; 39 | margin-right: 8px; 40 | } 41 | -------------------------------------------------------------------------------- /web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "jsx": "react-jsx", 4 | "jsxImportSource": "preact", 5 | "module": "commonjs", 6 | "moduleResolution": "node", 7 | "target": "es2019", 8 | "types": ["preact"], 9 | /* noEmit - Vite builds (emits) files, not tsc. */ 10 | "noEmit": true, 11 | /* Additional Options */ 12 | "strict": true, 13 | "skipLibCheck": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "resolveJsonModule": true, 16 | "allowSyntheticDefaultImports": true, 17 | "experimentalDecorators": true, 18 | "emitDecoratorMetadata": true, 19 | "esModuleInterop": true, 20 | "typeRoots": ["./typings"] 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /web/typings/images.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.png" { 2 | const content: string; 3 | export = content; 4 | } 5 | -------------------------------------------------------------------------------- /web/typings/sass.d.ts: -------------------------------------------------------------------------------- 1 | // As per https://lwebapp.com/en/post/cannot-find-module-scss 2 | declare module "*.scss" { 3 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 4 | const content: { [key: string]: any }; 5 | export = content; 6 | } 7 | --------------------------------------------------------------------------------