Merge remote-tracking branch 'origin/main' into hs/rs-message-queues

This commit is contained in:
Will Hunt 2024-01-05 01:36:50 +00:00
commit 6be34e55bf
205 changed files with 11026 additions and 4240 deletions

View File

@ -15,6 +15,8 @@ module.exports = {
},
rules: {
"@typescript-eslint/explicit-module-boundary-types": "off",
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/no-unused-vars": "warn",
"camelcase": ["error", { "properties": "never", "ignoreDestructuring": true }],
"no-console": "error"
},
@ -48,6 +50,7 @@ module.exports = {
rules: {
"no-console": "off",
"no-unused-vars": "off",
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/no-unused-vars": ["error"],
"no-useless-constructor": "off",
"@typescript-eslint/no-useless-constructor": ["error"],

View File

@ -10,6 +10,12 @@ on:
branches: [ main ]
paths-ignore:
- changelog.d/**'
merge_group:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
DOCKER_NAMESPACE: halfshot

View File

@ -10,6 +10,10 @@ env:
DOCKER_NAMESPACE: halfshot
PLATFORMS: linux/amd64,linux/arm64
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
docker-release:
runs-on: ubuntu-latest

View File

@ -5,6 +5,10 @@ on:
paths-ignore:
- changelog.d/**'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
deploy:
runs-on: ubuntu-20.04

View File

@ -4,6 +4,10 @@ on:
release:
types: [published]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
deploy:
runs-on: ubuntu-20.04

27
.github/workflows/helm-lint.yml vendored Normal file
View File

@ -0,0 +1,27 @@
name: Helm Chart - Validate
on:
push:
branches: [ main ]
paths-ignore:
- changelog.d/**'
pull_request:
branches: [ main ]
paths-ignore:
- changelog.d/**'
workflow_dispatch:
jobs:
lint-helm:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Lint Helm
uses: WyriHaximus/github-action-helm3@v3
with:
exec: helm lint ./helm/hookshot/
- name: Validate
uses: nlamirault/helm-kubeconform-action@v0.1.0
with:
charts: ./helm/

56
.github/workflows/helm.yml vendored Normal file
View File

@ -0,0 +1,56 @@
name: Helm Chart - Release
on:
push:
branches:
- main
paths:
- 'helm/**' # only execute if we have helm chart changes
workflow_dispatch:
jobs:
release:
# depending on default permission settings for your org (contents being read-only or read-write for workloads), you will have to add permissions
# see: https://docs.github.com/en/actions/security-guides/automatic-token-authentication#modifying-the-permissions-for-the-github_token
permissions:
contents: write
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Configure Git
run: |
git config user.name "$GITHUB_ACTOR"
git config user.email "$GITHUB_ACTOR@users.noreply.github.com"
- name: Install Helm
uses: azure/setup-helm@v3
with:
version: v3.10.0
- name: "Get app version from package.json"
id: get_hookshot_version
run: |
echo "hookshot_version=$(cat package.json | yq .version)" >> "$GITHUB_OUTPUT"
- name: Set chart appVersion to current package.json version
uses: mikefarah/yq@v4.34.1
with:
cmd: |
yq -i '.appVersion="${{steps.get_hookshot_version.outputs.hookshot_version}}"' helm/hookshot/Chart.yaml
- name: Set values hookshot config to current config.sample.yml contents
uses: mikefarah/yq@v4.34.1
with:
cmd: |
yq -i eval-all 'select(fileIndex==0).hookshot.config = select(fileIndex==1) | select(fileIndex==0)' helm/hookshot/values.yaml config.sample.yml
- name: Run chart-releaser
uses: helm/chart-releaser-action@v1.5.0
env:
CR_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
with:
config: helm/cr.yaml
charts_dir: helm/

View File

@ -11,6 +11,12 @@ on:
- changelog.d/**'
workflow_dispatch:
merge_group:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
lint-node:
@ -34,6 +40,7 @@ jobs:
profile: minimal
components: rustfmt
- run: cargo fmt --all -- --check
- run: cargo clippy -- -Dwarnings
config:
runs-on: ubuntu-latest
@ -44,7 +51,7 @@ jobs:
with:
node-version-file: .node-version
- run: yarn # Need to build scripts to get rust bindings
- run: yarn --silent ts-node src/Config/Defaults.ts --config | diff config.sample.yml -
- run: yarn --silent ts-node src/config/Defaults.ts --config | diff config.sample.yml -
metrics-docs:
runs-on: ubuntu-latest
@ -62,7 +69,7 @@ jobs:
runs-on: ubuntu-20.04
strategy:
matrix:
node_version: [18, 20]
node_version: [20, 21]
steps:
- uses: actions/checkout@v3
- name: Use Node.js ${{ matrix.node_version }}
@ -73,5 +80,83 @@ jobs:
with:
toolchain: stable
profile: minimal
- uses: Swatinem/rust-cache@v2
with:
shared-key: rust-cache
- run: yarn
- run: yarn test:cover
build-homerunner:
runs-on: ubuntu-latest
outputs:
homerunnersha: ${{ steps.gitsha.outputs.sha }}
steps:
- name: Checkout matrix-org/complement
uses: actions/checkout@v3
with:
repository: matrix-org/complement
- name: Get complement git sha
id: gitsha
run: echo sha=`git rev-parse --short HEAD` >> "$GITHUB_OUTPUT"
- name: Cache homerunner
id: cached
uses: actions/cache@v3
with:
path: homerunner
key: ${{ runner.os }}-homerunner-${{ steps.gitsha.outputs.sha }}
- name: "Set Go Version"
if: ${{ steps.cached.outputs.cache-hit != 'true' }}
run: |
echo "$GOROOT_1_18_X64/bin" >> $GITHUB_PATH
echo "~/go/bin" >> $GITHUB_PATH
# Build and install homerunner
- name: Install Complement Dependencies
if: ${{ steps.cached.outputs.cache-hit != 'true' }}
run: |
sudo apt-get update && sudo apt-get install -y libolm3 libolm-dev
- name: Build homerunner
if: ${{ steps.cached.outputs.cache-hit != 'true' }}
run: |
go build ./cmd/homerunner
integration-test:
runs-on: ubuntu-latest
timeout-minutes: 30
needs:
- test
- build-homerunner
steps:
- name: Install Complement Dependencies
run: |
sudo apt-get update && sudo apt-get install -y libolm3
- name: Load cached homerunner bin
uses: actions/cache@v3
with:
path: homerunner
key: ${{ runner.os }}-homerunner-${{ needs.build-synapse.outputs.homerunnersha }}
fail-on-cache-miss: true # Shouldn't happen, we build this in the needs step.
- name: Checkout matrix-hookshot
uses: actions/checkout@v3
with:
path: matrix-hookshot
# Setup node & run tests
- name: Use Node.js
uses: actions/setup-node@v3
with:
node-version-file: matrix-hookshot/.node-version
- uses: Swatinem/rust-cache@v2
with:
workspaces: matrix-hookshot
shared-key: rust-cache
- name: Run Homerunner tests
timeout-minutes: 10
env:
HOMERUNNER_SPAWN_HS_TIMEOUT_SECS: 100
HOMERUNNER_IMAGE: ghcr.io/element-hq/synapse/complement-synapse:latest
NODE_OPTIONS: --dns-result-order ipv4first
run: |
docker pull $HOMERUNNER_IMAGE
cd matrix-hookshot
yarn --strict-semver --frozen-lockfile
../homerunner &
bash -ic 'yarn test:e2e'

View File

@ -3,6 +3,7 @@ name: Newsfile
on:
pull_request:
branches: [ main ]
merge_group:
jobs:
changelog:

View File

@ -1 +1 @@
18
20

View File

@ -1,3 +1,262 @@
5.1.2 (2024-01-02)
==================
Bugfixes
--------
- Fix widget pinning to light theme. ([\#873](https://github.com/matrix-org/matrix-hookshot/issues/873))
- Fix hookshot failing to format API errors.
Only log a stacktrace of API errors on debug level logging, log limited error on info. ([\#874](https://github.com/matrix-org/matrix-hookshot/issues/874))
- Fix GitHub events not working due to verification failures. ([\#875](https://github.com/matrix-org/matrix-hookshot/issues/875))
Internal Changes
----------------
- Fix spelling of "successfully". ([\#869](https://github.com/matrix-org/matrix-hookshot/issues/869))
5.1.1 (2023-12-29)
==================
Bugfixes
--------
- Fix widgets not loading when bound to the same listener as "webhooks". ([\#872](https://github.com/matrix-org/matrix-hookshot/issues/872))
5.1.0 (2023-12-29)
==================
Bugfixes
--------
- Fix feed widget not showing the true values for template / notify on failure. ([\#866](https://github.com/matrix-org/matrix-hookshot/issues/866))
- Fix widgets failing with "Request timed out". ([\#870](https://github.com/matrix-org/matrix-hookshot/issues/870))
Deprecations and Removals
-------------------------
- The GoNEB migrator is being removed in this release. Users wishing to migrate from GoNEB deployments should use <=5.0.0 and then upgrade. ([\#867](https://github.com/matrix-org/matrix-hookshot/issues/867))
Internal Changes
----------------
- Integrate end to end testing. ([\#869](https://github.com/matrix-org/matrix-hookshot/issues/869))
5.0.0 (2023-12-27)
==================
Features
--------
- Warn if the bot does not have permissions to talk in a room. ([\#852](https://github.com/matrix-org/matrix-hookshot/issues/852))
- Support dark mode for the widget interface. ([\#863](https://github.com/matrix-org/matrix-hookshot/issues/863))
- Add `webhook list` and `webhook remove` commands. ([\#866](https://github.com/matrix-org/matrix-hookshot/issues/866))
Bugfixes
--------
- Fix notify on failure not being toggleable in the feeds widget interface. ([\#865](https://github.com/matrix-org/matrix-hookshot/issues/865))
Improved Documentation
----------------------
- Documentation tidyups. ([\#855](https://github.com/matrix-org/matrix-hookshot/issues/855), [\#857](https://github.com/matrix-org/matrix-hookshot/issues/857), [\#858](https://github.com/matrix-org/matrix-hookshot/issues/858), [\#859](https://github.com/matrix-org/matrix-hookshot/issues/859), [\#860](https://github.com/matrix-org/matrix-hookshot/issues/860))
- Generally tidy up and improve metrics documentation. ([\#856](https://github.com/matrix-org/matrix-hookshot/issues/856))
Deprecations and Removals
-------------------------
- Drop support for Node 18 and start supporting Node 21. ([\#862](https://github.com/matrix-org/matrix-hookshot/issues/862))
4.7.0 (2023-12-06)
==================
Internal Changes
----------------
- Update the release script to examine the staged contents of package files when checking for consistency between Node & Rust package versions. ([\#846](https://github.com/matrix-org/matrix-hookshot/issues/846))
- Use Node 20 (slim) for Docker image base. ([\#849](https://github.com/matrix-org/matrix-hookshot/issues/849))
4.6.0 (2023-11-20)
==================
Features
--------
- Add new `webhookResponse` field to the transformation API to specify your own response data. See the documentation for help. ([\#839](https://github.com/matrix-org/matrix-hookshot/issues/839))
Bugfixes
--------
- Fix version picker on docs site not loading. ([\#843](https://github.com/matrix-org/matrix-hookshot/issues/843))
Improved Documentation
----------------------
- Add note about GitHub token scope for private vs. public repo notifications ([\#830](https://github.com/matrix-org/matrix-hookshot/issues/830))
Internal Changes
----------------
- Update the release script to check for consistency between Node & Rust package versions. ([\#819](https://github.com/matrix-org/matrix-hookshot/issues/819))
- Chart version 0.1.14
Do not populate optional values in default helm config, as default values are not valid. ([\#821](https://github.com/matrix-org/matrix-hookshot/issues/821))
- Release chart version 0.1.15.
Sample config now comments out optional parameters by default. ([\#826](https://github.com/matrix-org/matrix-hookshot/issues/826))
4.5.1 (2023-09-26)
==================
Bugfixes
--------
- Fix transformation scripts breaking if they include a `return` at the top level ([\#818](https://github.com/matrix-org/matrix-hookshot/issues/818))
4.5.0 (2023-09-26)
==================
Features
--------
- Bridge Gitlab comment replies as Matrix threads. ([\#758](https://github.com/matrix-org/matrix-hookshot/issues/758))
- Add generic webhook transformation JS snippet for Prometheus Alertmanager. ([\#808](https://github.com/matrix-org/matrix-hookshot/issues/808))
Bugfixes
--------
- Fix a potential memory leak where Hookshot may hold onto certain requests forever in memory. ([\#814](https://github.com/matrix-org/matrix-hookshot/issues/814))
- Fix feed metrics treating request failures as parsing failures. ([\#816](https://github.com/matrix-org/matrix-hookshot/issues/816))
Deprecations and Removals
-------------------------
- Drop support for the Sled crypto store format. Users must disable/remove the configuration key of `experimentalEncryption.useLegacySledStore`, and the crypto store will always use the SQLite format. If an existing SQLite store does not exist on bridge startup, one will be created. ([\#798](https://github.com/matrix-org/matrix-hookshot/issues/798))
Internal Changes
----------------
- Update the version number of Hookshot's Rust package. ([\#803](https://github.com/matrix-org/matrix-hookshot/issues/803))
- Update eslint to a version that supports Typescript 5.1.3. ([\#815](https://github.com/matrix-org/matrix-hookshot/issues/815))
- Use quickjs instead of vm2 for evaluating JS transformation functions. ([\#817](https://github.com/matrix-org/matrix-hookshot/issues/817))
4.4.1 (2023-07-31)
==================
It is **strongly** reccomended you upgrade your bridge, as this release contains security fixes.
🔒 Security
-----------
- Fixes for GHSA-vc7j-h8xg-fv5x.
Features
--------
- Add more icons to GitHub repo hooks ([\#795](https://github.com/matrix-org/matrix-hookshot/issues/795))
Bugfixes
--------
- Fix instructions for validating your config using Docker ([\#787](https://github.com/matrix-org/matrix-hookshot/issues/787))
Internal Changes
----------------
- Sort feed list alphabetically in bot command response ([\#791](https://github.com/matrix-org/matrix-hookshot/issues/791))
- Update word-wrap from 1.2.3 to 1.2.4. ([\#799](https://github.com/matrix-org/matrix-hookshot/issues/799))
- Update matrix-appservice-bridge to 9.0.1. ([\#800](https://github.com/matrix-org/matrix-hookshot/issues/800))
4.4.0 (2023-06-28)
==================
Bugfixes
--------
- Refactor Hookshot to use Redis for caching of feed information, massively improving memory usage.
Please note that this is a behavioural change: Hookshots configured to use in-memory caching (not Redis),
will no longer bridge any RSS entries it may have missed during downtime, and will instead perform an initial
sync (not reporting any entries) instead. ([\#786](https://github.com/matrix-org/matrix-hookshot/issues/786))
- Feeds now tries to find an HTML-type link before falling back to the first link when parsing atom feeds ([\#784](https://github.com/matrix-org/matrix-hookshot/issues/784))
4.3.0 (2023-06-19)
==================
Features
--------
- Added basic helm chart to repository with GitHub Actions / chart-releaser builds ([\#719](https://github.com/matrix-org/matrix-hookshot/issues/719))
- Feeds are now polled concurrently (defaulting to 4 feeds at a time). ([\#779](https://github.com/matrix-org/matrix-hookshot/issues/779))
4.2.0 (2023-06-05)
===================
Features
--------
- Add support for uploading bot avatar images. ([\#767](https://github.com/matrix-org/matrix-hookshot/issues/767))
Bugfixes
--------
- Fix confusing case where issue comments would be notified on if the issue event type is checked on GitHub connections. ([\#757](https://github.com/matrix-org/matrix-hookshot/issues/757))
- Fix crash when failing to handle events, typically due to lacking permissions to send messages in a room. ([\#771](https://github.com/matrix-org/matrix-hookshot/issues/771))
4.1.0 (2023-05-24)
==================
Features
--------
- Add support for notifying when a GitLab MR has a single review (rather than completed review). ([\#736](https://github.com/matrix-org/matrix-hookshot/issues/736))
- Add support for Sentry tracing. ([\#754](https://github.com/matrix-org/matrix-hookshot/issues/754))
Bugfixes
--------
- Fix feed message format when the item does not contain a title or link. ([\#737](https://github.com/matrix-org/matrix-hookshot/issues/737))
- Fix HTML appearing in its escaped form in feed item summaries. ([\#738](https://github.com/matrix-org/matrix-hookshot/issues/738))
- Fix Github comments not being rendered correctly as blockquotes. ([\#746](https://github.com/matrix-org/matrix-hookshot/issues/746))
- Fix setup issues when the bot has PL 0 and room default isn't 0. ([\#755](https://github.com/matrix-org/matrix-hookshot/issues/755))
Internal Changes
----------------
- Apply non-style suggestions by `cargo clippy` to reduce allocations in the rust code. ([\#750](https://github.com/matrix-org/matrix-hookshot/issues/750))
- Apply more Rust clippy suggestions, and run clippy in CI. ([\#753](https://github.com/matrix-org/matrix-hookshot/issues/753))
- Update eslint to a version that supports Typescript 5. ([\#760](https://github.com/matrix-org/matrix-hookshot/issues/760))
4.0.0 (2023-04-27)
==================

1666
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,13 +1,13 @@
[package]
name = "matrix-hookshot"
version = "1.8.1"
version = "5.1.2"
edition = "2021"
[lib]
crate-type = ["cdylib"]
[dependencies]
napi = {version="2.12.6", features=["serde-json", "napi6"]}
napi = {version="2", features=["serde-json", "async", "napi6"]}
napi-derive = "2"
url = "2"
serde_json = "1"
@ -15,13 +15,15 @@ serde = "1"
serde_derive = "1"
contrast = "0"
rgb = "0"
md-5 = "0.8.0"
hex = "0.4.3"
rss = "2.0.3"
md-5 = "0.10"
hex = "0.4"
rss = "2.0"
atom_syndication = "0.12"
glob = "0.3.1"
uuid = {version="1.3.2", features=["v4", "fast-rng"]}
redis = "0.23.0"
ruma = { version = "0.9", features = ["events", "html"] }
reqwest = "0.11"
[build-dependencies]
napi-build = "1"
napi-build = "2"

View File

@ -1,9 +1,11 @@
# Stage 0: Build the thing
# Need debian based image to build the native rust module
# as musl doesn't support cdylib
FROM node:18 AS builder
FROM node:20-slim AS builder
# Needed in order to build rust FFI bindings.
RUN apt-get update && apt-get install -y build-essential cmake curl pkg-config pkg-config libssl-dev
# We need rustup so we have a sensible rust version, the version packed with bullsye is too old
RUN curl https://sh.rustup.rs -sSf | sh -s -- -y --profile minimal
ENV PATH="/root/.cargo/bin:${PATH}"
@ -12,9 +14,6 @@ ENV PATH="/root/.cargo/bin:${PATH}"
ARG CARGO_NET_GIT_FETCH_WITH_CLI=false
ENV CARGO_NET_GIT_FETCH_WITH_CLI=$CARGO_NET_GIT_FETCH_WITH_CLI
# Needed to build rust things for matrix-sdk-crypto-nodejs
# See https://github.com/matrix-org/matrix-rust-sdk-bindings/blob/main/crates/matrix-sdk-crypto-nodejs/release/Dockerfile.linux#L5-L6
RUN apt-get update && apt-get install -y build-essential cmake
WORKDIR /src
@ -30,10 +29,12 @@ RUN yarn build
# Stage 1: The actual container
FROM node:18
FROM node:20-slim
WORKDIR /bin/matrix-hookshot
RUN apt-get update && apt-get install -y openssl ca-certificates
COPY --from=builder /src/yarn.lock /src/package.json ./
COPY --from=builder /cache/yarn /cache/yarn
RUN yarn config set yarn-offline-mirror /cache/yarn
@ -42,6 +43,7 @@ RUN yarn --network-timeout 600000 --production --pure-lockfile && yarn cache cle
COPY --from=builder /src/lib ./
COPY --from=builder /src/public ./public
COPY --from=builder /src/assets ./assets
VOLUME /data
EXPOSE 9993

View File

@ -23,14 +23,14 @@ We richly support the following integrations:
- [Jira](https://matrix-org.github.io/matrix-hookshot/latest/setup/jira.html)
- [RSS/Atom feeds](https://matrix-org.github.io/matrix-hookshot/latest/setup/feeds.html)
Get started by reading the [the setup guide](https://matrix-org.github.io/matrix-hookshot/latest/setup.html)!
Get started by reading [the setup guide](https://matrix-org.github.io/matrix-hookshot/latest/setup.html)!
## Documentation
Documentation can be found on [GitHub Pages](https://matrix-org.github.io/matrix-hookshot).
You can build the documentation yourself by:
You can build the documentation yourself by typing:
```sh
# cargo install mdbook
mdbook build

BIN
assets/feeds_avatar.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

BIN
assets/figma_avatar.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

BIN
assets/github_avatar.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

BIN
assets/gitlab_avatar.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
assets/jira_avatar.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@ -0,0 +1,67 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="32"
height="32"
viewBox="0 0 0.33333333 0.33333333"
version="1.1"
id="svg1630"
inkscape:version="1.2.2 (b0a84865, 2022-12-01)"
sodipodi:docname="feeds_avatar.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1632"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="in"
showgrid="false"
inkscape:zoom="4.28125"
inkscape:cx="94.715328"
inkscape:cy="-14.014599"
inkscape:window-width="2560"
inkscape:window-height="1302"
inkscape:window-x="2560"
inkscape:window-y="25"
inkscape:window-maximized="0"
inkscape:current-layer="layer1" />
<defs
id="defs1627" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<rect
style="fill:#f8991d;stroke-width:0.000783981;fill-opacity:1"
id="rect16036"
width="0.33333334"
height="0.33333334"
x="0"
y="0" />
<ellipse
style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.0113383;stroke-linecap:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="path5270"
cx="0.10655722"
cy="0.22619501"
rx="0.023283437"
ry="0.022608556" />
<path
style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.00108608px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 0.08347222,0.16973219 -7.942e-5,-0.032879 c 0.070275,0.00367 0.10927918,0.0539491 0.11023648,0.11071368 h -0.032928 c -5.4632e-4,-0.049895 -0.034473,-0.07602 -0.07722906,-0.0778347 z"
id="path5805"
sodipodi:nodetypes="ccccc" />
<path
style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.00108608px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="M 0.08434684,0.11703641 0.0833928,0.08363142 c 0.10798627,0.0050099 0.16581867,0.08337597 0.16654766,0.16607045 l -0.0338827,-4.7761e-4 c 0.001483,-0.052895 -0.0376492,-0.13061767 -0.13171092,-0.13218785 z"
id="path5807"
sodipodi:nodetypes="ccccc" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@ -0,0 +1,88 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="32"
height="32"
viewBox="0 0 0.33333333 0.33333333"
version="1.1"
id="svg1630"
inkscape:version="1.2.2 (b0a84865, 2022-12-01)"
sodipodi:docname="figma_avatar.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1632"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="in"
showgrid="false"
inkscape:zoom="4.2812503"
inkscape:cx="62.715324"
inkscape:cy="56.525544"
inkscape:window-width="2560"
inkscape:window-height="1274"
inkscape:window-x="2560"
inkscape:window-y="25"
inkscape:window-maximized="0"
inkscape:current-layer="layer1" />
<defs
id="defs1627" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<rect
style="fill:#ffffff;stroke-width:0.000783981;fill-opacity:1"
id="rect16036"
width="0.33333334"
height="0.33333334"
x="0"
y="0" />
<g
id="g482"
transform="translate(0.01500414)">
<rect
width="0.11111087"
height="0.16666642"
fill="#000000"
fill-opacity="0"
id="rect240"
x="0.096107043"
y="0.083333619"
style="stroke-width:0.00116931" />
<path
d="m 0.15166251,0.16666672 c 0,-0.0153406 0.0124364,-0.027778 0.0277778,-0.027778 v 0 c 0.015341,0 0.0277777,0.0124366 0.0277777,0.027778 v 0 c 0,0.0153413 -0.0124368,0.0277779 -0.0277777,0.0277779 v 0 c -0.0153414,0 -0.0277778,-0.0124366 -0.0277778,-0.0277779 z"
fill="#1abcfe"
id="path242"
style="stroke-width:0.000389771" />
<path
d="m 0.09610705,0.22222216 c 0,-0.0153406 0.0124365,-0.027778 0.0277777,-0.027778 h 0.0277778 v 0.027778 c 0,0.0153413 -0.0124364,0.027778 -0.0277778,0.027778 v 0 c -0.0153412,0 -0.0277777,-0.0124366 -0.0277777,-0.027778 z"
fill="#0acf83"
id="path244"
style="stroke-width:0.000389771" />
<path
d="m 0.15166251,0.08333365 v 0.05555524 h 0.0277778 c 0.0153414,0 0.0277777,-0.0124366 0.0277777,-0.0277773 v 0 c 0,-0.01534131 -0.0124364,-0.02777796 -0.0277777,-0.02777796 z"
fill="#ff7262"
id="path246"
style="stroke-width:0.000389771" />
<path
d="m 0.09610705,0.11111141 c 0,0.0153413 0.0124365,0.0277773 0.0277777,0.0277773 h 0.0277778 V 0.08333345 h -0.0277778 c -0.0153412,0 -0.0277777,0.01243665 -0.0277777,0.02777796 z"
fill="#f24e1e"
id="path248"
style="stroke-width:0.000389771" />
<path
d="m 0.09610705,0.16666672 c 0,0.0153413 0.0124365,0.0277779 0.0277777,0.0277779 h 0.0277778 v -0.0555552 h -0.0277778 c -0.0153412,0 -0.0277777,0.0124366 -0.0277777,0.0277773 z"
fill="#a259ff"
id="path250"
style="stroke-width:0.000389771" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

@ -0,0 +1,57 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="32"
height="32"
viewBox="0 0 0.33333333 0.33333333"
version="1.1"
id="svg1630"
inkscape:version="1.2.2 (b0a84865, 2022-12-01)"
sodipodi:docname="github_avatar.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1632"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="in"
showgrid="false"
inkscape:zoom="12.109204"
inkscape:cx="39.350233"
inkscape:cy="8.0104357"
inkscape:window-width="2560"
inkscape:window-height="1302"
inkscape:window-x="2560"
inkscape:window-y="25"
inkscape:window-maximized="0"
inkscape:current-layer="layer1" />
<defs
id="defs1627" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<rect
style="fill:#ffffff;stroke-width:0.000783981;fill-opacity:1"
id="rect16036"
width="0.33333334"
height="0.33333334"
x="0"
y="0" />
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="m 0.16677043,0.04375549 c -0.0691789,0 -0.1251031,0.0563338 -0.1251031,0.12602641 0,0.055709 0.03583264,0.10286558 0.085542,0.11955571 0.006214,0.001255 0.008491,-0.002712 0.008491,-0.006048 0,-0.002922 -2.0487e-4,-0.012936 -2.0487e-4,-0.0233708 -0.0348006,0.007513 -0.0420475,-0.0150232 -0.0420475,-0.0150232 -0.005592,-0.0146055 -0.0138795,-0.0183596 -0.0138795,-0.0183596 -0.0113895,-0.00772 8.2964e-4,-0.00772 8.2964e-4,-0.00772 0.0126345,8.3481e-4 0.0192646,0.0129359 0.0192646,0.0129359 0.0111825,0.0191945 0.0292028,0.0137715 0.0364524,0.0104325 0.001034,-0.008137 0.004351,-0.0137715 0.007872,-0.0169001 -0.0277559,-0.002922 -0.0569588,-0.0137715 -0.0569588,-0.0621797 0,-0.0137715 0.004968,-0.0250378 0.01284,-0.0338003 -0.001242,-0.003129 -0.005593,-0.0160681 0.001245,-0.0333854 0,0 0.010563,-0.003339 0.0343806,0.012936 a 0.12027864,0.12027267 0 0 1 0.031277,-0.004173 c 0.010563,0 0.0213311,0.001463 0.0312744,0.004173 0.0238202,-0.0162752 0.0343833,-0.012936 0.0343833,-0.012936 0.006837,0.0173176 0.002484,0.0302564 0.001241,0.0333854 0.008079,0.008763 0.012843,0.0200291 0.012843,0.0338003 0,0.0484087 -0.0292028,0.059048 -0.0571663,0.0621797 0.004558,0.003964 0.008491,0.0114735 0.008491,0.0233683 0,0.0169001 -2.0484e-4,0.0304638 -2.0484e-4,0.034635 0,0.003339 0.002279,0.007305 0.008491,0.006054 0.0497094,-0.0166952 0.085542,-0.0638492 0.085542,-0.11955826 2.0433e-4,-0.0696935 -0.0559249,-0.12602732 -0.12489634,-0.12602731 z"
fill="#24292f"
id="path361"
style="stroke-width:0.00256072" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

@ -0,0 +1,74 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="32"
height="32"
viewBox="0 0 0.33333333 0.33333333"
version="1.1"
id="svg1630"
inkscape:version="1.2.2 (b0a84865, 2022-12-01)"
sodipodi:docname="gitlab_avatar.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1632"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="in"
showgrid="false"
inkscape:zoom="17.125001"
inkscape:cx="28.992699"
inkscape:cy="10.218978"
inkscape:window-width="2560"
inkscape:window-height="1302"
inkscape:window-x="2560"
inkscape:window-y="25"
inkscape:window-maximized="0"
inkscape:current-layer="layer1" />
<defs
id="defs1627" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<rect
style="fill:#ffffff;stroke-width:0.000783981;fill-opacity:1"
id="rect16036"
width="0.33333334"
height="0.33333334"
x="0"
y="0" />
<g
id="LOGO"
transform="matrix(0.00130172,0,0,0.00130172,-0.08066014,-0.08067311)">
<path
class="cls-1"
d="m 282.83,170.73 -0.27,-0.69 -26.14,-68.22 a 6.81,6.81 0 0 0 -2.69,-3.24 7,7 0 0 0 -8,0.43 7,7 0 0 0 -2.32,3.52 l -17.65,54 h -71.47 l -17.65,-54 a 6.86,6.86 0 0 0 -2.32,-3.53 7,7 0 0 0 -8,-0.43 6.87,6.87 0 0 0 -2.69,3.24 L 97.44,170 l -0.26,0.69 a 48.54,48.54 0 0 0 16.1,56.1 l 0.09,0.07 0.24,0.17 39.82,29.82 19.7,14.91 12,9.06 a 8.07,8.07 0 0 0 9.76,0 l 12,-9.06 19.7,-14.91 40.06,-30 0.1,-0.08 a 48.56,48.56 0 0 0 16.08,-56.04 z"
id="path695"
style="fill:#e24329" />
<path
class="cls-2"
d="m 282.83,170.73 -0.27,-0.69 a 88.3,88.3 0 0 0 -35.15,15.8 L 190,229.25 c 19.55,14.79 36.57,27.64 36.57,27.64 l 40.06,-30 0.1,-0.08 a 48.56,48.56 0 0 0 16.1,-56.08 z"
id="path697"
style="fill:#fc6d26" />
<path
class="cls-3"
d="m 153.43,256.89 19.7,14.91 12,9.06 a 8.07,8.07 0 0 0 9.76,0 l 12,-9.06 19.7,-14.91 c 0,0 -17.04,-12.89 -36.59,-27.64 -19.55,14.75 -36.57,27.64 -36.57,27.64 z"
id="path699"
style="fill:#fca326" />
<path
class="cls-2"
d="M 132.58,185.84 A 88.19,88.19 0 0 0 97.44,170 l -0.26,0.69 a 48.54,48.54 0 0 0 16.1,56.1 l 0.09,0.07 0.24,0.17 39.82,29.82 c 0,0 17,-12.85 36.57,-27.64 z"
id="path701"
style="fill:#fc6d26" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

@ -0,0 +1,90 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="32"
height="32"
viewBox="0 0 32 32"
fill="none"
version="1.1"
id="svg22"
sodipodi:docname="jira_avatar.svg"
inkscape:version="1.2.2 (b0a84865, 2022-12-01)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview24"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
showgrid="false"
inkscape:zoom="11.346854"
inkscape:cx="12.999198"
inkscape:cy="14.453345"
inkscape:window-width="2560"
inkscape:window-height="1302"
inkscape:window-x="2560"
inkscape:window-y="25"
inkscape:window-maximized="0"
inkscape:current-layer="svg22" />
<rect
style="opacity:1;fill:#0052cc;fill-opacity:1;stroke-width:0.941893"
id="rect332"
width="32"
height="32"
x="0"
y="-1.3125472e-07" />
<path
d="M23.7199 7.64001H15.6399C15.6399 9.64001 17.2799 11.28 19.2799 11.28H20.7599V12.72C20.7599 14.72 22.3999 16.36 24.3999 16.36V8.32001C24.4399 7.96001 24.1199 7.64001 23.7199 7.64001Z"
fill="white"
id="path4" />
<path
d="M19.7199 11.64H11.6399C11.6399 13.64 13.2799 15.28 15.2799 15.28H16.7599V16.72C16.7599 18.72 18.3999 20.36 20.3999 20.36V12.36C20.4399 11.96 20.1199 11.64 19.7199 11.64Z"
fill="url(#paint0_linear)"
id="path6" />
<path
d="M15.7199 15.6801H7.63989C7.63989 17.6801 9.27988 19.3201 11.2799 19.3201H12.7599V20.7601C12.7599 22.7601 14.3999 24.4001 16.3999 24.4001V16.4001C16.4399 16.0001 16.1199 15.6801 15.7199 15.6801Z"
fill="url(#paint1_linear)"
id="path8" />
<defs
id="defs20">
<linearGradient
id="paint0_linear"
x1="20.2597"
y1="11.6708"
x2="16.8183"
y2="15.2196"
gradientUnits="userSpaceOnUse">
<stop
offset="0.176"
stop-color="white"
stop-opacity="0.4"
id="stop10" />
<stop
offset="1"
stop-color="white"
id="stop12" />
</linearGradient>
<linearGradient
id="paint1_linear"
x1="16.4844"
y1="15.7246"
x2="12.5054"
y2="19.596"
gradientUnits="userSpaceOnUse">
<stop
offset="0.176"
stop-color="white"
stop-opacity="0.4"
id="stop15" />
<stop
offset="1"
stop-color="white"
id="stop17" />
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@ -0,0 +1,79 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="32"
height="32"
viewBox="0 0 0.33333333 0.33333333"
version="1.1"
id="svg1630"
inkscape:version="1.2.2 (b0a84865, 2022-12-01)"
sodipodi:docname="webhooks_avatar.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1632"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="in"
showgrid="false"
inkscape:zoom="6.0546022"
inkscape:cx="90.096753"
inkscape:cy="-17.011853"
inkscape:window-width="2560"
inkscape:window-height="1302"
inkscape:window-x="2560"
inkscape:window-y="25"
inkscape:window-maximized="0"
inkscape:current-layer="layer1" />
<defs
id="defs1627" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<rect
style="fill:#ffffff;stroke-width:0.000783981;fill-opacity:1"
id="rect16036"
width="0.33333334"
height="0.33333334"
x="0"
y="0" />
<g
id="g346"
transform="matrix(0.015625,0,0,0.015625,-2.4512961,-2.5012285)">
<g
id="OvHZFw.tif"
transform="matrix(0.12401029,0,0,0.12401029,159.54955,163.29764)">
<g
id="g325">
<path
fill-rule="evenodd"
clip-rule="evenodd"
fill="#231f20"
d="m 60.467,50.345 c -5.367,9.022 -10.509,17.759 -15.758,26.43 -1.348,2.226 -2.015,4.039 -0.938,6.869 2.973,7.817 -1.221,15.424 -9.104,17.489 C 27.233,103.081 19.99,98.195 18.515,90.236 17.208,83.191 22.675,76.285 30.442,75.184 31.093,75.091 31.757,75.08 32.851,74.998 36.657,68.616 40.556,62.079 44.666,55.186 37.235,47.797 32.812,39.159 33.791,28.456 34.483,20.89 37.458,14.352 42.896,8.993 53.311,-1.269 69.2,-2.931 81.463,4.946 93.241,12.512 98.635,27.25 94.037,39.864 90.57,38.924 87.079,37.976 83.241,36.935 84.685,29.922 83.617,23.624 78.887,18.229 75.762,14.667 71.752,12.8 67.192,12.112 58.051,10.731 49.076,16.604 46.413,25.576 43.39,35.759 47.965,44.077 60.467,50.345 Z"
id="path319" />
<path
fill-rule="evenodd"
clip-rule="evenodd"
fill="#231f20"
d="m 75.794,39.676 c 3.781,6.67 7.62,13.441 11.425,20.15 19.232,-5.95 33.732,4.696 38.934,16.094 6.283,13.768 1.988,30.075 -10.352,38.569 -12.666,8.72 -28.684,7.23 -39.906,-3.971 2.86,-2.394 5.734,-4.799 8.805,-7.368 11.084,7.179 20.778,6.841 27.975,-1.66 6.137,-7.252 6.004,-18.065 -0.311,-25.165 -7.288,-8.193 -17.05,-8.443 -28.85,-0.578 -4.895,-8.684 -9.875,-17.299 -14.615,-26.046 -1.598,-2.948 -3.363,-4.658 -6.965,-5.282 -6.016,-1.043 -9.9,-6.209 -10.133,-11.997 -0.229,-5.724 3.143,-10.898 8.414,-12.914 5.221,-1.997 11.348,-0.385 14.86,4.054 2.87,3.627 3.782,7.709 2.272,12.182 -0.42,1.247 -0.964,2.454 -1.553,3.932 z"
id="path321" />
<path
fill-rule="evenodd"
clip-rule="evenodd"
fill="#231f20"
d="m 84.831,94.204 c -7.605,0 -15.238,0 -23.152,0 -2.219,9.127 -7.012,16.496 -15.271,21.182 -6.42,3.642 -13.34,4.877 -20.705,3.688 C 12.143,116.887 1.055,104.68 0.079,90.934 -1.026,75.363 9.677,61.522 23.943,58.413 c 0.985,3.577 1.98,7.188 2.965,10.756 -13.089,6.678 -17.619,15.092 -13.956,25.613 3.225,9.259 12.385,14.334 22.331,12.371 10.157,-2.004 15.278,-10.445 14.653,-23.992 9.629,0 19.266,-0.1 28.896,0.049 3.76,0.059 6.663,-0.331 9.496,-3.646 4.664,-5.455 13.248,-4.963 18.271,0.189 5.133,5.265 4.887,13.737 -0.545,18.78 -5.241,4.866 -13.521,4.606 -18.424,-0.637 -1.008,-1.081 -1.802,-2.364 -2.799,-3.692 z"
id="path323" />
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

BIN
assets/webhooks_avatar.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View File

@ -1 +0,0 @@
Fix feed message format when the item does not contain a title or link.

1
changelog.d/876.feature Normal file
View File

@ -0,0 +1 @@
Add command to list feeds in JSON and YAML format to easily export all feeds from a room.

View File

@ -2,175 +2,27 @@
bridge:
# Basic homeserver configuration
domain: example.com
url: http://localhost:8008
mediaUrl: https://example.com
port: 9993
bindAddress: 127.0.0.1
github:
# (Optional) Configure this to enable GitHub support
auth:
# Authentication for the GitHub App.
id: 123
privateKeyFile: github-key.pem
webhook:
# Webhook settings for the GitHub app.
secret: secrettoken
oauth:
# (Optional) Settings for allowing users to sign in via OAuth.
client_id: foo
client_secret: bar
redirect_uri: https://example.com/bridge_oauth/
defaultOptions:
# (Optional) Default options for GitHub connections.
showIssueRoomLink: false
hotlinkIssues:
prefix: "#"
userIdPrefix:
# (Optional) Prefix used when creating ghost users for GitHub accounts.
_github_
gitlab:
# (Optional) Configure this to enable GitLab support
instances:
gitlab.com:
url: https://gitlab.com
webhook:
secret: secrettoken
publicUrl: https://example.com/hookshot/
userIdPrefix:
# (Optional) Prefix used when creating ghost users for GitLab accounts.
_gitlab_
figma:
# (Optional) Configure this to enable Figma support
publicUrl: https://example.com/hookshot/
instances:
your-instance:
teamId: your-team-id
accessToken: your-personal-access-token
passcode: your-webhook-passcode
jira:
# (Optional) Configure this to enable Jira support. Only specify `url` if you are using a On Premise install (i.e. not atlassian.com)
webhook:
# Webhook settings for JIRA
secret: secrettoken
oauth:
# (Optional) OAuth settings for connecting users to JIRA. See documentation for more information
client_id: foo
client_secret: bar
redirect_uri: https://example.com/bridge_oauth/
generic:
# (Optional) Support for generic webhook events.
#'allowJsTransformationFunctions' will allow users to write short transformation snippets in code, and thus is unsafe in untrusted environments
enabled: false
enableHttpGet: false
urlPrefix: https://example.com/webhook/
userIdPrefix: _webhooks_
allowJsTransformationFunctions: false
waitForComplete: false
feeds:
# (Optional) Configure this to enable RSS/Atom feed support
enabled: false
pollIntervalSeconds: 600
pollTimeoutSeconds: 30
provisioning:
# (Optional) Provisioning API for integration managers
secret: "!secretToken"
passFile:
# A passkey used to encrypt tokens stored inside the bridge.
# Run openssl genpkey -out passkey.pem -outform PEM -algorithm RSA -pkeyopt rsa_keygen_bits:4096 to generate
passkey.pem
bot:
# (Optional) Define profile information for the bot user
displayname: Hookshot Bot
avatar: mxc://half-shot.uk/2876e89ccade4cb615e210c458e2a7a6883fe17d
serviceBots:
# (Optional) Define additional bot users for specific services
- localpart: feeds
displayname: Feeds
avatar: mxc://half-shot.uk/2876e89ccade4cb615e210c458e2a7a6883fe17d
prefix: "!feeds"
service: feeds
metrics:
# (Optional) Prometheus metrics support
enabled: true
queue:
# (Optional) Message queue / cache configuration options for large scale deployments.
# For encryption to work, must be set to monolithic mode and have a host & port specified.
monolithic: true
port: 6379
host: localhost
logging:
# (Optional) Logging settings. You can have a severity debug,info,warn,error
# Logging settings. You can have a severity debug,info,warn,error
level: info
colorize: true
json: false
timestampFormat: HH:mm:ss:SSS
widgets:
# (Optional) EXPERIMENTAL support for complimentary widgets
addToAdminRooms: false
disallowedIpRanges:
- 127.0.0.0/8
- 10.0.0.0/8
- 172.16.0.0/12
- 192.168.0.0/16
- 100.64.0.0/10
- 192.0.0.0/24
- 169.254.0.0/16
- 192.88.99.0/24
- 198.18.0.0/15
- 192.0.2.0/24
- 198.51.100.0/24
- 203.0.113.0/24
- 224.0.0.0/4
- ::1/128
- fe80::/10
- fc00::/7
- 2001:db8::/32
- ff00::/8
- fec0::/10
roomSetupWidget:
addOnInvite: false
publicUrl: https://example.com/widgetapi/v1/static/
branding:
widgetTitle: Hookshot Configuration
permissions:
# (Optional) Permissions for using the bridge. See docs/setup.md#permissions for help
- actor: example.com
services:
- service: "*"
level: admin
listeners:
# (Optional) HTTP Listener configuration.
# HTTP Listener configuration.
# Bind resource endpoints to ports and addresses.
# 'port' must be specified. Each listener must listen on a unique port.
# 'bindAddress' will default to '127.0.0.1' if not specified, which may not be suited to Docker environments.
# 'resources' may be any of webhooks, widgets, metrics, provisioning
- port: 9000
bindAddress: 0.0.0.0
resources:
@ -185,3 +37,150 @@ listeners:
resources:
- widgets
#github:
# # (Optional) Configure this to enable GitHub support
# auth:
# # Authentication for the GitHub App.
# id: 123
# privateKeyFile: github-key.pem
# webhook:
# # Webhook settings for the GitHub app.
# secret: secrettoken
# oauth:
# # (Optional) Settings for allowing users to sign in via OAuth.
# client_id: foo
# client_secret: bar
# redirect_uri: https://example.com/bridge_oauth/
# defaultOptions:
# # (Optional) Default options for GitHub connections.
# showIssueRoomLink: false
# hotlinkIssues:
# prefix: "#"
# userIdPrefix:
# # (Optional) Prefix used when creating ghost users for GitHub accounts.
# _github_
#gitlab:
# # (Optional) Configure this to enable GitLab support
# instances:
# gitlab.com:
# url: https://gitlab.com
# webhook:
# secret: secrettoken
# publicUrl: https://example.com/hookshot/
# userIdPrefix:
# # (Optional) Prefix used when creating ghost users for GitLab accounts.
# _gitlab_
# commentDebounceMs:
# # (Optional) Aggregate comments by waiting this many miliseconds before posting them to Matrix. Defaults to 5000 (5 seconds)
# 5000
#figma:
# # (Optional) Configure this to enable Figma support
# publicUrl: https://example.com/hookshot/
# instances:
# your-instance:
# teamId: your-team-id
# accessToken: your-personal-access-token
# passcode: your-webhook-passcode
#jira:
# # (Optional) Configure this to enable Jira support. Only specify `url` if you are using a On Premise install (i.e. not atlassian.com)
# webhook:
# # Webhook settings for JIRA
# secret: secrettoken
# oauth:
# # (Optional) OAuth settings for connecting users to JIRA. See documentation for more information
# client_id: foo
# client_secret: bar
# redirect_uri: https://example.com/bridge_oauth/
#generic:
# # (Optional) Support for generic webhook events.
# #'allowJsTransformationFunctions' will allow users to write short transformation snippets in code, and thus is unsafe in untrusted environments
# enabled: false
# enableHttpGet: false
# urlPrefix: https://example.com/webhook/
# userIdPrefix: _webhooks_
# allowJsTransformationFunctions: false
# waitForComplete: false
#feeds:
# # (Optional) Configure this to enable RSS/Atom feed support
# enabled: false
# pollConcurrency: 4
# pollIntervalSeconds: 600
# pollTimeoutSeconds: 30
#provisioning:
# # (Optional) Provisioning API for integration managers
# secret: "!secretToken"
#bot:
# # (Optional) Define profile information for the bot user
# displayname: Hookshot Bot
# avatar: mxc://half-shot.uk/2876e89ccade4cb615e210c458e2a7a6883fe17d
#serviceBots:
# # (Optional) Define additional bot users for specific services
# - localpart: feeds
# displayname: Feeds
# avatar: ./assets/feeds_avatar.png
# prefix: "!feeds"
# service: feeds
#metrics:
# # (Optional) Prometheus metrics support
# enabled: true
#queue:
# # (Optional) Message queue / cache configuration options for large scale deployments.
# # For encryption to work, must be set to monolithic mode and have a host & port specified.
# monolithic: true
# port: 6379
# host: localhost
#widgets:
# # (Optional) EXPERIMENTAL support for complimentary widgets
# addToAdminRooms: false
# disallowedIpRanges:
# - 127.0.0.0/8
# - 10.0.0.0/8
# - 172.16.0.0/12
# - 192.168.0.0/16
# - 100.64.0.0/10
# - 192.0.0.0/24
# - 169.254.0.0/16
# - 192.88.99.0/24
# - 198.18.0.0/15
# - 192.0.2.0/24
# - 198.51.100.0/24
# - 203.0.113.0/24
# - 224.0.0.0/4
# - ::1/128
# - fe80::/10
# - fc00::/7
# - 2001:db8::/32
# - ff00::/8
# - fec0::/10
# roomSetupWidget:
# addOnInvite: false
# publicUrl: https://example.com/widgetapi/v1/static/
# branding:
# widgetTitle: Hookshot Configuration
#sentry:
# # (Optional) Configure Sentry error reporting
# dsn: https://examplePublicKey@o0.ingest.sentry.io/0
# environment: production
#permissions:
# # (Optional) Permissions for using the bridge. See docs/setup.md#permissions for help
# - actor: example.com
# services:
# - service: "*"
# level: admin

View File

@ -0,0 +1,68 @@
/**
* This is a transformation function for Prometheus Alertmanager webhooks.
* https://prometheus.io/docs/alerting/latest/configuration/#webhook_config
*
* Creates a formatted `m.text` message with plaintext fallback, containing:
* - alert status and severity
* - alert name and description
* - URL to the entity that caused the alert
* The formatted message also contains a clickable link that silences the alert.
*/
/**
* @param status resolved or firing
* @param severity from the labels of the alert
* @returns colored text rendering of the status and severity
*/
function statusBadge(status, severity) {
let statusColor;
if (status === "resolved") {
return `<font color='green'><b>[RESOLVED]</b></font>`;
}
switch(severity) {
case 'resolved':
case 'critical':
return `<font color='red'><b>[FIRING - CRITICAL]</b></font>`;
case 'warning':
return `<font color='orange'><b>[FIRING - WARNING]</b></font>`;
default:
return `<b>[${status.toUpperCase()}]</b>`;
}
}
/**
* @param alert object from the webhook payload
* @param externalURL from the webhook payload
* @returns a formatted link that will silence the alert when clicked
*/
function silenceLink(alert, externalURL) {
filters = []
for (const [label, val] of Object.entries(alert.labels)) {
filters.push(encodeURIComponent(`${label}="${val}"`));
}
return `<a href="${externalURL}#silences/new?filter={${filters.join(",")}}">silence</a>`;
}
if (!data.alerts) {
result = {
version: 'v2',
empty: true,
};
return;
}
const plainErrors = [];
const htmlErrors = [];
const { externalURL, alerts } = data;
for (const alert of data.alerts) {
plainErrors.push(`**[${alert.status.toUpperCase()} - ${alert.labels.severity}]** - ${alert.labels.alertname}: ${alert.annotations.description} [source](${alert.generatorURL})`);
htmlErrors.push(`<p>${statusBadge(alert.status, alert.labels.severity)}</p><p><b>${alert.labels.alertname}</b>: ${alert.annotations.description.replaceAll("\n","<br\>")}</p><p><a href="${alert.generatorURL}">source</a> | ${silenceLink(alert, externalURL)}</p>`)
result = {
version: 'v2',
plain: plainErrors.join(`\n\n`),
html: htmlErrors.join(`<br/><br/>`),
msgtype: 'm.text'
};
}

View File

@ -17,6 +17,7 @@
- [GitLab Project](./usage/room_configuration/gitlab_project.md)
- [JIRA Project](./usage/room_configuration/jira_project.md)
- [📊 Metrics](./metrics.md)
- [Sentry](./sentry.md)
# 🧑‍💻 Development
- [Contributing](./contributing.md)

View File

@ -11,7 +11,7 @@ window.addEventListener("load", () => {
res.json()
).then(releases => {
selectElement.innerHTML = "";
for (const version of ['latest', ...releases.map(r => r.tag_name).filter(s => s !== "0.1.0")]) {
for (const version of ['latest', ...releases.map(r => r.tag_name).filter(s => s !== "0.1.0" && !s.startsWith("helm-"))]) {
const option = document.createElement("option");
option.innerHTML = version;
selectElement.add(option);

View File

@ -29,25 +29,30 @@
/* icons for headers */
.chapter > li:nth-child(3) > ol:nth-child(1) > li:nth-child(2) strong:after {
content: ' ' url('/matrix-hookshot/latest/icons/feeds.png')
content: ' ' url('/matrix-hookshot/latest/icons/feeds.png');
}
.chapter > li:nth-child(3) > ol:nth-child(1) > li:nth-child(3) strong:after {
content: ' ' url('/matrix-hookshot/latest/icons/figma.png')
content: ' ' url('/matrix-hookshot/latest/icons/figma.png');
}
.chapter > li:nth-child(3) > ol:nth-child(1) > li:nth-child(4) strong:after {
content: ' ' url('/matrix-hookshot/latest/icons/github.png')
content: ' ' url('/matrix-hookshot/latest/icons/github.png');
}
.chapter > li:nth-child(3) > ol:nth-child(1) > li:nth-child(5) strong:after {
content: ' ' url('/matrix-hookshot/latest/icons/gitlab.png')
content: ' ' url('/matrix-hookshot/latest/icons/gitlab.png');
}
.chapter > li:nth-child(3) > ol:nth-child(1) > li:nth-child(6) strong:after {
content: ' ' url('/matrix-hookshot/latest/icons/jira.png')
content: ' ' url('/matrix-hookshot/latest/icons/jira.png');
}
.chapter > li:nth-child(3) > ol:nth-child(1) > li:nth-child(7) strong:after {
content: ' ' url('/matrix-hookshot/latest/icons/webhooks.png')
content: ' ' url('/matrix-hookshot/latest/icons/webhooks.png');
}
.chapter li:nth-child(7) > a:nth-child(1) > strong:after {
content: ' ' url('/matrix-hookshot/latest/icons/sentry.png');
}

View File

@ -6,19 +6,18 @@ Encryption support is <strong>HIGHLY EXPERIMENTAL AND SUBJECT TO CHANGE</strong>
For more details, see <a href="https://github.com/matrix-org/matrix-hookshot/issues/594">issue 594</a>.
</section>
Hookshot supports end-to-bridge encryption via [MSC3202](https://github.com/matrix-org/matrix-spec-proposals/pull/3202). As such, encryption requires hookshot to be connected to a homeserver that supports that MSC, such as [Synapse](#running-with-synapse).
Hookshot supports end-to-bridge encryption via [MSC3202](https://github.com/matrix-org/matrix-spec-proposals/pull/3202). As such, encryption requires Hookshot to be connected to a homeserver that supports that MSC, such as [Synapse](#running-with-synapse).
## Enabling encryption in Hookshot
In order for hookshot to use encryption, it must be configured as follows:
- The `experimentalEncryption.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).
- By default, the crypto store uses an SQLite-based format. To use the legacy Sled-based format, set `experimentalEncryption.useLegacySledStore` to `true`, though this is not expected to be necessary and will be removed in a future release.
- Once a crypto store of a particular type (SQLite or Sled) 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`.
In order for Hookshot to use encryption, it must be configured as follows:
- The `experimentalEncryption.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).
- 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`.
- [Redis](./workers.md) must be enabled. Note that worker mode is not yet supported with encryption, so `queue.monolithic` must be set to `true`.
If you ever reset your homeserver's state, ensure you also reset hookshot's encryption state. This includes clearing the `experimentalEncryption.storagePath` directory and all worker state stored in your redis instance. Otherwise, hookshot may fail on start up with registration errors.
If you ever reset your homeserver's state, ensure you also reset Hookshot's encryption state. This includes clearing the `experimentalEncryption.storagePath` directory and all worker state stored in your redis instance. Otherwise, Hookshot may fail on start up with registration errors.
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.
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.
## Running with Synapse

View File

@ -20,7 +20,7 @@ For example with this configuration:
serviceBots:
- localpart: feeds
displayname: Feeds
avatar: mxc://half-shot.uk/2876e89ccade4cb615e210c458e2a7a6883fe17d
avatar: "./assets/feeds_avatar.png"
prefix: "!feeds"
service: feeds
```

View File

@ -56,14 +56,14 @@ When `addOnInvite` is true, the bridge will add a widget to rooms when the bot i
`disallowedIpRanges` describes which IP ranges should be disallowed when resolving homeserver IP addresses (for security reasons).
Unless you know what you are doing, it is recommended to not include this key. The default blocked IPs are listed above for your convenience.
`publicUrl` should be set to the publicly reachable address for the widget `public` content. By default, hookshot hosts this content on the
`publicUrl` should be set to the publicly reachable address for the widget `public` content. By default, Hookshot hosts this content on the
`widgets` listener under `/widgetapi/v1/static`.
`branding` allows you to change the strings used for various bits of widget UI. At the moment you can:
- Set `widgetTitle` to change the title of the widget that is created.
`openIdOverrides` allows you to configure the correct federation endpoints for a given set of Matrix server names. This is useful if you are
testing/developing hookshot in a local dev environment. Production environments should not use this configuration (as their Matrix server name
testing/developing Hookshot in a local dev environment. Production environments should not use this configuration (as their Matrix server name
should be resolvable). The config takes a mapping of Matrix server name => base path for federation.
E.g. if your server name was `my-local-server` and your federation was readable via http://localhost/_matrix/federation,
you would put configure `my-local-server: "http://localhost"`.

View File

@ -9,12 +9,11 @@ This feature is <b>experimental</b> and should only be used when you are reachin
## Running in multi-process mode
You must first have a working redis instance somewhere which can talk between processes. For example, in Docker you can run:
You must first have a working Redis instance somewhere which can talk between processes. For example, in Docker you can run:
`docker run --name github-bridge-redis -p 6379:6379 -d redis`.
The processes should all share the same config, which should contain the correct config enable redis:
The processes should all share the same config, which should contain the correct config to enable Redis:
```yaml
queue:

BIN
docs/icons/sentry.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 543 B

View File

@ -10,12 +10,14 @@ metrics:
port: 9002
```
Hookshot will then provide metrics on `127.0.0.1` at port `9002`.
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).
There are 3 variables at the top of the dashboard:
![image](https://user-images.githubusercontent.com/2803622/179366574-1bb83e30-05c6-4558-9e66-e813e85b3a6e.png)
Select the Prometheus 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)).
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)).
Below is the generated list of Prometheus metrics for Hookshot.
@ -24,29 +26,29 @@ Below is the generated list of Prometheus metrics for Hookshot.
| Metric | Help | Labels |
|--------|------|--------|
| hookshot_webhooks_http_request | Number of requests made to the hookshot webhooks handler | path, method |
| hookshot_provisioning_http_request | Number of requests made to the hookshot webhooks handler | path, method |
| hookshot_provisioning_http_request | Number of requests made to the hookshot provisioner handler | path, method |
| hookshot_queue_event_pushes | Number of events pushed through the queue | event |
| hookshot_connection_event_failed | The number of events that failed to process | event, connectionId |
| hookshot_connections | The number of active hookshot connections | service |
| hookshot_connection_event_failed | Number of events that failed to process | event, connectionId |
| hookshot_connections | Number of active hookshot connections | service |
| hookshot_notifications_push | Number of notifications pushed | service |
| hookshot_notifications_service_up | Is the notification service up or down | service |
| hookshot_notifications_service_up | Whether the notification service is up or down | service |
| hookshot_notifications_watchers | Number of notifications watchers running | service |
| hookshot_feeds_count | The number of RSS feeds that hookshot is subscribed to | |
| hookshot_feeds_fetch_ms | The time taken for hookshot to fetch all feeds | |
| hookshot_feeds_failing | The number of RSS feeds that hookshot is failing to read | reason |
| hookshot_feeds_count | Number of RSS feeds that hookshot is subscribed to | |
| hookshot_feeds_fetch_ms | Time taken for hookshot to fetch all feeds | |
| hookshot_feeds_failing | Number of RSS feeds that hookshot is failing to read | reason |
## matrix
| Metric | Help | Labels |
|--------|------|--------|
| matrix_api_calls | The number of Matrix client API calls made | method |
| matrix_api_calls_failed | The number of Matrix client API calls which failed | method |
| matrix_appservice_events | The number of events sent over the AS API | |
| matrix_appservice_decryption_failed | The number of events sent over the AS API that failed to decrypt | |
| matrix_api_calls | Number of Matrix client API calls made | method |
| matrix_api_calls_failed | Number of Matrix client API calls which failed | method |
| matrix_appservice_events | Number of events sent over the AS API | |
| matrix_appservice_decryption_failed | Number of events sent over the AS API that failed to decrypt | |
## feed
| Metric | Help | Labels |
|--------|------|--------|
| feed_count | (Deprecated) The number of RSS feeds that hookshot is subscribed to | |
| feed_fetch_ms | (Deprecated) The time taken for hookshot to fetch all feeds | |
| feed_failing | (Deprecated) The number of RSS feeds that hookshot is failing to read | reason |
| feed_count | (Deprecated) Number of RSS feeds that hookshot is subscribed to | |
| feed_fetch_ms | (Deprecated) Time taken for hookshot to fetch all feeds | |
| feed_failing | (Deprecated) Number of RSS feeds that hookshot is failing to read | reason |
## process
| Metric | Help | Labels |
|--------|------|--------|
@ -70,6 +72,8 @@ Below is the generated list of Prometheus metrics for Hookshot.
| nodejs_eventloop_lag_p50_seconds | The 50th percentile of the recorded event loop delays. | |
| nodejs_eventloop_lag_p90_seconds | The 90th percentile of the recorded event loop delays. | |
| nodejs_eventloop_lag_p99_seconds | The 99th percentile of the recorded event loop delays. | |
| nodejs_active_resources | Number of active resources that are currently keeping the event loop alive, grouped by async resource type. | type |
| nodejs_active_resources_total | Total number of active resources. | |
| nodejs_active_handles | Number of active libuv handles grouped by handle type. Every handle type is C++ class name. | type |
| nodejs_active_handles_total | Total number of active handles. | |
| nodejs_active_requests | Number of active libuv requests grouped by request type. Every request type is C++ class name. | type |

14
docs/sentry.md Normal file
View File

@ -0,0 +1,14 @@
Sentry
======
Hookshot supports [Sentry](https://sentry.io/welcome/) error reporting.
You can configure Sentry by adding the following to your config:
```yaml
sentry:
dsn: https://examplePublicKey@o0.ingest.sentry.io/0 # The DSN for your Sentry project.
environment: production # The environment sentry is being used in. Can be omitted.
```
Sentry will automatically include the name of your homeserver as the `serverName` reported.

View File

@ -5,13 +5,12 @@ This page explains how to set up Hookshot for use with a Matrix homeserver.
## Requirements
Hookshot is fairly light on resources, and can run in as low as 100MB or so of memory.
Hookshot is fairly light on resources, and can run in as low as 100 MB or so of memory.
Hookshot memory requirements may increase depending on the traffic and the number of rooms bridged.
You **must** have administrative access to an existing homeserver in order to set up Hookshot, as
Hookshot requires the homeserver to be configured with its appservice registration.
## Local installation
This bridge requires at least Node 16 and Rust installed.
@ -47,6 +46,11 @@ docker run \
Where `/etc/matrix-hookshot` would contain the configuration files `config.yml` and `registration.yml`. The `passKey` file should also be stored alongside these files. In your config, you should use the path `/data/passkey.pem`.
## Installation via Helm
There's now a basic chart defined in [helm/hookshot](/helm/hookshot/) that can be used to deploy the Hookshot Docker container in a Kubernetes-native way.
More information on this method is available [here](https://github.com/matrix-org/matrix-hookshot/helm/hookshot/README.md)
## Configuration
@ -56,16 +60,18 @@ Copy the `config.sample.yml` to a new file `config.yml`. The sample config is al
You should read and fill this in as the bridge will not start without a complete config.
You may validate your config without starting the service by running `yarn validate-config`.
For Docker you can run `docker run --rm -v /absolute-path-to/config.yml:/config.yml halfshot/matrix-hookshot node Config/Config.js /config.yml`
For Docker you can run `docker run --rm -v /absolute-path-to/config.yml:/config.yml halfshot/matrix-hookshot node config/Config.js /config.yml`
Copy `registration.sample.yml` into `registration.yml` and fill in:
- At a minimum, you will need to replace the `as_token` and `hs_token` and change the domain part of the namespaces. The sample config can be also found at our [github repo](https://raw.githubusercontent.com/matrix-org/matrix-hookshot/main/registration.sample.yml) for your convienence.
At a minimum, you will need to replace the `as_token` and `hs_token` and change the domain part of the namespaces. The sample config can be also found at our [github repo](https://raw.githubusercontent.com/matrix-org/matrix-hookshot/main/registration.sample.yml) for your convienence.
You will need to link the registration file to the homeserver. Consult your homeserver documentation
on how to add appservices. [Synapse documents the process here](https://matrix-org.github.io/synapse/latest/application_services.html).
### Homeserver Configuration
In addition to providing the registration file above, you also need to tell Hookshot how to reach the homeserver which is hosting it. For clarity, hookshot expects to be able to connect to an existing homeserver which has the Hookshot registration file configured.
In addition to providing the registration file above, you also need to tell Hookshot how to reach the homeserver which is hosting it. For clarity, Hookshot expects to be able to connect to an existing homeserver which has the Hookshot registration file configured.
```yaml
bridge:
@ -76,10 +82,9 @@ bridge:
bindAddress: 127.0.0.1 # The address which Hookshot will bind to. Docker users should set this to `0.0.0.0`.
```
The `port` and `bindAddress` must not conflict with the other listeners in the bridge config. This listeners should **not** be reachable
The `port` and `bindAddress` must not conflict with the other listeners in the bridge config. This listener should **not** be reachable
over the internet to users, as it's intended to be used by the homeserver exclusively. This service listens on `/_matrix/app/`.
### Permissions
The bridge supports fine grained permission control over what services a user can access.
@ -94,6 +99,7 @@ permissions:
```
You must configure a set of "actors" with access to services. An `actor` can be:
- A MxID (also known as a User ID) e.g. `"@Half-Shot:half-shot.uk"`
- A homeserver domain e.g. `matrix.org`
- A roomId. This will allow any member of this room to complete actions. e.g. `"!TlZdPIYrhwNvXlBiEk:half-shot.uk"`
@ -101,7 +107,8 @@ You must configure a set of "actors" with access to services. An `actor` can be:
MxIDs. room IDs and `*` **must** be wrapped in quotes.
Each permission set can have a services. The `service` field can be:
Each permission set can have a service. The `service` field can be:
- `github`
- `gitlab`
- `jira`
@ -111,13 +118,14 @@ Each permission set can have a services. The `service` field can be:
- `*`, for any service.
The `level` can be:
- `commands` Can run commands within connected rooms, but NOT log in to the bridge.
- `login` All the above, and can also log in to the bridge.
- `notifications` All the above, and can also bridge their notifications.
- `manageConnections` All the above, and can create and delete connections (either via the provisioner, setup commands, or state events).
- `admin` All permissions. This allows you to perform administrative tasks like deleting connections from all rooms.
When permissions are checked, if a user matches any of the permission set and one
- `commands` Can run commands within connected rooms, but NOT log in to the bridge.
- `login` All the above, and can also log in to the bridge.
- `notifications` All the above, and can also bridge their notifications.
- `manageConnections` All the above, and can create and delete connections (either via the provisioner, setup commands, or state events).
- `admin` All permissions. This allows you to perform administrative tasks like deleting connections from all rooms.
When permissions are checked, if a user matches any of the permissions set and one
of those grants the right level for a service, they are allowed access. If none of the
definitions match, they are denied.
@ -186,9 +194,21 @@ At a minimum, you should bind the `webhooks` resource to a port and address. You
port, or one on each. Each listener MUST listen on a unique port.
You will also need to make this port accessible to the internet so services like GitHub can reach the bridge. It
is recommended to factor hookshot into your load balancer configuration, but currently this process is left as an
is recommended to factor Hookshot into your load balancer configuration, but currently this process is left as an
exercise to the user.
However, if you use Nginx, have a look at this example:
```
location ~ ^/widgetapi(.*)$ {
set $backend "127.0.0.1:9002";
proxy_pass http://$backend/widgetapi$1$is_args$args;
}
```
This will pass all requests at `/widgetapi` to Hookshot.
In terms of API endpoints:
- The `webhooks` resource handles resources under `/`, so it should be on its own listener.
@ -202,7 +222,6 @@ Please note that the appservice HTTP listener is configured <strong>separately</
in the upstream library. See <a href="https://github.com/turt2live/matrix-bot-sdk/issues/191">this issue</a> for details.
</section>
### Services configuration
You will need to configure some services. Each service has its own documentation file inside the setup subdirectory.
@ -230,7 +249,6 @@ logging:
timestampFormat: HH:mm:ss:SSS
```
#### JSON Logging
Enabling the `json` option will configure hookshot to output structured JSON logs. The schema looks like:

View File

@ -19,40 +19,44 @@ Each feed will only be checked once, regardless of the number of rooms to which
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.
Please note that Hookshot **must** be configured with Redis to retain seen entries between restarts. By default, Hookshot will
run an "initial sync" on each startup and will not process any entries from feeds from before the first sync.
## Usage
### Adding new feeds
To add a feed to your room:
- Invite the bot user to the room.
- Make sure the bot able to send state events (usually the Moderator power level in clients)
- Say `!hookshot feed <URL>` where `<URL>` links to an RSS/Atom feed you want to subscribe to.
- Invite the bot user to the room.
- Make sure the bot able to send state events (usually the Moderator power level in clients)
- Say `!hookshot feed <URL>` where `<URL>` links to an RSS/Atom feed you want to subscribe to.
### Listing feeds
You can list all feeds that a room you're in is currently subscribed to with `!hookshot feed list`.
It requires no special permissions from the user issuing the command.
It requires no special permissions from the user issuing the command. Optionally you can format the list as `json` or
`yaml` with `!hookshot feed list <format>`.
### Removing feeds
To remove a feed from a room, say `!hookshot feed remove <URL>`, with the URL specifying which feed you want to unsubscribe from.
### Feed templates
You can optionally give a feed a specific template to use when sending a message into a room. A template
may include any of the following tokens:
|Token |Description |
|----------|--------------------------------------------|
|$FEEDNAME | Either the label, title or url of the feed.|
|$FEEDURL | The URL of the feed. |
|$FEEDTITLE| The title of the feed. |
|$TITLE | The title of the feed entry. |
|$LINK | The link of the feed entry. |
|$AUTHOR | The author of the feed entry. |
|$DATE | The publish date (`pubDate`) of the entry. |
|$SUMMARY | The summary of the entry. |
| Token | Description |
| ---------- | ---------------------------------------------------------- |
| $FEEDNAME | Either the label, title or url of the feed. |
| $FEEDURL | The URL of the feed. |
| $FEEDTITLE | The title of the feed. |
| $TITLE | The title of the feed entry. |
| $URL | The URL of the feed entry. |
| $LINK | The link of the feed entry. Formatted as `[$TITLE]($URL)`. |
| $AUTHOR | The author of the feed entry. |
| $DATE | The publish date (`pubDate`) of the entry. |
| $SUMMARY | The summary of the entry. |
If not specified, the default template is `New post in $FEEDNAME: $LINK`.

View File

@ -75,6 +75,7 @@ If the body *also* contains a `username` key, then the message will be prepended
If the body does NOT contain a `text` field, the full payload will be sent to the room. This can be adapted into a message by creating a **JavaScript transformation function**.
### Payload formats
If the request is a `POST`/`PUT`, the body of the request will be decoded and stored inside the event. Currently, Hookshot supports:
@ -101,6 +102,15 @@ to a string representation of that value. This change is <strong>not applied</st
variable, so it will contain proper float values.
</section>
### Wait for complete
It is possible to choose whether a webhook response should be instant, or after hookshot has handled the message. The reason
for this is that some services expect a quick response time (like Slack) whereas others will wait for the request to complete. You
can specify this either globally in your config, or on the widget with `waitForComplete`.
If you make use of the `webhookResponse` feature, you will need to enable `waitForComplete` as otherwise hookshot will
immeditately respond with it's default response values.
## JavaScript Transformations
<section class="notice">
@ -142,6 +152,11 @@ The `v2` api expects an object to be returned from the `result` variable.
"plain": "Some text", // The plaintext value to be used for the Matrix message.
"html": "<b>Some</b> text", // The HTML value to be used for the Matrix message. If not provided, plain will be interpreted as markdown.
"msgtype": "some.type", // The message type, such as m.notice or m.text, to be used for the Matrix message. If not provided, m.notice will be used.
"webhookResponse": { // Optional response to send to the webhook requestor. All fields are optional. Defaults listed.
"body": "{ \"ok\": true }",
"contentType": "application/json",
"statusCode": 200
}
}
```

View File

@ -20,8 +20,9 @@ To authenticate with a personal access token:
1. Click **Generate new token**
1. Give it a good name, and a sensible expiration date. For scopes you will need:
- Repo (to access repo information)
- public_repo
- repo:status
- If you want notifications for private repos, you need `repo: Full control of private repositories`. If you just want notifications for public repos, you only need:
- repo:status
- public_repo
- Workflow (if you want to be able to launch workflows / GitHub actions from Matrix)
- Notifications (if you want to bridge in your notifications to Matrix)
- User

View File

@ -48,6 +48,7 @@ the events marked as default below will be enabled. Otherwise, this is ignored.
- merge_request.open *
- merge_request.review.comments *
- merge_request.review *
- merge_request.review.individual
- push *
- release *
- release.created *

2
helm/cr.yaml Normal file
View File

@ -0,0 +1,2 @@
release-name-template: "helm-{{ .Name }}-{{ .Version }}"

6
helm/ct.yaml Normal file
View File

@ -0,0 +1,6 @@
remote: origin
target-branch: main
chart-repos: []
chart-dirs:
- helm
validate-maintainers: false

1
helm/hookshot/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
*.tgz

24
helm/hookshot/.helmignore Normal file
View File

@ -0,0 +1,24 @@
# Patterns to ignore when building packages.
# This supports shell glob matching, relative path matching, and
# negation (prefixed with !). Only one pattern per line.
.DS_Store
# Common VCS dirs
.git/
.gitignore
.bzr/
.bzrignore
.hg/
.hgignore
.svn/
# Common backup files
*.swp
*.bak
*.tmp
*.orig
*~
# Various IDEs
.project
.idea/
*.tmproj
.vscode/
*.tgz

7
helm/hookshot/.yamllint Normal file
View File

@ -0,0 +1,7 @@
---
extends: default
rules:
line-length:
level: warning
max: 120
braces: disable

22
helm/hookshot/Chart.yaml Normal file
View File

@ -0,0 +1,22 @@
---
apiVersion: v2
name: hookshot
description: Deploy a Matrix Hookshot instance to Kubernetes
# A chart can be either an 'application' or a 'library' chart.
#
# Application charts are a collection of templates that can be packaged into versioned archives
# to be deployed.
#
# Library charts provide useful utilities or functions for the chart developer. They're included as
# a dependency of application charts to inject those utilities and functions into the rendering
# pipeline. Library charts do not define any templates and therefore cannot be deployed.
type: application
# This is the chart version. This version number should be incremented each time you make changes
# to the chart and its templates, including the app version.
# Versions are expected to follow Semantic Versioning (https://semver.org/)
version: 0.1.15
# This is the version number of the application being deployed. This version number should be
# incremented each time you make changes to the application. Versions are not expected to
# follow Semantic Versioning. They should reflect the version the application is using.
# It is recommended to use it with quotes.
appVersion: "0.0.0-replaced-by-ci"

122
helm/hookshot/README.md Normal file
View File

@ -0,0 +1,122 @@
# hookshot
![Version: 0.1.13](https://img.shields.io/badge/Version-0.1.13-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 3.2.0](https://img.shields.io/badge/AppVersion-3.2.0-informational?style=flat-square)
Deploy a Matrix Hookshot instance to Kubernetes
Status: Beta
## About
This chart creates a basic Hookshot deployment inside Kubernetes.
# Installation
You'll need to have the Helm repository added to your local environment:
``` bash
helm repo add hookshot https://matrix-org.github.io/matrix-hookshot
helm repo update
```
Which should allow you to see the Hookshot chart in the repo:
``` bash
helm search repo hookshot
NAME CHART VERSION APP VERSION DESCRIPTION
matrix-org/hookshot 0.1.13 1.16.0 A Helm chart for Kubernetes
```
Before you can install, however, you'll need to make sure to configure Hookshot properly.
# Configuration
You'll need to create a `values.yaml` for your deployment of this chart. You can use the [included defaults](./values.yaml) as a starting point.
## Helm Values
To configure Hookshot-specific parameters, the value `.Values.hookshot.config` accepts an arbitrary YAML map as configuration. This gets templated into the container by [templates/configmap.yaml](./templates/configmap.yaml) - thus anything you can set in the [Example Configuration](https://matrix-org.github.io/matrix-hookshot/latest/setup/sample-configuration.html) can be set here.
## Existing configuration
If you have an existing configuration file for Hookshot, you can create a configmap like so:
``` bash
kubectl create --namespace "your hookshot namespace" configmap hookshot-custom-config --from-file=config.yml --from-file=registration.yml --from-file=passkey.pem
```
Note that the filenames must remain as listed based on the templating done in [templates/configmap.yaml](./templates/configmap.yaml)
Once created, you can set `.Values.hookshot.existingConfigMap` to `custom-hookshot-config` (or whichever name you chose for your secret) and set `.Values.hookshot.config` to `{}` or null to prevent confusion with the default parameters.
# Installation
Once you have your `values.yaml` file ready you can install the chart like this:
``` bash
helm install hookshot --create-namespace --namespace hookshot matrix-org/hookshot -f values.yaml
```
And upgrades can be done via:
``` bash
helm upgrade hookshot --namespace hookshot matrix-org/hookshot -f values.yaml
```
# External access
You'll need to configure your Ingress connectivity according to your environment. This chart should be compatible with most Ingress controllers and has been tested successfully with [ingress-nginx](https://github.com/kubernetes/ingress-nginx) and EKS ALB. You should also ensure that you have a way to provision certificates i.e. [cert-manager](https://cert-manager.io/) as HTTPS is required for appservice traffic.
## Values
| Key | Type | Default | Description |
|-----|------|---------|-------------|
| affinity | object | `{}` | Affinity settings for deployment |
| autoscaling.enabled | bool | `false` | |
| fullnameOverride | string | `""` | Full name override for helm chart |
| hookshot.config | object | `{"bridge":{"bindAddress":"0.0.0.0","domain":"example.com","port":9002,"url":"https://example.com"},"generic":{"allowJsTransformationFunctions":true,"enableHttpGet":false,"enabled":true,"urlPrefix":"https://example.com/","userIdPrefix":"_webhooks_","waitForComplete":false},"listeners":[{"bindAddress":"0.0.0.0","port":9000,"resources":["webhooks","widgets"]},{"bindAddress":"0.0.0.0","port":9001,"resources":["metrics"]}],"logging":{"colorize":false,"json":false,"level":"info","timestampFormat":"HH:mm:ss:SSS"},"metrics":{"enabled":true},"passFile":"/data/passkey.pem","widgets":{"addToAdminRooms":false,"branding":{"widgetTitle":"Hookshot Configuration"},"publicUrl":"https://webhook-hookshot.example.com/widgetapi/v1/static","roomSetupWidget":{"addOnInvite":false},"setRoomName":false}}` | Raw Hookshot configuration. Gets templated into a YAML file and then loaded unless an existingConfigMap is specified. |
| hookshot.existingConfigMap | string | `nil` | Name of existing ConfigMap with valid Hookshot configuration |
| hookshot.passkey | string | `""` | |
| hookshot.registration.as_token | string | `""` | |
| hookshot.registration.hs_token | string | `""` | |
| hookshot.registration.id | string | `"matrix-hookshot"` | |
| hookshot.registration.namespaces.rooms | list | `[]` | |
| hookshot.registration.namespaces.users | list | `[]` | |
| hookshot.registration.rate_limited | bool | `false` | |
| hookshot.registration.sender_localpart | string | `"hookshot"` | |
| hookshot.registration.url | string | `"http://example.com"` | |
| image.pullPolicy | string | `"IfNotPresent"` | Pull policy for Hookshot image |
| image.repository | string | `"halfshot/matrix-hookshot"` | Repository to pull hookshot image from |
| image.tag | string | `nil` | Image tag to pull. Defaults to chart's appVersion value as set in Chart.yaml |
| imagePullSecrets | list | `[]` | List of names of k8s secrets to be used as ImagePullSecrets for the pod |
| ingress.appservice.annotations | object | `{}` | Annotations for appservice ingress |
| ingress.appservice.className | string | `""` | Ingress class name for appservice ingress |
| ingress.appservice.enabled | bool | `false` | Enable ingress for appservice |
| ingress.appservice.hosts | list | `[]` | Host configuration for appservice ingress |
| ingress.appservice.tls | list | `[]` | TLS configuration for appservice ingress |
| ingress.webhook.annotations | object | `{}` | Annotations for webhook ingress |
| ingress.webhook.className | string | `""` | Ingress class name for webhook ingress |
| ingress.webhook.enabled | bool | `false` | Enable ingress for webhook |
| ingress.webhook.hosts | list | `[]` | Host configuration for webhook ingress |
| ingress.webhook.tls | list | `[]` | TLS configuration for webhook ingress |
| nameOverride | string | `""` | Name override for helm chart |
| nodeSelector | object | `{}` | Node selector parameters |
| podAnnotations | object | `{}` | Extra annotations for Hookshot pod |
| podSecurityContext | object | `{}` | Pod security context settings |
| replicaCount | int | `1` | Number of replicas to deploy. Consequences of using multiple Hookshot replicas currently unknown. |
| resources | object | `{}` | Pod resource requests / limits |
| securityContext | object | `{}` | Security context settings |
| service.annotations | object | `{}` | Extra annotations for service |
| service.appservice.port | int | `9002` | Appservice port as configured in container |
| service.labels | object | `{}` | Extra labels for service |
| service.metrics.port | int | `9001` | Metrics port as configured in container |
| service.port | int | `80` | Port for Hookshot service |
| service.type | string | `"ClusterIP"` | Service type for Hookshot service |
| service.webhook.port | int | `9000` | Webhook port as configured in container |
| serviceAccount.annotations | object | `{}` | Annotations to add to the service account |
| serviceAccount.create | bool | `true` | Specifies whether a service account should be created |
| serviceAccount.name | string | `""` | The name of the service account to use. If not set and create is true, a name is generated using the fullname template |
| tolerations | list | `[]` | Tolerations for deployment |
----------------------------------------------
Autogenerated from chart metadata using [helm-docs v1.11.0](https://github.com/norwoodj/helm-docs/releases/v1.11.0)

View File

@ -0,0 +1,74 @@
{{ template "chart.header" . }}
{{ template "chart.deprecationWarning" . }}
{{ template "chart.badgesSection" . }}
{{ template "chart.description" . }}
Status: Beta
## About
This chart creates a basic Hookshot deployment inside Kubernetes.
# Installation
You'll need to have the Helm repository added to your local environment:
``` bash
helm repo add hookshot https://matrix-org.github.io/matrix-hookshot
helm repo update
```
Which should allow you to see the Hookshot chart in the repo:
``` bash
helm search repo hookshot
NAME CHART VERSION APP VERSION DESCRIPTION
matrix-org/hookshot 0.1.13 1.16.0 A Helm chart for Kubernetes
```
Before you can install, however, you'll need to make sure to configure Hookshot properly.
# Configuration
You'll need to create a `values.yaml` for your deployment of this chart. You can use the [included defaults](./values.yaml) as a starting point.
## Helm Values
To configure Hookshot-specific parameters, the value `.Values.hookshot.config` accepts an arbitrary YAML map as configuration. This gets templated into the container by [templates/configmap.yaml](./templates/configmap.yaml) - thus anything you can set in the [Example Configuration](https://matrix-org.github.io/matrix-hookshot/latest/setup/sample-configuration.html) can be set here.
## Existing configuration
If you have an existing configuration file for hookshot, you can create a configmap like so:
``` bash
kubectl create --namespace "your hookshot namespace" configmap hookshot-custom-config --from-file=config.yml --from-file=registration.yml --from-file=passkey.pem
```
Note that the filenames must remain as listed based on the templating done in [templates/configmap.yaml](./templates/configmap.yaml)
Once created, you can set `.Values.hookshot.existingConfigMap` to `custom-hookshot-config` (or whichever name you chose for your secret) and set `.Values.hookshot.config` to `{}` or null to prevent confusion with the default parameters.
# Installation
Once you have your `values.yaml` file ready you can install the chart like this:
``` bash
helm install hookshot --create-namespace --namespace hookshot matrix-org/hookshot -f values.yaml
```
And upgrades can be done via:
``` bash
helm upgrade hookshot --namespace hookshot matrix-org/hookshot -f values.yaml
```
# External access
You'll need to configure your Ingress connectivity according to your environment. This chart should be compatible with most Ingress controllers and has been tested successfully with [ingress-nginx](https://github.com/kubernetes/ingress-nginx) and EKS ALB. You should also ensure that you have a way to provision certificates i.e. [cert-manager](https://cert-manager.io/) as HTTPS is required for appservice traffic.
{{ template "chart.maintainersSection" . }}
{{ template "chart.sourcesSection" . }}
{{ template "chart.requirementsSection" . }}
{{ template "chart.valuesSection" . }}
{{ template "helm-docs.versionFooter" . }}

View File

@ -0,0 +1,22 @@
1. Get the application URL by running these commands:
{{- if .Values.ingress.enabled }}
{{- range $host := .Values.ingress.hosts }}
{{- range .paths }}
http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}{{ .path }}
{{- end }}
{{- end }}
{{- else if contains "NodePort" .Values.service.type }}
export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "hookshot.fullname" . }})
export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}")
echo http://$NODE_IP:$NODE_PORT
{{- else if contains "LoadBalancer" .Values.service.type }}
NOTE: It may take a few minutes for the LoadBalancer IP to be available.
You can watch the status of by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "hookshot.fullname" . }}'
export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "hookshot.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}")
echo http://$SERVICE_IP:{{ .Values.service.port }}
{{- else if contains "ClusterIP" .Values.service.type }}
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}")
export CONTAINER_PORT=$(kubectl get pod --namespace {{ .Release.Namespace }} $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}")
echo "Visit http://127.0.0.1:8080 to use your application"
kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:$CONTAINER_PORT
{{- end }}

View File

@ -0,0 +1,85 @@
{{/*
Expand the name of the chart.
*/}}
{{- define "hookshot.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Create a default fully qualified app name.
We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
If release name contains chart name it will be used as a full name.
*/}}
{{- define "hookshot.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- $name := default .Chart.Name .Values.nameOverride }}
{{- if contains $name .Release.Name }}
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{- end }}
{{/*
Helper for configmap name
*/}}
{{- define "hookshot.configMapName" -}}
{{- if .Values.hookshot.existingConfigMap }}
{{- printf "%s" .Values.hookshot.existingConfigMap -}}
{{- else }}
{{- printf "%s-config" (include "hookshot.fullname" .) | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{/*
Create chart name and version as used by the chart label.
*/}}
{{- define "hookshot.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Common labels
*/}}
{{- define "hookshot.labels" -}}
helm.sh/chart: {{ include "hookshot.chart" . }}
{{ include "hookshot.selectorLabels" . }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}
{{/*
Selector labels
*/}}
{{- define "hookshot.selectorLabels" -}}
app.kubernetes.io/name: {{ include "hookshot.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
{{/*
Create the name of the service account to use
*/}}
{{- define "hookshot.serviceAccountName" -}}
{{- if .Values.serviceAccount.create }}
{{- default (include "hookshot.fullname" .) .Values.serviceAccount.name }}
{{- else }}
{{- default "default" .Values.serviceAccount.name }}
{{- end }}
{{- end }}
{{/*
Allow the release namespace to be overridden for multi-namespace deployments in combined charts
*/}}
{{- define "hookshot.namespace" -}}
{{- if .Values.namespaceOverride -}}
{{- .Values.namespaceOverride -}}
{{- else -}}
{{- .Release.Namespace -}}
{{- end -}}
{{- end -}}

View File

@ -0,0 +1,160 @@
{{- define "hookshot.pod" -}}
{{- if .Values.schedulerName }}
schedulerName: "{{ .Values.schedulerName }}"
{{- end }}
serviceAccountName: {{ template "hookshot.serviceAccountName" . }}
automountServiceAccountToken: {{ .Values.serviceAccount.autoMount }}
{{- if .Values.securityContext }}
securityContext:
{{ toYaml .Values.securityContext | indent 2 }}
{{- end }}
{{- if .Values.hostAliases }}
hostAliases:
{{ toYaml .Values.hostAliases | indent 2 }}
{{- end }}
{{- if .Values.priorityClassName }}
priorityClassName: {{ .Values.priorityClassName }}
{{- end }}
initContainers:
{{- if .Values.image.pullSecrets }}
imagePullSecrets:
{{- $root := . }}
{{- range .Values.image.pullSecrets }}
- name: {{ tpl . $root }}
{{- end}}
{{- end }}
containers:
- name: {{ .Chart.Name }}
{{- if .Values.image.sha }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}@sha256:{{ .Values.image.sha }}"
{{- else }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
{{- end }}
imagePullPolicy: {{ .Values.image.pullPolicy }}
{{- if .Values.command }}
command:
{{- range .Values.command }}
- {{ . }}
{{- end }}
{{- end}}
{{- if .Values.containerSecurityContext }}
securityContext:
{{- toYaml .Values.containerSecurityContext | nindent 6 }}
{{- end }}
volumeMounts:
{{- if or (and (not .Values.hookshot.existingConfigMap) (.Values.hookshot.config)) (.Values.hookshot.existingConfigMap) }}
- name: config
mountPath: "/data"
{{- end }}
ports:
- name: webhook
containerPort: 9000
protocol: TCP
- name: metrics
containerPort: 9001
protocol: TCP
- name: appservice
containerPort: 9002
protocol: TCP
env:
envFrom:
{{- if .Values.envFromSecret }}
- secretRef:
name: {{ tpl .Values.envFromSecret . }}
{{- end }}
{{- if .Values.envRenderSecret }}
- secretRef:
name: {{ template "hookshot.fullname" . }}-env
{{- end }}
{{- range .Values.envFromSecrets }}
- secretRef:
name: {{ tpl .name $ }}
optional: {{ .optional | default false }}
{{- end }}
{{- range .Values.envFromConfigMaps }}
- configMapRef:
name: {{ tpl .name $ }}
optional: {{ .optional | default false }}
{{- end }}
livenessProbe:
{{ toYaml .Values.livenessProbe | indent 6 }}
readinessProbe:
{{ toYaml .Values.readinessProbe | indent 6 }}
{{- if .Values.lifecycleHooks }}
lifecycle: {{ tpl (.Values.lifecycleHooks | toYaml) . | nindent 6 }}
{{- end }}
resources:
{{ toYaml .Values.resources | indent 6 }}
{{- with .Values.extraContainers }}
{{ tpl . $ | indent 2 }}
{{- end }}
{{- with .Values.nodeSelector }}
nodeSelector:
{{ toYaml . | indent 2 }}
{{- end }}
{{- $root := . }}
{{- with .Values.affinity }}
affinity:
{{ tpl (toYaml .) $root | indent 2 }}
{{- end }}
{{- with .Values.topologySpreadConstraints }}
topologySpreadConstraints:
{{ toYaml . | indent 2 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{ toYaml . | indent 2 }}
{{- end }}
volumes:
- name: config
configMap:
name: {{ template "hookshot.configMapName" . }}
{{- $root := . }}
{{- range .Values.extraConfigmapMounts }}
- name: {{ tpl .name $root }}
configMap:
name: {{ tpl .configMap $root }}
{{- if .items }}
items: {{ toYaml .items | nindent 6 }}
{{- end }}
{{- end }}
{{- range .Values.extraSecretMounts }}
{{- if .secretName }}
- name: {{ .name }}
secret:
secretName: {{ .secretName }}
defaultMode: {{ .defaultMode }}
{{- if .items }}
items: {{ toYaml .items | nindent 6 }}
{{- end }}
{{- else if .projected }}
- name: {{ .name }}
projected: {{- toYaml .projected | nindent 6 }}
{{- else if .csi }}
- name: {{ .name }}
csi: {{- toYaml .csi | nindent 6 }}
{{- end }}
{{- end }}
{{- range .Values.extraVolumeMounts }}
- name: {{ .name }}
{{- if .existingClaim }}
persistentVolumeClaim:
claimName: {{ .existingClaim }}
{{- else if .hostPath }}
hostPath:
path: {{ .hostPath }}
{{- else }}
emptyDir: {}
{{- end }}
{{- end }}
{{- range .Values.extraEmptyDirMounts }}
- name: {{ .name }}
emptyDir: {}
{{- end -}}
{{- if .Values.extraContainerVolumes }}
{{ tpl (toYaml .Values.extraContainerVolumes) . | indent 2 }}
{{- end }}
{{- end }}

View File

@ -0,0 +1,21 @@
---
{{- if not .Values.hookshot.existingConfigMap }}
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ template "hookshot.configMapName" . }}
namespace: {{ template "hookshot.namespace" . }}
labels:
{{- include "hookshot.labels" . | nindent 4 }}
{{- with .Values.annotations }}
annotations:
{{ toYaml . | indent 4 }}
{{- end }}
data:
config.yml: |
{{ toYaml .Values.hookshot.config | indent 4 }}
registration.yml: |
{{ toYaml .Values.hookshot.registration | indent 4 }}
passkey.pem: |
{{ .Values.hookshot.passkey | indent 4 }}
{{- end }}

View File

@ -0,0 +1,24 @@
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "hookshot.fullname" . }}
labels:
{{- include "hookshot.labels" . | nindent 4 }}
spec:
{{- if not .Values.autoscaling.enabled }}
replicas: {{ .Values.replicaCount }}
{{- end }}
selector:
matchLabels:
{{- include "hookshot.selectorLabels" . | nindent 6 }}
template:
metadata:
{{- with .Values.podAnnotations }}
annotations:
{{- toYaml . | nindent 8 }}
{{- end }}
labels:
{{- include "hookshot.selectorLabels" . | nindent 8 }}
spec:
{{- include "hookshot.pod" . | nindent 6 }}

View File

@ -0,0 +1,29 @@
---
{{- if .Values.autoscaling.enabled }}
apiVersion: autoscaling/v2beta1
kind: HorizontalPodAutoscaler
metadata:
name: {{ include "hookshot.fullname" . }}
labels:
{{- include "hookshot.labels" . | nindent 4 }}
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: {{ include "hookshot.fullname" . }}
minReplicas: {{ .Values.autoscaling.minReplicas }}
maxReplicas: {{ .Values.autoscaling.maxReplicas }}
metrics:
{{- if .Values.autoscaling.targetCPUUtilizationPercentage }}
- type: Resource
resource:
name: cpu
targetAverageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }}
{{- end }}
{{- if .Values.autoscaling.targetMemoryUtilizationPercentage }}
- type: Resource
resource:
name: memory
targetAverageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }}
{{- end }}
{{- end }}

View File

@ -0,0 +1,62 @@
---
{{- if .Values.ingress.appservice.enabled -}}
{{- $fullName := include "hookshot.fullname" . -}}
{{- $svcPort := .Values.service.port -}}
{{- if and .Values.ingress.appservice.className (not (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion)) }}
{{- if not (hasKey .Values.ingress.appservice.annotations "kubernetes.io/ingress.class") }}
{{- $_ := set .Values.ingress.appservice.annotations "kubernetes.io/ingress.class" .Values.ingress.appservice.className}}
{{- end }}
{{- end }}
{{- if semverCompare ">=1.19-0" .Capabilities.KubeVersion.GitVersion -}}
apiVersion: networking.k8s.io/v1
{{- else if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}}
apiVersion: networking.k8s.io/v1beta1
{{- else -}}
apiVersion: extensions/v1beta1
{{- end }}
kind: Ingress
metadata:
name: {{ $fullName }}-appservice
labels:
{{- include "hookshot.labels" . | nindent 4 }}
{{- with .Values.ingress.appservice.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- if and .Values.ingress.appservice.className (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion) }}
ingressClassName: {{ .Values.ingress.appservice.className }}
{{- end }}
{{- if .Values.ingress.appservice.tls }}
tls:
{{- range .Values.ingress.appservice.tls }}
- hosts:
{{- range .hosts }}
- {{ . | quote }}
{{- end }}
secretName: {{ .secretName }}
{{- end }}
{{- end }}
rules:
{{- range .Values.ingress.appservice.hosts }}
- host: {{ .host | quote }}
http:
paths:
{{- range .paths }}
- path: {{ .path }}
{{- if and .pathType (semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion) }}
pathType: {{ .pathType }}
{{- end }}
backend:
{{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }}
service:
name: {{ $fullName }}
port:
number: {{ .port }}
{{- else }}
serviceName: {{ $fullName }}
servicePort: {{ .port }}
{{- end }}
{{- end }}
{{- end }}
{{- end }}

View File

@ -0,0 +1,62 @@
---
{{- if .Values.ingress.webhook.enabled -}}
{{- $fullName := include "hookshot.fullname" . -}}
{{- $svcPort := .Values.service.port -}}
{{- if and .Values.ingress.webhook.className (not (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion)) }}
{{- if not (hasKey .Values.ingress.webhook.annotations "kubernetes.io/ingress.class") }}
{{- $_ := set .Values.ingress.webhook.annotations "kubernetes.io/ingress.class" .Values.ingress.webhook.className}}
{{- end }}
{{- end }}
{{- if semverCompare ">=1.19-0" .Capabilities.KubeVersion.GitVersion -}}
apiVersion: networking.k8s.io/v1
{{- else if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}}
apiVersion: networking.k8s.io/v1beta1
{{- else -}}
apiVersion: extensions/v1beta1
{{- end }}
kind: Ingress
metadata:
name: {{ $fullName }}
labels:
{{- include "hookshot.labels" . | nindent 4 }}
{{- with .Values.ingress.webhook.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- if and .Values.ingress.webhook.className (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion) }}
ingressClassName: {{ .Values.ingress.webhook.className }}
{{- end }}
{{- if .Values.ingress.webhook.tls }}
tls:
{{- range .Values.ingress.webhook.tls }}
- hosts:
{{- range .hosts }}
- {{ . | quote }}
{{- end }}
secretName: {{ .secretName }}
{{- end }}
{{- end }}
rules:
{{- range .Values.ingress.webhook.hosts }}
- host: {{ .host | quote }}
http:
paths:
{{- range .paths }}
- path: {{ .path }}
{{- if and .pathType (semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion) }}
pathType: {{ .pathType }}
{{- end }}
backend:
{{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }}
service:
name: {{ $fullName }}
port:
number: {{ .port }}
{{- else }}
serviceName: {{ $fullName }}
servicePort: {{ .port }}
{{- end }}
{{- end }}
{{- end }}
{{- end }}

View File

@ -0,0 +1,31 @@
---
apiVersion: v1
kind: Service
metadata:
name: {{ include "hookshot.fullname" . }}
{{- with .Values.service.annotations }}
annotations:
{{ toYaml . | indent 4 }}
{{- end }}
labels:
{{- include "hookshot.labels" . | nindent 4 }}
{{- with .Values.service.labels }}
{{ toYaml . | indent 4 }}
{{- end }}
spec:
type: {{ .Values.service.type }}
ports:
- port: {{ .Values.service.webhook.port }}
targetPort: webhook
protocol: TCP
name: webhook
- port: {{ .Values.service.metrics.port }}
targetPort: metrics
protocol: TCP
name: metrics
- port: {{ .Values.service.appservice.port }}
targetPort: appservice
protocol: TCP
name: appservice
selector:
{{- include "hookshot.selectorLabels" . | nindent 4 }}

View File

@ -0,0 +1,12 @@
{{- if .Values.serviceAccount.create -}}
apiVersion: v1
kind: ServiceAccount
metadata:
name: {{ include "hookshot.serviceAccountName" . }}
labels:
{{- include "hookshot.labels" . | nindent 4 }}
{{- with .Values.serviceAccount.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
{{- end }}

View File

@ -0,0 +1,16 @@
---
apiVersion: v1
kind: Pod
metadata:
name: "{{ include "hookshot.fullname" . }}-test-connection"
labels:
{{- include "hookshot.labels" . | nindent 4 }}
annotations:
"helm.sh/hook": test
spec:
containers:
- name: wget
image: busybox
command: ['wget']
args: ['{{ include "hookshot.fullname" . }}:{{ .Values.service.webhook.port }}']
restartPolicy: Never

314
helm/hookshot/values.yaml Normal file
View File

@ -0,0 +1,314 @@
---
# Note: This chart is released using the config.sample.yml file
#
# -- Number of replicas to deploy. Consequences of using multiple Hookshot replicas currently unknown.
replicaCount: 1
image:
# -- Repository to pull hookshot image from
repository: halfshot/matrix-hookshot
# -- Pull policy for Hookshot image
pullPolicy: IfNotPresent
# -- Image tag to pull. Defaults to chart's appVersion value as set in Chart.yaml
tag:
# -- List of names of k8s secrets to be used as ImagePullSecrets for the pod
imagePullSecrets: []
# -- Name override for helm chart
nameOverride: ""
# -- Full name override for helm chart
fullnameOverride: ""
serviceAccount:
# -- Specifies whether a service account should be created
create: true
# -- Annotations to add to the service account
annotations: {}
# -- The name of the service account to use. If not set and create is true, a name is generated using the fullname template
name: ""
# -- Extra annotations for Hookshot pod
podAnnotations: {}
# -- Pod security context settings
podSecurityContext: {}
# fsGroup: 2000
# -- Security context settings
securityContext: {}
# capabilities:
# drop:
# - ALL
# readOnlyRootFilesystem: true
# runAsNonRoot: true
# runAsUser: 1000
service:
# -- Service type for Hookshot service
type: ClusterIP
# -- Port for Hookshot service
port: 80
# -- Extra annotations for service
annotations: {}
# -- Extra labels for service
labels: {}
webhook:
# -- Webhook port as configured in container
port: 9000
metrics:
# -- Metrics port as configured in container
port: 9001
appservice:
# -- Appservice port as configured in container
port: 9002
ingress:
webhook:
# -- Enable ingress for webhook
enabled: false
# -- Ingress class name for webhook ingress
className: ""
# -- Annotations for webhook ingress
annotations: {}
# -- Host configuration for webhook ingress
hosts: []
# -- TLS configuration for webhook ingress
tls: []
appservice:
# -- Enable ingress for appservice
enabled: false
# -- Ingress class name for appservice ingress
className: ""
# -- Annotations for appservice ingress
annotations: {}
# -- Host configuration for appservice ingress
hosts: []
# -- TLS configuration for appservice ingress
tls: []
# -- Pod resource requests / limits
resources: {}
# We usually recommend not to specify default resources and to leave this as a conscious
# choice for the user. This also increases chances charts run on environments with little
# resources, such as Minikube. If you do want to specify resources, uncomment the following
# lines, adjust them as necessary, and remove the curly braces after 'resources:'.
# limits:
# cpu: 100m
# memory: 128Mi
# requests:
# cpu: 100m
# memory: 128Mi
autoscaling:
enabled: false
# -- Node selector parameters
nodeSelector: {}
# -- Tolerations for deployment
tolerations: []
# -- Affinity settings for deployment
affinity: {}
hookshot:
# -- Name of existing ConfigMap with valid Hookshot configuration
existingConfigMap:
# -- Raw Hookshot configuration. Gets templated into a YAML file and then loaded unless an existingConfigMap is specified.
config:
bridge:
# Basic homeserver configuration
#
domain: example.com
url: http://localhost:8008
mediaUrl: https://example.com
port: 9993
bindAddress: 127.0.0.1
# github:
# (Optional) Configure this to enable GitHub support
#
# auth:
# Authentication for the GitHub App.
#
# id: 123
# privateKeyFile: github-key.pem
# webhook:
# Webhook settings for the GitHub app.
#
# secret: secrettoken
# oauth:
# (Optional) Settings for allowing users to sign in via OAuth.
#
# client_id: foo
# client_secret: bar
# redirect_uri: https://example.com/bridge_oauth/
# defaultOptions:
# (Optional) Default options for GitHub connections.
#
# showIssueRoomLink: false
# hotlinkIssues:
# prefix: "#"
# userIdPrefix: _github_
# (Optional) Prefix used when creating ghost users for GitHub accounts.
#
# gitlab:
# (Optional) Configure this to enable GitLab support
#
# instances:
# gitlab.com:
# url: https://gitlab.com
# webhook:
# secret: secrettoken
# publicUrl: https://example.com/hookshot/
# userIdPrefix: _gitlab_
# (Optional) Prefix used when creating ghost users for GitLab accounts.
#
# figma:
# (Optional) Configure this to enable Figma support
#
# publicUrl: https://example.com/hookshot/
# instances:
# your-instance:
# teamId: your-team-id
# accessToken: your-personal-access-token
# passcode: your-webhook-passcode
# jira:
# (Optional) Configure this to enable Jira support. Only specify `url` if you are using a On Premise install (i.e. not atlassian.com)
#
# webhook:
# Webhook settings for JIRA
#
# secret: secrettoken
# oauth:
# (Optional) OAuth settings for connecting users to JIRA. See documentation for more information
#
# client_id: foo
# client_secret: bar
# redirect_uri: https://example.com/bridge_oauth/
generic:
# (Optional) Support for generic webhook events.
#'allowJsTransformationFunctions' will allow users to write short transformation snippets in code, and thus is unsafe in untrusted environments
#
#
enabled: false
enableHttpGet: false
urlPrefix: https://example.com/webhook/
userIdPrefix: _webhooks_
allowJsTransformationFunctions: false
waitForComplete: false
feeds:
# (Optional) Configure this to enable RSS/Atom feed support
#
enabled: false
pollIntervalSeconds: 600
pollTimeoutSeconds: 30
# provisioning:
# (Optional) Provisioning API for integration managers
#
# secret: "!secretToken"
passFile: passkey.pem
# A passkey used to encrypt tokens stored inside the bridge.
# Run openssl genpkey -out passkey.pem -outform PEM -algorithm RSA -pkeyopt rsa_keygen_bits:4096 to generate
#
# bot:
# (Optional) Define profile information for the bot user
#
# displayname: Hookshot Bot
# avatar: mxc://half-shot.uk/2876e89ccade4cb615e210c458e2a7a6883fe17d
# serviceBots:
# (Optional) Define additional bot users for specific services
#
# - localpart: feeds
# displayname: Feeds
# avatar: mxc://half-shot.uk/2876e89ccade4cb615e210c458e2a7a6883fe17d
# prefix: "!feeds"
# service: feeds
metrics:
# (Optional) Prometheus metrics support
#
enabled: true
# queue:
# (Optional) Message queue / cache configuration options for large scale deployments.
# For encryption to work, must be set to monolithic mode and have a host & port specified.
#
# monolithic: true
# port: 6379
# host: localhost
logging:
# (Optional) Logging settings. You can have a severity debug,info,warn,error
#
level: info
colorize: true
json: false
timestampFormat: HH:mm:ss:SSS
# widgets:
# (Optional) EXPERIMENTAL support for complimentary widgets
#
# addToAdminRooms: false
# disallowedIpRanges:
# - 127.0.0.0/8
# - 10.0.0.0/8
# - 172.16.0.0/12
# - 192.168.0.0/16
# - 100.64.0.0/10
# - 192.0.0.0/24
# - 169.254.0.0/16
# - 192.88.99.0/24
# - 198.18.0.0/15
# - 192.0.2.0/24
# - 198.51.100.0/24
# - 203.0.113.0/24
# - 224.0.0.0/4
# - ::1/128
# - fe80::/10
# - fc00::/7
# - 2001:db8::/32
# - ff00::/8
# - fec0::/10
# roomSetupWidget:
# addOnInvite: false
# publicUrl: https://example.com/widgetapi/v1/static/
# branding:
# widgetTitle: Hookshot Configuration
# permissions:
# (Optional) Permissions for using the bridge. See docs/setup.md#permissions for help
#
# - actor: example.com
# services:
# - service: "*"
# level: admin
listeners:
# (Optional) HTTP Listener configuration.
# Bind resource endpoints to ports and addresses.
# 'port' must be specified. Each listener must listen on a unique port.
# 'bindAddress' will default to '127.0.0.1' if not specified, which may not be suited to Docker environments.
# 'resources' may be any of webhooks, widgets, metrics, provisioning
#
- port: 9000
bindAddress: 0.0.0.0
resources:
- webhooks
- port: 9001
bindAddress: 127.0.0.1
resources:
- metrics
- provisioning
- port: 9002
bindAddress: 0.0.0.0
resources:
- widgets
registration:
id: matrix-hookshot
as_token: ""
hs_token: ""
namespaces:
rooms: []
users: []
sender_localpart: hookshot
url: "http://example.com"
rate_limited: false
passkey: ""

21
jest.config.ts Normal file
View File

@ -0,0 +1,21 @@
/**
* For a detailed explanation regarding each configuration property, visit:
* https://jestjs.io/docs/configuration
*/
import type {Config} from 'jest';
const config: Config = {
// The root directory that Jest should scan for tests and modules within
rootDir: "spec",
testTimeout: 60000,
transform: {
'^.+\\.tsx?$': [
'ts-jest',
{
},
],
},
};
export default config;

View File

@ -1,6 +1,6 @@
{
"name": "matrix-hookshot",
"version": "4.0.0",
"version": "5.1.2",
"description": "A bridge between Matrix and multiple project management services, such as GitHub, GitLab and JIRA.",
"main": "lib/app.js",
"repository": "https://github.com/matrix-org/matrix-hookshot",
@ -10,7 +10,7 @@
"name": "matrix-hookshot-rs"
},
"engines": {
"node": ">=18"
"node": ">=20"
},
"scripts": {
"build:web": "vite build",
@ -32,80 +32,88 @@
"start:matrixsender": "node --require source-map-support/register lib/App/MatrixSenderApp.js",
"start:resetcrypto": "node --require source-map-support/register lib/App/ResetCryptoStore.js",
"test": "mocha -r ts-node/register tests/init.ts tests/*.ts tests/**/*.ts",
"test:e2e": "yarn node --experimental-vm-modules $(yarn bin jest)",
"test:cover": "nyc --reporter=lcov --reporter=text yarn test",
"lint": "yarn run lint:js && yarn run lint:rs",
"lint:js": "eslint -c .eslintrc.js 'src/**/*.ts' 'tests/**/*.ts' 'web/**/*.ts' 'web/**/*.tsx'",
"lint:rs": "cargo fmt --all -- --check",
"lint:rs:apply": "cargo fmt --all",
"generate-default-config": "ts-node src/Config/Defaults.ts --config > config.sample.yml",
"validate-config": "ts-node src/Config/Config.ts"
"lint:rs": "cargo fmt --all -- --check && cargo clippy -- -Dwarnings",
"lint:rs:apply": "cargo clippy --fix && cargo fmt --all",
"generate-default-config": "ts-node src/config/Defaults.ts --config > config.sample.yml",
"validate-config": "ts-node src/config/Config.ts"
},
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
"@octokit/auth-app": "^3.3.0",
"@octokit/auth-token": "^2.4.5",
"@octokit/rest": "^18.10.0",
"@octokit/webhooks": "^9.1.2",
"@octokit/auth-app": "^6.0.2",
"@octokit/auth-token": "^4.0.0",
"@octokit/rest": "^20.0.2",
"@octokit/webhooks": "^12.0.10",
"@sentry/node": "^7.52.1",
"@vector-im/compound-design-tokens": "^0.1.0",
"@vector-im/compound-web": "^0.9.4",
"ajv": "^8.11.0",
"axios": "^0.24.0",
"axios": "^1.6.3",
"cors": "^2.8.5",
"express": "^4.17.3",
"express": "^4.18.2",
"figma-js": "^1.14.0",
"http-status-codes": "^2.2.0",
"ioredis": "^5.2.3",
"jira-client": "^8.0.0",
"markdown-it": "^12.3.2",
"matrix-appservice-bridge": "^9.0.0",
"matrix-bot-sdk": "npm:@vector-im/matrix-bot-sdk@^0.6.6-element.1",
"matrix-widget-api": "^1.0.0",
"micromatch": "^4.0.4",
"mime": "^3.0.0",
"node-emoji": "^1.11.0",
"nyc": "^15.1.0",
"jira-client": "^8.2.2",
"markdown-it": "^14.0.0",
"matrix-appservice-bridge": "^9.0.1",
"matrix-bot-sdk": "npm:@vector-im/matrix-bot-sdk@^0.7.0-specific-device-2",
"matrix-widget-api": "^1.6.0",
"micromatch": "^4.0.5",
"mime": "^4.0.1",
"node-emoji": "^2.1.3",
"p-queue": "^6.6.2",
"prom-client": "^14.0.1",
"reflect-metadata": "^0.1.13",
"preact-render-to-string": "^6.3.1",
"prom-client": "^15.1.0",
"quickjs-emscripten": "^0.26.0",
"reflect-metadata": "^0.2.1",
"source-map-support": "^0.5.21",
"string-argv": "^0.3.1",
"tiny-typed-emitter": "^2.1.0",
"vm2": "^3.9.17",
"winston": "^3.3.3",
"xml2js": "^0.5.0",
"yaml": "^2.2.2"
"vite-plugin-magical-svg": "^1.1.1",
"winston": "^3.11.0",
"xml2js": "^0.6.2",
"yaml": "^2.3.4"
},
"devDependencies": {
"@codemirror/lang-javascript": "^6.0.2",
"@napi-rs/cli": "^2.2.0",
"@napi-rs/cli": "^2.13.2",
"@preact/preset-vite": "^2.2.0",
"@tsconfig/node18": "^2.0.0",
"@rollup/plugin-alias": "^5.1.0",
"@tsconfig/node18": "^18.2.2",
"@types/ajv": "^1.0.0",
"@types/chai": "^4.2.22",
"@types/cors": "^2.8.12",
"@types/express": "^4.17.14",
"@types/jest": "^29.5.11",
"@types/jira-client": "^7.1.0",
"@types/markdown-it": "^12.2.3",
"@types/markdown-it": "^13.0.7",
"@types/micromatch": "^4.0.1",
"@types/mime": "^2.0.3",
"@types/mocha": "^9.0.0",
"@types/node-emoji": "^1.8.1",
"@types/uuid": "^8.3.3",
"@types/mime": "^3.0.4",
"@types/mocha": "^10.0.6",
"@types/node": "20.10.6",
"@types/xml2js": "^0.4.11",
"@types/node": "18",
"@typescript-eslint/eslint-plugin": "^5.4.0",
"@typescript-eslint/parser": "^5.4.0",
"@typescript-eslint/eslint-plugin": "^6.17.0",
"@typescript-eslint/parser": "^6.17.0",
"@uiw/react-codemirror": "^4.12.3",
"chai": "^4.3.4",
"eslint": "^8.3.0",
"eslint": "^8.49.0",
"eslint-config-preact": "^1.3.0",
"eslint-plugin-mocha": "^9.0.0",
"eslint-plugin-mocha": "^10.1.0",
"homerunner-client": "^1.0.0",
"jest": "^29.7.0",
"mini.css": "^3.0.1",
"mocha": "^8.2.1",
"mocha": "^10.2.0",
"nyc": "^15.1.0",
"preact": "^10.5.15",
"rimraf": "^3.0.2",
"sass": "^1.51.0",
"rimraf": "^5.0.5",
"sass": "^1.69.6",
"ts-jest": "^29.1.1",
"ts-node": "^10.9.1",
"typescript": "^5.0.4",
"vite": "^4.1.4",
"vite-svg-loader": "^4.0.0"
"typescript": "^5.3.3",
"vite": "^5.0.10"
}
}

View File

@ -35,12 +35,14 @@ metrics:
port: 9002
\`\`\`
Hookshot will then provide metrics on \`127.0.0.1\` at port \`9002\`.
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).
There are 3 variables at the top of the dashboard:
![image](https://user-images.githubusercontent.com/2803622/179366574-1bb83e30-05c6-4558-9e66-e813e85b3a6e.png)
Select the Prometheus 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)).
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)).
Below is the generated list of Prometheus metrics for Hookshot.

View File

@ -3,23 +3,41 @@
if ! command -v jq &> /dev/null
then
echo "You must install jq to use this script"
exit
echo "You must install jq to use this script" >&2
exit 1
fi
VERSION=`jq -r .version package.json`
VERSION=`jq -r .version <(git show :package.json)`
function parseCargoVersion {
awk '$1 == "version" {gsub("\"", "", $3); print $3}' $1
}
CARGO_TOML_VERSION=`parseCargoVersion <(git show :Cargo.toml)`
if [[ $VERSION != $CARGO_TOML_VERSION ]]; then
echo "Node & Rust package versions do not match." >&2
echo "Node version (package.json): ${VERSION}" >&2
echo "Rust version (Cargo.toml): ${CARGO_TOML_VERSION}" >&2
exit 2
fi
CARGO_LOCK_VERSION=`parseCargoVersion <(grep -A1 matrix-hookshot <(git show :Cargo.lock))`
if [[ $CARGO_TOML_VERSION != $CARGO_LOCK_VERSION ]]; then
echo "Rust package version does not match the lockfile." >&2
echo "Rust version (Cargo.toml): ${CARGO_TOML_VERSION}" >&2
echo "Lockfile version (Cargo.lock): ${CARGO_LOCK_VERSION}" >&2
exit 3
fi
TAG="$VERSION"
HEAD_BRANCH=`git remote show origin | sed -n '/HEAD branch/s/.*: //p'`
REPO_NAME=`git remote show origin -n | grep -m 1 -oP '(?<=git@github.com:)(.*)(?=.git)'`
if [[ "`git branch --show-current`" != $HEAD_BRANCH ]]; then
echo "You must be on the develop branch to run this command."
exit 1
echo "You must be on the $HEAD_BRANCH branch to run this command." >&2
exit 4
fi
if [ $(git tag -l "$TAG") ]; then
echo "Tag $TAG already exists, not continuing."
exit 1
echo "Tag $TAG already exists, not continuing." >&2
exit 5
fi
echo "Drafting a new release"

62
spec/basic.spec.ts Normal file
View File

@ -0,0 +1,62 @@
import { MessageEventContent } from "matrix-bot-sdk";
import { E2ESetupTestTimeout, E2ETestEnv } from "./util/e2e-test";
import { describe, it, beforeEach, afterEach } from "@jest/globals";
import { expect } from "chai";
describe('Basic test setup', () => {
let testEnv: E2ETestEnv;
beforeEach(async () => {
testEnv = await E2ETestEnv.createTestEnv({matrixLocalparts: ['user']});
await testEnv.setUp();
}, E2ESetupTestTimeout);
afterEach(() => {
return testEnv?.tearDown();
});
it('should be able to invite the bot to a room', async () => {
const user = testEnv.getUser('user');
const roomId = await user.createRoom({ name: 'Test room', invite:[testEnv.botMxid] });
await user.waitForRoomJoin({sender: testEnv.botMxid, roomId });
await user.sendText(roomId, "!hookshot help");
const msg = await user.waitForRoomEvent<MessageEventContent>({
eventType: 'm.room.message', sender: testEnv.botMxid, roomId
});
// Expect help text.
expect(msg.data.content.body).to.include('!hookshot help` - This help text\n');
});
// TODO: Move test to it's own generic connections file.
it('should be able to setup a webhook', async () => {
const user = testEnv.getUser('user');
const testRoomId = await user.createRoom({ name: 'Test room', invite:[testEnv.botMxid] });
await user.waitForRoomJoin({sender: testEnv.botMxid, roomId: testRoomId });
await user.setUserPowerLevel(testEnv.botMxid, testRoomId, 50);
await user.sendText(testRoomId, "!hookshot webhook test-webhook");
const inviteResponse = await user.waitForRoomInvite({sender: testEnv.botMxid});
await user.waitForRoomEvent<MessageEventContent>({
eventType: 'm.room.message', sender: testEnv.botMxid, roomId: testRoomId,
body: 'Room configured to bridge webhooks. See admin room for secret url.'
});
const webhookUrlMessage = user.waitForRoomEvent<MessageEventContent>({
eventType: 'm.room.message', sender: testEnv.botMxid, roomId: inviteResponse.roomId
});
await user.joinRoom(inviteResponse.roomId);
const msgData = (await webhookUrlMessage).data.content.body;
const webhookUrl = msgData.split('\n')[2];
const webhookNotice = user.waitForRoomEvent<MessageEventContent>({
eventType: 'm.room.message', sender: testEnv.botMxid, roomId: testRoomId, body: 'Hello world!'
});
// Send a webhook
await fetch(webhookUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({text: 'Hello world!'})
});
// And await the notice.
await webhookNotice;
});
});

143
spec/github.spec.ts Normal file
View File

@ -0,0 +1,143 @@
import { E2ESetupTestTimeout, E2ETestEnv } from "./util/e2e-test";
import { describe, it, beforeEach, afterEach } from "@jest/globals";
import { createHmac, randomUUID } from "crypto";
import { GitHubRepoConnection, GitHubRepoConnectionState } from "../src/Connections";
import { MessageEventContent } from "matrix-bot-sdk";
import { getBridgeApi } from "./util/bridge-api";
import { Server, createServer } from "http";
describe('GitHub', () => {
let testEnv: E2ETestEnv;
let githubServer: Server;
const webhooksPort = 9500 + E2ETestEnv.workerId;
const githubPort = 9700 + E2ETestEnv.workerId;
beforeEach(async () => {
// Fake out enough of a GitHub API to get past startup. Later
// tests might make more use of this.
githubServer = createServer((req, res) => {
if (req.method === 'GET' && req.url === '/api/v3/app') {
res.writeHead(200, undefined, { "content-type": 'application/json'});
res.write(JSON.stringify({}));
} else if (req.method === 'GET' && req.url === '/api/v3/app/installations?per_page=100&page=1') {
res.writeHead(200, undefined, { "content-type": 'application/json'});
res.write(JSON.stringify([]));
} else {
console.log('Unknown request', req.method, req.url);
res.writeHead(404);
}
res.end();
}).listen(githubPort);
testEnv = await E2ETestEnv.createTestEnv({matrixLocalparts: ['user'], config: {
github: {
webhook: {
secret: randomUUID(),
},
// So we can mock out the URL
enterpriseUrl: `http://localhost:${githubPort}`,
auth: {
privateKeyFile: 'replaced',
id: '1234',
}
},
widgets: {
publicUrl: `http://localhost:${webhooksPort}`
},
listeners: [{
port: webhooksPort,
bindAddress: '0.0.0.0',
// Bind to the SAME listener to ensure we don't have conflicts.
resources: ['webhooks', 'widgets'],
}],
}});
await testEnv.setUp();
}, E2ESetupTestTimeout);
afterEach(() => {
githubServer?.close();
return testEnv?.tearDown();
});
it.only('should be able to handle a GitHub event', async () => {
const user = testEnv.getUser('user');
const bridgeApi = await getBridgeApi(testEnv.opts.config?.widgets?.publicUrl!, user);
const testRoomId = await user.createRoom({ name: 'Test room', invite:[testEnv.botMxid] });
await user.setUserPowerLevel(testEnv.botMxid, testRoomId, 50);
await user.waitForRoomJoin({sender: testEnv.botMxid, roomId: testRoomId });
// Now hack in a GitHub connection.
await testEnv.app.appservice.botClient.sendStateEvent(testRoomId, GitHubRepoConnection.CanonicalEventType, "my-test", {
org: 'my-org',
repo: 'my-repo'
} satisfies GitHubRepoConnectionState);
// Wait for connection to be accepted.
await new Promise<void>(r => {
let interval: NodeJS.Timeout;
interval = setInterval(() => {
bridgeApi.getConnectionsForRoom(testRoomId).then(conns => {
if (conns.length > 0) {
clearInterval(interval);
r();
}
})
}, 500);
});
const webhookNotice = user.waitForRoomEvent<MessageEventContent>({
eventType: 'm.room.message', sender: testEnv.botMxid, roomId: testRoomId
});
const webhookPayload = JSON.stringify({
"action": "opened",
"number": 1,
"pull_request": {
id: 1,
"url": "https://api.github.com/repos/my-org/my-repo/pulls/1",
"html_url": "https://github.com/my-org/my-repo/pulls/1",
"number": 1,
"state": "open",
"locked": false,
"title": "My test pull request",
"user": {
"login": "alice",
},
},
repository: {
id: 1,
"html_url": "https://github.com/my-org/my-repo",
name: 'my-repo',
full_name: 'my-org/my-repo',
owner: {
login: 'my-org',
}
},
sender: {
login: 'alice',
}
});
const hmac = createHmac('sha256', testEnv.opts.config?.github?.webhook.secret!);
hmac.write(webhookPayload);
hmac.end();
// Send a webhook
const req = await fetch(`http://localhost:${webhooksPort}/`, {
method: 'POST',
headers: {
'x-github-event': 'pull_request',
'X-Hub-Signature-256': `sha256=${hmac.read().toString('hex')}`,
'X-GitHub-Delivery': randomUUID(),
'Content-Type': 'application/json'
},
body: webhookPayload,
});
expect(req.status).toBe(200);
expect(await req.text()).toBe('OK');
// And await the notice.
const { body } = (await webhookNotice).data.content;
expect(body).toContain('**alice** opened a new PR');
expect(body).toContain('https://github.com/my-org/my-repo/pulls/1');
expect(body).toContain('My test pull request');
});
});

14
spec/util/bridge-api.ts Normal file
View File

@ -0,0 +1,14 @@
import { MatrixClient } from "matrix-bot-sdk";
import { BridgeAPI } from "../../web/BridgeAPI";
import { WidgetApi } from "matrix-widget-api";
export async function getBridgeApi(publicUrl: string, user: MatrixClient) {
return BridgeAPI.getBridgeAPI(publicUrl, {
requestOpenIDConnectToken: () => {
return user.getOpenIDConnectToken()
},
} as unknown as WidgetApi, {
getItem() { return null},
setItem() { },
} as unknown as Storage);
}

273
spec/util/e2e-test.ts Normal file
View File

@ -0,0 +1,273 @@
import { ComplementHomeServer, createHS, destroyHS } from "./homerunner";
import { IAppserviceRegistration, MatrixClient, MembershipEventContent, PowerLevelsEventContent } from "matrix-bot-sdk";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { BridgeConfig, BridgeConfigRoot } from "../../src/config/Config";
import { start } from "../../src/App/BridgeApp";
import { RSAKeyPairOptions, generateKeyPair } from "node:crypto";
import path from "node:path";
const WAIT_EVENT_TIMEOUT = 10000;
export const E2ESetupTestTimeout = 60000;
interface Opts {
matrixLocalparts?: string[];
config?: Partial<BridgeConfigRoot>,
}
export class E2ETestMatrixClient extends MatrixClient {
public async waitForPowerLevel(
roomId: string, expected: Partial<PowerLevelsEventContent>,
): Promise<{roomId: string, data: {
sender: string, type: string, state_key?: string, content: PowerLevelsEventContent, event_id: string,
}}> {
return this.waitForEvent('room.event', (eventRoomId: string, eventData: {
sender: string, type: string, content: Record<string, unknown>, event_id: string, state_key: string,
}) => {
if (eventRoomId !== roomId) {
return undefined;
}
if (eventData.type !== "m.room.power_levels") {
return undefined;
}
if (eventData.state_key !== "") {
return undefined;
}
// Check only the keys we care about
for (const [key, value] of Object.entries(expected)) {
const evValue = eventData.content[key] ?? undefined;
const sortOrder = value !== null && typeof value === "object" ? Object.keys(value).sort() : undefined;
const jsonLeft = JSON.stringify(evValue, sortOrder);
const jsonRight = JSON.stringify(value, sortOrder);
if (jsonLeft !== jsonRight) {
return undefined;
}
}
console.info(
// eslint-disable-next-line max-len
`${eventRoomId} ${eventData.event_id} ${eventData.sender}`
);
return {roomId: eventRoomId, data: eventData};
}, `Timed out waiting for powerlevel from in ${roomId}`)
}
public async waitForRoomEvent<T extends object = Record<string, unknown>>(
opts: {eventType: string, sender: string, roomId?: string, stateKey?: string, body?: string}
): Promise<{roomId: string, data: {
sender: string, type: string, state_key?: string, content: T, event_id: string,
}}> {
const {eventType, sender, roomId, stateKey} = opts;
return this.waitForEvent('room.event', (eventRoomId: string, eventData: {
sender: string, type: string, state_key?: string, content: T, event_id: string,
}) => {
if (eventData.sender !== sender) {
return undefined;
}
if (eventData.type !== eventType) {
return undefined;
}
if (roomId && eventRoomId !== roomId) {
return undefined;
}
if (stateKey !== undefined && eventData.state_key !== stateKey) {
return undefined;
}
const body = 'body' in eventData.content && eventData.content.body;
if (opts.body && body !== opts.body) {
return undefined;
}
console.info(
// eslint-disable-next-line max-len
`${eventRoomId} ${eventData.event_id} ${eventData.type} ${eventData.sender} ${eventData.state_key ?? body ?? ''}`
);
return {roomId: eventRoomId, data: eventData};
}, `Timed out waiting for ${eventType} from ${sender} in ${roomId || "any room"}`)
}
public async waitForRoomJoin(
opts: {sender: string, roomId?: string}
): Promise<{roomId: string, data: unknown}> {
const {sender, roomId} = opts;
return this.waitForEvent('room.event', (eventRoomId: string, eventData: {
sender: string,
state_key: string,
content: MembershipEventContent,
}) => {
if (eventData.state_key !== sender) {
return;
}
if (roomId && eventRoomId !== roomId) {
return;
}
if (eventData.content.membership !== "join") {
return;
}
return {roomId: eventRoomId, data: eventData};
}, `Timed out waiting for join to ${roomId || "any room"} from ${sender}`)
}
public async waitForRoomInvite(
opts: {sender: string, roomId?: string}
): Promise<{roomId: string, data: unknown}> {
const {sender, roomId} = opts;
return this.waitForEvent('room.invite', (eventRoomId: string, eventData: {
sender: string
}) => {
if (eventData.sender !== sender) {
return undefined;
}
if (roomId && eventRoomId !== roomId) {
return undefined;
}
return {roomId: eventRoomId, data: eventData};
}, `Timed out waiting for invite to ${roomId || "any room"} from ${sender}`)
}
public async waitForEvent<T>(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
emitterType: string, filterFn: (...args: any[]) => T|undefined, timeoutMsg: string)
: Promise<T> {
return new Promise((resolve, reject) => {
// eslint-disable-next-line prefer-const
let timer: NodeJS.Timeout;
const fn = (...args: unknown[]) => {
const data = filterFn(...args);
if (data) {
clearTimeout(timer);
resolve(data);
}
};
timer = setTimeout(() => {
this.removeListener(emitterType, fn);
reject(new Error(timeoutMsg));
}, WAIT_EVENT_TIMEOUT);
this.on(emitterType, fn)
});
}
}
export class E2ETestEnv {
static get workerId() {
return parseInt(process.env.JEST_WORKER_ID ?? '0');
}
static async createTestEnv(opts: Opts): Promise<E2ETestEnv> {
const workerID = this.workerId;
const { matrixLocalparts, config: providedConfig } = opts;
const keyPromise = new Promise<string>((resolve, reject) => generateKeyPair("rsa", {
modulusLength: 4096,
privateKeyEncoding: {
type: "pkcs8",
format: "pem",
},
publicKeyEncoding: {
format: "pem",
type: "pkcs1",
}
} satisfies RSAKeyPairOptions<"pem", "pem">, (err, _, privateKey) => {
if (err) { reject(err) } else { resolve(privateKey) }
}));
// Configure homeserver and bots
const [homeserver, dir, privateKey] = await Promise.all([
createHS([...matrixLocalparts || []], workerID),
mkdtemp('hookshot-int-test'),
keyPromise,
]);
const keyPath = path.join(dir, 'key.pem');
await writeFile(keyPath, privateKey, 'utf-8');
const webhooksPort = 9500 + workerID;
if (providedConfig?.widgets) {
providedConfig.widgets.openIdOverrides = {
'hookshot': homeserver.url,
}
}
if (providedConfig?.github) {
providedConfig.github.auth.privateKeyFile = keyPath;
}
const config = new BridgeConfig({
bridge: {
domain: homeserver.domain,
url: homeserver.url,
port: homeserver.appPort,
bindAddress: '0.0.0.0',
},
logging: {
level: 'info',
},
queue: {
monolithic: true,
},
// Always enable webhooks so that hookshot starts.
generic: {
enabled: true,
urlPrefix: `http://localhost:${webhooksPort}/webhook`,
},
listeners: [{
port: webhooksPort,
bindAddress: '0.0.0.0',
resources: ['webhooks'],
}],
passFile: keyPath,
...providedConfig,
});
const registration: IAppserviceRegistration = {
as_token: homeserver.asToken,
hs_token: homeserver.hsToken,
sender_localpart: 'hookshot',
namespaces: {
users: [{
regex: `@hookshot:${homeserver.domain}`,
exclusive: true,
}],
rooms: [],
aliases: [],
}
};
const app = await start(config, registration);
app.listener.finaliseListeners();
return new E2ETestEnv(homeserver, app, opts, config, dir);
}
private constructor(
public readonly homeserver: ComplementHomeServer,
public app: Awaited<ReturnType<typeof start>>,
public readonly opts: Opts,
private readonly config: BridgeConfig,
private readonly dir: string,
) { }
public get botMxid() {
return `@hookshot:${this.homeserver.domain}`;
}
public async setUp(): Promise<void> {
await this.app.bridgeApp.start();
}
public async tearDown(): Promise<void> {
await this.app.bridgeApp.stop();
await this.app.listener.stop();
await this.app.storage.disconnect?.();
this.homeserver.users.forEach(u => u.client.stop());
await destroyHS(this.homeserver.id);
await rm(this.dir, { recursive: true });
}
public getUser(localpart: string) {
const u = this.homeserver.users.find(u => u.userId === `@${localpart}:${this.homeserver.domain}`);
if (!u) {
throw Error("User missing from test");
}
return u.client;
}
}

134
spec/util/homerunner.ts Normal file
View File

@ -0,0 +1,134 @@
import { MatrixClient } from "matrix-bot-sdk";
import { createHash, createHmac, randomUUID } from "crypto";
import { Homerunner } from "homerunner-client";
import { E2ETestMatrixClient } from "./e2e-test";
const HOMERUNNER_IMAGE = process.env.HOMERUNNER_IMAGE || 'ghcr.io/element-hq/synapse/complement-synapse:latest';
export const DEFAULT_REGISTRATION_SHARED_SECRET = (
process.env.REGISTRATION_SHARED_SECRET || 'complement'
);
const COMPLEMENT_HOSTNAME_RUNNING_COMPLEMENT = (
process.env.COMPLEMENT_HOSTNAME_RUNNING_COMPLEMENT || "host.docker.internal"
);
const homerunner = new Homerunner.Client();
export interface ComplementHomeServer {
id: string,
url: string,
domain: string,
users: {userId: string, accessToken: string, deviceId: string, client: E2ETestMatrixClient}[]
asToken: string,
hsToken: string,
appPort: number,
}
async function waitForHomerunner() {
let attempts = 0;
do {
attempts++;
console.log(`Waiting for homerunner to be ready (${attempts}/100)`);
try {
await homerunner.health();
break;
}
catch (ex) {
await new Promise(r => setTimeout(r, 1000));
}
} while (attempts < 100)
if (attempts === 100) {
throw Error('Homerunner was not ready after 100 attempts');
}
}
export async function createHS(localparts: string[] = [], workerId: number): Promise<ComplementHomeServer> {
await waitForHomerunner();
const appPort = 49600 + workerId;
const blueprint = `hookshot_integration_test_${Date.now()}`;
const asToken = randomUUID();
const hsToken = randomUUID();
const blueprintResponse = await homerunner.create({
base_image_uri: HOMERUNNER_IMAGE,
blueprint: {
Name: blueprint,
Homeservers: [{
Name: 'hookshot',
Users: localparts.map(localpart => ({Localpart: localpart, DisplayName: localpart})),
ApplicationServices: [{
ID: 'hookshot',
URL: `http://${COMPLEMENT_HOSTNAME_RUNNING_COMPLEMENT}:${appPort}`,
SenderLocalpart: 'hookshot',
RateLimited: false,
...{ASToken: asToken,
HSToken: hsToken},
}]
}],
}
});
const [homeserverName, homeserver] = Object.entries(blueprintResponse.homeservers)[0];
// Skip AS user.
const users = Object.entries(homeserver.AccessTokens)
.filter(([_uId, accessToken]) => accessToken !== asToken)
.map(([userId, accessToken]) => ({
userId: userId,
accessToken,
deviceId: homeserver.DeviceIDs[userId],
client: new E2ETestMatrixClient(homeserver.BaseURL, accessToken),
})
);
// Start syncing proactively.
await Promise.all(users.map(u => u.client.start()));
return {
users,
id: blueprint,
url: homeserver.BaseURL,
domain: homeserverName,
asToken,
appPort,
hsToken,
};
}
export function destroyHS(
id: string
): Promise<void> {
return homerunner.destroy(id);
}
export async function registerUser(
homeserverUrl: string,
user: { username: string, admin: boolean },
sharedSecret = DEFAULT_REGISTRATION_SHARED_SECRET,
): Promise<{mxid: string, client: MatrixClient}> {
const registerUrl: string = (() => {
const url = new URL(homeserverUrl);
url.pathname = '/_synapse/admin/v1/register';
return url.toString();
})();
const nonce = await fetch(registerUrl, { method: 'GET' }).then(res => res.json()).then((res) => (res as any).nonce);
const password = createHash('sha256')
.update(user.username)
.update(sharedSecret)
.digest('hex');
const hmac = createHmac('sha1', sharedSecret)
.update(nonce).update("\x00")
.update(user.username).update("\x00")
.update(password).update("\x00")
.update(user.admin ? 'admin' : 'notadmin')
.digest('hex');
return await fetch(registerUrl, { method: "POST", body: JSON.stringify(
{
nonce,
username: user.username,
password,
admin: user.admin,
mac: hmac,
}
)}).then(res => res.json()).then(res => ({
mxid: (res as {user_id: string}).user_id,
client: new MatrixClient(homeserverUrl, (res as {access_token: string}).access_token),
})).catch(err => { console.log(err.response.body); throw new Error(`Failed to register user: ${err}`); });
}

37
spec/widgets.spec.ts Normal file
View File

@ -0,0 +1,37 @@
import { E2ESetupTestTimeout, E2ETestEnv } from "./util/e2e-test";
import { describe, it, beforeEach, afterEach } from "@jest/globals";
import { getBridgeApi } from "./util/bridge-api";
describe('Widgets', () => {
let testEnv: E2ETestEnv;
beforeEach(async () => {
const webhooksPort = 9500 + E2ETestEnv.workerId;
testEnv = await E2ETestEnv.createTestEnv({matrixLocalparts: ['user'], config: {
widgets: {
publicUrl: `http://localhost:${webhooksPort}`
},
listeners: [{
port: webhooksPort,
bindAddress: '0.0.0.0',
// Bind to the SAME listener to ensure we don't have conflicts.
resources: ['webhooks', 'widgets'],
}],
}});
await testEnv.setUp();
}, E2ESetupTestTimeout);
afterEach(() => {
return testEnv?.tearDown();
});
it('should be able to authenticate with the widget API', async () => {
const user = testEnv.getUser('user');
const bridgeApi = await getBridgeApi(testEnv.opts.config?.widgets?.publicUrl!, user);
expect(await bridgeApi.verify()).toEqual({
"type": "widget",
"userId": "@user:hookshot",
});
});
});

View File

@ -2,20 +2,20 @@
import "reflect-metadata";
import { AdminAccountData, AdminRoomCommandHandler, Category } from "./AdminRoomCommandHandler";
import { botCommand, compileBotCommands, handleCommand, BotCommands, HelpFunction } from "./BotCommands";
import { BridgeConfig, BridgePermissionLevel } from "./Config/Config";
import { BridgeConfig, BridgePermissionLevel } from "./config/Config";
import { BridgeRoomState, BridgeRoomStateGitHub } from "./Widgets/BridgeWidgetInterface";
import { Endpoints } from "@octokit/types";
import { GitHubDiscussionSpace, GitHubIssueConnection, GitHubRepoConnection } from "./Connections";
import { ConnectionManager } from "./ConnectionManager";
import { FormatUtil } from "./FormatUtil";
import { GetUserResponse } from "./Gitlab/Types";
import { GitHubBotCommands } from "./Github/AdminCommands";
import { GithubGraphQLClient } from "./Github/GithubInstance";
import { GitHubBotCommands } from "./github/AdminCommands";
import { GithubGraphQLClient } from "./github/GithubInstance";
import { GitLabClient } from "./Gitlab/Client";
import { Intent } from "matrix-bot-sdk";
import { JiraBotCommands } from "./Jira/AdminCommands";
import { JiraBotCommands } from "./jira/AdminCommands";
import { NotifFilter, NotificationFilterStateContent } from "./NotificationFilters";
import { ProjectsListResponseData } from "./Github/Types";
import { ProjectsListResponseData } from "./github/Types";
import { UserTokenStore } from "./UserTokenStore";
import { Logger } from "matrix-appservice-bridge";
import markdown from "markdown-it";

View File

@ -1,6 +1,6 @@
import EventEmitter from "events";
import { Intent } from "matrix-bot-sdk";
import { BridgeConfig } from "./Config/Config";
import { BridgeConfig } from "./config/Config";
import { UserTokenStore } from "./UserTokenStore";

View File

@ -1,23 +1,20 @@
import { Bridge } from "../Bridge";
import { BridgeConfig, parseRegistrationFile } from "../Config/Config";
import { BridgeConfig, parseRegistrationFile } from "../config/Config";
import { Webhooks } from "../Webhooks";
import { MatrixSender } from "../MatrixSender";
import { UserNotificationWatcher } from "../Notifications/UserNotificationWatcher";
import { ListenerService } from "../ListenerService";
import { Logger } from "matrix-appservice-bridge";
import { LogService } from "matrix-bot-sdk";
import { Logger, getBridgeVersion } from "matrix-appservice-bridge";
import { IAppserviceRegistration, LogService } from "matrix-bot-sdk";
import { getAppservice } from "../appservice";
import BotUsersManager from "../Managers/BotUsersManager";
import * as Sentry from '@sentry/node';
import { GenericHookConnection } from "../Connections";
Logger.configure({console: "info"});
const log = new Logger("App");
async function start() {
const configFile = process.argv[2] || "./config.yml";
const registrationFile = process.argv[3] || "./registration.yml";
const config = await BridgeConfig.parseConfig(configFile, process.env);
const registration = await parseRegistrationFile(registrationFile);
export async function start(config: BridgeConfig, registration: IAppserviceRegistration) {
const listener = new ListenerService(config.listeners);
listener.start();
Logger.configure({
@ -37,6 +34,21 @@ async function start() {
userNotificationWatcher.start();
}
if (config.sentry) {
Sentry.init({
dsn: config.sentry.dsn,
environment: config.sentry.environment,
release: getBridgeVersion(),
serverName: config.bridge.domain,
includeLocalVariables: true,
});
log.info("Sentry reporting enabled");
}
if (config.generic?.allowJsTransformationFunctions) {
await GenericHookConnection.initialiseQuickJS();
}
const botUsersManager = new BotUsersManager(config, appservice);
const bridgeApp = new Bridge(config, listener, appservice, storage, botUsersManager);
@ -48,22 +60,32 @@ async function start() {
// Don't care to await this, as the process is about to end
storage.disconnect?.();
});
await bridgeApp.start();
// XXX: Since the webhook listener listens on /, it must listen AFTER other resources
// have bound themselves.
if (config.queue.monolithic) {
const webhookHandler = new Webhooks(config);
listener.bindResource('webhooks', webhookHandler.expressRouter);
}
return {
appservice,
bridgeApp,
storage,
listener,
};
}
start().catch((ex) => {
if (Logger.root.configured) {
log.error("BridgeApp encountered an error and has stopped:", ex);
} else {
// eslint-disable-next-line no-console
console.error("BridgeApp encountered an error and has stopped", ex);
}
process.exit(1);
});
async function startFromFile() {
const configFile = process.argv[2] || "./config.yml";
const registrationFile = process.argv[3] || "./registration.yml";
const config = await BridgeConfig.parseConfig(configFile, process.env);
const registration = await parseRegistrationFile(registrationFile);
const { bridgeApp, listener } = await start(config, registration);
await bridgeApp.start();
listener.finaliseListeners();
}
if (require.main === module) {
startFromFile().catch((ex) => {
if (Logger.root.configured) {
log.error("BridgeApp encountered an error and has stopped:", ex);
} else {
// eslint-disable-next-line no-console
console.error("BridgeApp encountered an error and has stopped", ex);
}
process.exit(1);
});
}

View File

@ -1,4 +1,4 @@
import { BridgeConfig } from "../Config/Config";
import { BridgeConfig } from "../config/Config";
import { Webhooks } from "../Webhooks";
import { Logger } from "matrix-appservice-bridge";
import { UserNotificationWatcher } from "../Notifications/UserNotificationWatcher";
@ -30,6 +30,7 @@ async function start() {
}
const webhookHandler = new Webhooks(config);
listener.bindResource('webhooks', webhookHandler.expressRouter);
listener.finaliseListeners();
const userWatcher = new UserNotificationWatcher(config);
userWatcher.start();
process.once("SIGTERM", () => {

View File

@ -1,4 +1,4 @@
import { BridgeConfig, parseRegistrationFile } from "../Config/Config";
import { BridgeConfig, parseRegistrationFile } from "../config/Config";
import { MatrixSender } from "../MatrixSender";
import { Logger } from "matrix-appservice-bridge";
import Metrics from "../Metrics";
@ -32,6 +32,7 @@ async function start() {
listener.bindResource('metrics', Metrics.expressRouter);
}
}
listener.finaliseListeners();
sender.listen();
process.once("SIGTERM", () => {
log.error("Got SIGTERM");

View File

@ -1,6 +1,6 @@
import { rm } from "fs/promises";
import { BridgeConfig, parseRegistrationFile } from "../Config/Config";
import { BridgeConfig, parseRegistrationFile } from "../config/Config";
import { Logger } from "matrix-appservice-bridge";
import { LogService, MatrixClient } from "matrix-bot-sdk";
import { getAppservice } from "../appservice";

View File

@ -1,11 +1,11 @@
import markdown from "markdown-it";
import stringArgv from "string-argv";
import { ApiError } from "./api";
import { CommandError } from "./errors";
import { MatrixMessageContent } from "./MatrixEvent";
import { BridgePermissionLevel } from "./Config/Config";
import { BridgePermissionLevel } from "./config/Config";
import { PermissionCheckFn } from "./Connections";
const stringArgv = import("string-argv");
const md = new markdown();
export const botCommandSymbol = Symbol("botCommandMetadata");
@ -124,7 +124,7 @@ export async function handleCommand(
}
command = command.substring(prefix.length);
}
const parts = stringArgv(command);
const parts = (await stringArgv).parseArgsStringToArgv(command);
for (let i = parts.length; i > 0; i--) {
const prefix = parts.slice(0, i).join(" ").toLowerCase();
// We have a match!

View File

@ -2,44 +2,46 @@ import { AdminAccountData } from "./AdminRoomCommandHandler";
import { AdminRoom, BRIDGE_ROOM_TYPE, LEGACY_BRIDGE_ROOM_TYPE } from "./AdminRoom";
import { Appservice, RichRepliesPreprocessor, IRichReplyMetadata, StateEvent, EventKind, PowerLevelsEvent, Intent } from "matrix-bot-sdk";
import BotUsersManager from "./Managers/BotUsersManager";
import { BridgeConfig, BridgePermissionLevel, GitLabInstance } from "./Config/Config";
import { BridgeConfig, BridgePermissionLevel, GitLabInstance } from "./config/Config";
import { BridgeWidgetApi } from "./Widgets/BridgeWidgetApi";
import { CommentProcessor } from "./CommentProcessor";
import { ConnectionManager } from "./ConnectionManager";
import { GetIssueResponse, GetIssueOpts } from "./Gitlab/Types"
import { GithubInstance } from "./Github/GithubInstance";
import { GithubInstance } from "./github/GithubInstance";
import { IBridgeStorageProvider } from "./Stores/StorageProvider";
import { IConnection, GitHubDiscussionSpace, GitHubDiscussionConnection, GitHubUserSpace, JiraProjectConnection, GitLabRepoConnection,
GitHubIssueConnection, GitHubProjectConnection, GitHubRepoConnection, GitLabIssueConnection, FigmaFileConnection, FeedConnection, GenericHookConnection } from "./Connections";
GitHubIssueConnection, GitHubProjectConnection, GitHubRepoConnection, GitLabIssueConnection, FigmaFileConnection, FeedConnection, GenericHookConnection, WebhookResponse } from "./Connections";
import { IGitLabWebhookIssueStateEvent, IGitLabWebhookMREvent, IGitLabWebhookNoteEvent, IGitLabWebhookPushEvent, IGitLabWebhookReleaseEvent, IGitLabWebhookTagPushEvent, IGitLabWebhookWikiPageEvent } from "./Gitlab/WebhookTypes";
import { JiraIssueEvent, JiraIssueUpdatedEvent, JiraVersionEvent } from "./Jira/WebhookTypes";
import { JiraOAuthResult } from "./Jira/Types";
import { JiraIssueEvent, JiraIssueUpdatedEvent, JiraVersionEvent } from "./jira/WebhookTypes";
import { JiraOAuthResult } from "./jira/Types";
import { MatrixEvent, MatrixMemberContent, MatrixMessageContent } from "./MatrixEvent";
import { MessageQueue, createMessageQueue } from "./MessageQueue";
import { MessageQueue, MessageQueueMessageOut, createMessageQueue } from "./MessageQueue";
import { MessageSenderClient } from "./MatrixSender";
import { NotifFilter, NotificationFilterStateContent } from "./NotificationFilters";
import { NotificationProcessor } from "./NotificationsProcessor";
import { NotificationsEnableEvent, NotificationsDisableEvent } from "./Webhooks";
import { GitHubOAuthToken, GitHubOAuthTokenResponse, ProjectsGetResponseData } from "./Github/Types";
import { NotificationsEnableEvent, NotificationsDisableEvent, Webhooks } from "./Webhooks";
import { GitHubOAuthToken, GitHubOAuthTokenResponse, ProjectsGetResponseData } from "./github/Types";
import { retry } from "./PromiseUtil";
import { UserNotificationsEvent } from "./Notifications/UserNotificationWatcher";
import { UserTokenStore } from "./UserTokenStore";
import * as GitHubWebhookTypes from "@octokit/webhooks-types";
import { Logger } from "matrix-appservice-bridge";
import { Provisioner } from "./provisioning/provisioner";
import { JiraProvisionerRouter } from "./Jira/Router";
import { GitHubProvisionerRouter } from "./Github/Router";
import { JiraProvisionerRouter } from "./jira/Router";
import { GitHubProvisionerRouter } from "./github/Router";
import { OAuthRequest } from "./WebhookTypes";
import { promises as fs } from "fs";
import Metrics from "./Metrics";
import { FigmaEvent, ensureFigmaWebhooks } from "./figma";
import { ListenerService } from "./ListenerService";
import { SetupConnection } from "./Connections/SetupConnection";
import { JiraOAuthRequestCloud, JiraOAuthRequestOnPrem, JiraOAuthRequestResult } from "./Jira/OAuth";
import { JiraOAuthRequestCloud, JiraOAuthRequestOnPrem, JiraOAuthRequestResult } from "./jira/OAuth";
import { GenericWebhookEvent, GenericWebhookEventResult } from "./generic/types";
import { SetupWidget } from "./Widgets/SetupWidget";
import { FeedEntry, FeedError, FeedReader, FeedSuccess } from "./feeds/FeedReader";
import PQueue from "p-queue";
import * as Sentry from '@sentry/node';
const log = new Logger("Bridge");
export class Bridge {
@ -365,6 +367,18 @@ export class Bridge {
(c, data) => c.onMergeRequestReviewed(data),
);
this.bindHandlerToQueue<IGitLabWebhookMREvent, GitLabRepoConnection>(
"gitlab.merge_request.approval",
(data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace),
(c, data) => c.onMergeRequestIndividualReview(data),
);
this.bindHandlerToQueue<IGitLabWebhookMREvent, GitLabRepoConnection>(
"gitlab.merge_request.unapproval",
(data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace),
(c, data) => c.onMergeRequestIndividualReview(data),
);
this.bindHandlerToQueue<IGitLabWebhookMREvent, GitLabRepoConnection>(
"gitlab.merge_request.update",
(data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace),
@ -597,18 +611,25 @@ export class Bridge {
return;
}
let successful: boolean|null = null;
if (this.config.generic?.waitForComplete) {
successful = await c.onGenericHook(data.hookData);
}
await this.queue.push<GenericWebhookEventResult>({
data: {successful},
sender: "Bridge",
eventName: `response.${id}`,
});
didPush = true;
if (!this.config.generic?.waitForComplete) {
let response: WebhookResponse|undefined;
if (this.config.generic?.waitForComplete || c.waitForComplete) {
const result = await c.onGenericHook(data.hookData);
successful = result.successful;
response = result.response;
await this.queue.push<GenericWebhookEventResult>({
data: {successful, response},
sender: "Bridge",
eventName: "response.generic-webhook.event",
});
} else {
await this.queue.push<GenericWebhookEventResult>({
data: {},
sender: "Bridge",
eventName: "response.generic-webhook.event",
});
await c.onGenericHook(data.hookData);
}
didPush = true;
}
catch (ex) {
log.warn(`Failed to handle generic webhook`, ex);
@ -756,28 +777,46 @@ export class Bridge {
this.config.feeds,
this.connectionManager,
this.queue,
// Use default bot when storing account data
this.as.botClient,
this.storage,
);
}
const webhookHandler = new Webhooks(this.config);
this.listener.bindResource('webhooks', webhookHandler.expressRouter);
await this.as.begin();
log.info(`Bridge is now ready. Found ${this.connectionManager.size} connections`);
this.ready = true;
}
private async handleHookshotEvent<EventType, ConnType extends IConnection>(msg: MessageQueueMessageOut<EventType>, connection: ConnType, handler: (c: ConnType, data: EventType) => Promise<unknown>|unknown) {
try {
await handler(connection, msg.data);
} catch (e) {
Sentry.withScope((scope) => {
scope.setTransactionName('handleHookshotEvent');
scope.setTags({
eventType: msg.eventName,
roomId: connection.roomId,
});
scope.setContext("connection", {
id: connection.connectionId,
});
log.warn(`Connection ${connection.toString()} failed to handle ${msg.eventName}:`, e);
Metrics.connectionsEventFailed.inc({ event: msg.eventName, connectionId: connection.connectionId });
Sentry.captureException(e, scope);
});
}
}
private async bindHandlerToQueue<EventType, ConnType extends IConnection>(event: string, connectionFetcher: (data: EventType) => ConnType[], handler: (c: ConnType, data: EventType) => Promise<unknown>|unknown) {
const connectionFetcherBound = connectionFetcher.bind(this);
this.queue.on<EventType>(event, (msg) => {
const connections = connectionFetcher.bind(this)(msg.data);
const connections = connectionFetcherBound(msg.data);
log.debug(`${event} for ${connections.map(c => c.toString()).join(', ') || '[empty]'}`);
connections.forEach(async (connection) => {
try {
await handler(connection, msg.data);
} catch (ex) {
Metrics.connectionsEventFailed.inc({ event, connectionId: connection.connectionId });
log.warn(`Connection ${connection.toString()} failed to handle ${event}:`, ex);
}
})
connections.forEach((connection) => {
void this.handleHookshotEvent(msg, connection, handler);
});
});
}
@ -888,12 +927,24 @@ export class Bridge {
if (!adminRoom) {
let handled = false;
for (const connection of this.connectionManager.getAllConnectionsForRoom(roomId)) {
const scope = new Sentry.Scope();
scope.setTransactionName('onRoomMessage');
scope.setTags({
eventId: event.event_id,
sender: event.sender,
eventType: event.type,
roomId: connection.roomId,
});
scope.setContext("connection", {
id: connection.connectionId,
});
try {
if (connection.onMessageEvent) {
handled = await connection.onMessageEvent(event, checkPermission, processedReplyMetadata);
}
} catch (ex) {
log.warn(`Connection ${connection.toString()} failed to handle message:`, ex);
Sentry.captureException(ex, scope);
}
if (handled) {
break;
@ -1060,6 +1111,17 @@ export class Bridge {
if (!this.connectionManager.verifyStateEventForConnection(connection, state, true)) {
continue;
}
const scope = new Sentry.Scope();
scope.setTransactionName('onStateUpdate');
scope.setTags({
eventId: event.event_id,
sender: event.sender,
eventType: event.type,
roomId: connection.roomId,
});
scope.setContext("connection", {
id: connection.connectionId,
});
try {
// Empty object == redacted
if (event.content.disabled === true || Object.keys(event.content).length === 0) {
@ -1086,9 +1148,9 @@ export class Bridge {
if (this.config.widgets?.roomSetupWidget?.addOnInvite && event.type === "m.room.power_levels" && event.state_key === "" && !this.connectionManager.isRoomConnected(roomId)) {
log.debug(`${roomId} got a new powerlevel change and isn't connected to any connections, testing to see if we should create a setup widget`)
const plEvent = new PowerLevelsEvent(event);
const currentPl = plEvent.content.users?.[botUser.userId] || plEvent.defaultUserLevel;
const previousPl = plEvent.previousContent?.users?.[botUser.userId] || plEvent.previousContent?.users_default;
const requiredPl = plEvent.content.events?.["im.vector.modular.widgets"] || plEvent.defaultStateEventLevel;
const currentPl = plEvent.content.users?.[botUser.userId] ?? plEvent.defaultUserLevel;
const previousPl = plEvent.previousContent?.users?.[botUser.userId] ?? plEvent.previousContent?.users_default;
const requiredPl = plEvent.content.events?.["im.vector.modular.widgets"] ?? plEvent.defaultStateEventLevel;
if (currentPl !== previousPl && currentPl >= requiredPl) {
// PL changed for bot user, check to see if the widget can be created.
try {
@ -1110,11 +1172,24 @@ export class Bridge {
}
for (const connection of this.connectionManager.getAllConnectionsForRoom(roomId)) {
if (!connection.onEvent) {
continue;
}
const scope = new Sentry.Scope();
scope.setTransactionName('onRoomEvent');
scope.setTags({
eventId: event.event_id,
sender: event.sender,
eventType: event.type,
roomId: connection.roomId,
});
scope.setContext("connection", {
id: connection.connectionId,
});
try {
if (connection.onEvent) {
await connection.onEvent(event);
}
await connection.onEvent(event);
} catch (ex) {
Sentry.captureException(ex, scope);
log.warn(`Connection ${connection.toString()} failed to handle onEvent:`, ex);
}
}

View File

@ -1,12 +1,11 @@
import { Appservice } from "matrix-bot-sdk";
import markdown from "markdown-it";
import mime from "mime";
import emoji from "node-emoji";
import { MatrixMessageContent, MatrixEvent } from "./MatrixEvent";
import { Logger } from "matrix-appservice-bridge";
import axios from "axios";
import { FormatUtil } from "./FormatUtil";
import { IssuesGetCommentResponseData, ReposGetResponseData, IssuesGetResponseData } from "./Github/Types"
import { IssuesGetCommentResponseData, ReposGetResponseData, IssuesGetResponseData } from "./github/Types"
import { IGitLabWebhookNoteEvent } from "./Gitlab/WebhookTypes";
const REGEX_MENTION = /(^|\s)(@[a-z\d](?:[a-z\d]|-(?=[a-z\d])){0,38})(\s|$)/ig;
@ -14,6 +13,7 @@ const REGEX_MATRIX_MENTION = /<a href="https:\/\/matrix\.to\/#\/(.+)">(.*)<\/a>/
const REGEX_IMAGES = /!\[.*]\((.*\.(\w+))\)/gm;
const md = new markdown();
const log = new Logger("CommentProcessor");
const mime = import('mime');
interface IMatrixCommentEvent extends MatrixMessageContent {
// eslint-disable-next-line camelcase
@ -131,7 +131,7 @@ export class CommentProcessor {
let match = REGEX_IMAGES.exec(bodyCopy);
while (match) {
bodyCopy = bodyCopy.replace(match[1], "");
const contentType = mime.getType(match[1]) || "none";
const contentType = (await mime).default.getType(match[1]) || "none";
if (
!contentType.startsWith("image") &&
!contentType.startsWith("video") &&
@ -145,7 +145,7 @@ export class CommentProcessor {
try {
const { data, headers } = await axios.get(rawUrl, {responseType: "arraybuffer"});
const imageData = data;
const contentType = headers["content-type"] || mime.getType(rawUrl) || "application/octet-stream";
const contentType = headers["content-type"] || (await mime).default.getType(rawUrl) || "application/octet-stream";
let url;
if (convertToMxc) {
url = await this.as.botIntent.underlyingClient.uploadContent(imageData, contentType);

View File

@ -6,15 +6,15 @@
import { Appservice, Intent, StateEvent } from "matrix-bot-sdk";
import { ApiError, ErrCode } from "./api";
import { BridgeConfig, BridgePermissionLevel, GitLabInstance } from "./Config/Config";
import { BridgeConfig, BridgePermissionLevel, GitLabInstance } from "./config/Config";
import { CommentProcessor } from "./CommentProcessor";
import { ConnectionDeclaration, ConnectionDeclarations, GenericHookConnection, GitHubDiscussionConnection, GitHubDiscussionSpace, GitHubIssueConnection, GitHubProjectConnection, GitHubRepoConnection, GitHubUserSpace, GitLabIssueConnection, GitLabRepoConnection, IConnection, IConnectionState, JiraProjectConnection } from "./Connections";
import { FigmaFileConnection, FeedConnection } from "./Connections";
import { GetConnectionTypeResponseItem } from "./provisioning/api";
import { GitLabClient } from "./Gitlab/Client";
import { GithubInstance } from "./Github/GithubInstance";
import { GithubInstance } from "./github/GithubInstance";
import { IBridgeStorageProvider } from "./Stores/StorageProvider";
import { JiraProject, JiraVersion } from "./Jira/Types";
import { JiraProject, JiraVersion } from "./jira/Types";
import { Logger } from "matrix-appservice-bridge";
import { MessageSenderClient } from "./MatrixSender";
import { UserTokenStore } from "./UserTokenStore";

View File

@ -1,14 +1,18 @@
import {Intent, StateEvent} from "matrix-bot-sdk";
import { IConnection, IConnectionState, InstantiateConnectionOpts } from ".";
import { ApiError, ErrCode } from "../api";
import { FeedEntry, FeedError, FeedReader} from "../feeds/FeedReader";
import { FeedEntry, FeedError} from "../feeds/FeedReader";
import { Logger } from "matrix-appservice-bridge";
import { BaseConnection } from "./BaseConnection";
import markdown from "markdown-it";
import { Connection, ProvisionConnectionOpts } from "./IConnection";
import { GetConnectionsResponseItem } from "../provisioning/api";
import { readFeed, sanitizeHtml } from "../libRs";
import UserAgent from "../UserAgent";
const log = new Logger("FeedConnection");
const md = new markdown();
const md = new markdown({
html: true,
});
export interface LastResultOk {
timestamp: number;
@ -22,10 +26,10 @@ export interface LastResultFail {
export interface FeedConnectionState extends IConnectionState {
url: string;
label?: string;
template?: string;
notifyOnFailure?: boolean;
url: string;
label: string|undefined;
template: string|undefined;
notifyOnFailure: boolean|undefined;
}
export interface FeedConnectionSecrets {
@ -35,7 +39,7 @@ export interface FeedConnectionSecrets {
export type FeedResponseItem = GetConnectionsResponseItem<FeedConnectionState, FeedConnectionSecrets>;
const MAX_LAST_RESULT_ITEMS = 5;
const VALIDATION_FETCH_TIMEOUT_MS = 5000;
const VALIDATION_FETCH_TIMEOUT_S = 5;
const MAX_SUMMARY_LENGTH = 512;
const MAX_TEMPLATE_LENGTH = 1024;
@ -65,7 +69,10 @@ export class FeedConnection extends BaseConnection implements IConnection {
}
try {
await FeedReader.fetchFeed(url, {}, VALIDATION_FETCH_TIMEOUT_MS);
await readFeed(url, {
userAgent: UserAgent,
pollTimeoutSeconds: VALIDATION_FETCH_TIMEOUT_S,
});
} catch (ex) {
throw new ApiError(`Could not read feed from URL: ${ex.message}`, ErrCode.BadValue);
}
@ -90,8 +97,11 @@ export class FeedConnection extends BaseConnection implements IConnection {
}
}
if (typeof data.notifyOnFailure !== 'undefined' && typeof data.notifyOnFailure !== 'boolean') {
throw new ApiError('notifyOnFailure must be a boolean', ErrCode.BadValue);
}
return { url, label: data.label, template: data.template };
return { url, label: data.label, template: data.template, notifyOnFailure: data.notifyOnFailure };
}
static async provisionConnection(roomId: string, _userId: string, data: Record<string, unknown> = {}, { intent, config }: ProvisionConnectionOpts) {
@ -126,6 +136,8 @@ export class FeedConnection extends BaseConnection implements IConnection {
config: {
url: this.feedUrl,
label: this.state.label,
template: this.state.template,
notifyOnFailure: this.state.notifyOnFailure,
},
secrets: {
lastResults: this.lastResults,
@ -182,6 +194,15 @@ export class FeedConnection extends BaseConnection implements IConnection {
}
public async handleFeedEntry(entry: FeedEntry): Promise<void> {
// We will need to tidy this up.
if (this.state.template?.match(/\$SUMMARY\b/) && entry.summary) {
// This might be massive and cause us to fail to send the message
// so confine to a maximum size.
if (entry.summary.length > MAX_SUMMARY_LENGTH) {
entry.summary = entry.summary.substring(0, MAX_SUMMARY_LENGTH) + "…";
}
entry.summary = sanitizeHtml(entry.summary);
}
let message;
if (this.state.template) {
@ -194,12 +215,6 @@ export class FeedConnection extends BaseConnection implements IConnection {
message = this.templateFeedEntry(DEFAULT_TEMPLATE, entry);
}
// This might be massive and cause us to fail to send the message
// so confine to a maximum size.
if (entry.summary?.length ?? 0 > MAX_SUMMARY_LENGTH) {
entry.summary = entry.summary?.substring(0, MAX_SUMMARY_LENGTH) + "…" ?? null;
}
await this.intent.sendEvent(this.roomId, {
msgtype: 'm.notice',
format: "org.matrix.custom.html",

View File

@ -5,7 +5,7 @@ import { BaseConnection } from "./BaseConnection";
import { IConnection, IConnectionState } from ".";
import { Logger } from "matrix-appservice-bridge";
import { IBridgeStorageProvider } from "../Stores/StorageProvider";
import { BridgeConfig } from "../Config/Config";
import { BridgeConfig } from "../config/Config";
import { Connection, InstantiateConnectionOpts, ProvisionConnectionOpts } from "./IConnection";
import { ConfigGrantChecker, GrantChecker } from "../grants/GrantCheck";

View File

@ -2,13 +2,13 @@ import { Connection, IConnection, IConnectionState, InstantiateConnectionOpts, P
import { Logger } from "matrix-appservice-bridge";
import { MessageSenderClient } from "../MatrixSender"
import markdownit from "markdown-it";
import { VMScript as Script, NodeVM } from "vm2";
import { QuickJSWASMModule, newQuickJSWASMModule, shouldInterruptAfterDeadline } from "quickjs-emscripten";
import { MatrixEvent } from "../MatrixEvent";
import { Appservice, Intent, StateEvent } from "matrix-bot-sdk";
import { ApiError, ErrCode } from "../api";
import { BaseConnection } from "./BaseConnection";
import { GetConnectionsResponseItem } from "../provisioning/api";
import { BridgeConfigGenericWebhooks } from "../Config/Config";
import { BridgeConfigGenericWebhooks } from "../config/Config";
import { ensureUserIsInRoom } from "../IntentUtils";
import { randomUUID } from 'node:crypto';
@ -21,7 +21,11 @@ export interface GenericHookConnectionState extends IConnectionState {
* The name given in the provisioning UI and displaynames.
*/
name: string;
transformationFunction?: string;
transformationFunction: string|undefined;
/**
* Should the webhook only respond on completion.
*/
waitForComplete: boolean|undefined;
}
export interface GenericHookSecrets {
@ -45,12 +49,19 @@ export interface GenericHookAccountData {
[hookId: string]: string;
}
export interface WebhookResponse {
body: string;
contentType?: string;
statusCode?: number;
}
interface WebhookTransformationResult {
version: string;
plain?: string;
html?: string;
msgtype?: string;
empty?: boolean;
webhookResponse?: WebhookResponse;
}
const log = new Logger("GenericHookConnection");
@ -65,6 +76,11 @@ const SANITIZE_MAX_BREADTH = 50;
*/
@Connection
export class GenericHookConnection extends BaseConnection implements IConnection {
private static quickModule?: QuickJSWASMModule;
public static async initialiseQuickJS() {
GenericHookConnection.quickModule = await newQuickJSWASMModule();
}
/**
* Ensures a JSON payload is compatible with Matrix JSON requirements, such
@ -107,27 +123,30 @@ export class GenericHookConnection extends BaseConnection implements IConnection
return obj;
}
static validateState(state: Record<string, unknown>, allowJsTransformationFunctions?: boolean): GenericHookConnectionState {
const {name, transformationFunction} = state;
let transformationFunctionResult: string|undefined;
if (transformationFunction) {
if (allowJsTransformationFunctions !== undefined && !allowJsTransformationFunctions) {
throw new ApiError('Transformation functions are not allowed', ErrCode.DisabledFeature);
}
if (typeof transformationFunction !== "string") {
throw new ApiError('Transformation functions must be a string', ErrCode.BadValue);
}
transformationFunctionResult = transformationFunction;
}
static validateState(state: Record<string, unknown>): GenericHookConnectionState {
const {name, transformationFunction, waitForComplete} = state;
if (!name) {
throw new ApiError('Missing name', ErrCode.BadValue);
}
if (typeof name !== "string" || name.length < 3 || name.length > 64) {
throw new ApiError("'name' must be a string between 3-64 characters long", ErrCode.BadValue);
}
if (waitForComplete !== undefined && typeof waitForComplete !== "boolean") {
throw new ApiError("'waitForComplete' must be a boolean", ErrCode.BadValue);
}
// Use !=, not !==, to check for both undefined and null
if (transformationFunction != undefined) {
if (!this.quickModule) {
throw new ApiError('Transformation functions are not allowed', ErrCode.DisabledFeature);
}
if (typeof transformationFunction !== "string") {
throw new ApiError('Transformation functions must be a string', ErrCode.BadValue);
}
}
return {
name,
...(transformationFunctionResult && {transformationFunction: transformationFunctionResult}),
transformationFunction: transformationFunction || undefined,
waitForComplete,
};
}
@ -163,7 +182,7 @@ export class GenericHookConnection extends BaseConnection implements IConnection
throw Error('Generic Webhooks are not configured');
}
const hookId = randomUUID();
const validState = GenericHookConnection.validateState(data, config.generic.allowJsTransformationFunctions || false);
const validState = GenericHookConnection.validateState(data);
await GenericHookConnection.ensureRoomAccountData(roomId, intent, hookId, validState.name);
await intent.underlyingClient.sendStateEvent(roomId, this.CanonicalEventType, validState.name, validState);
const connection = new GenericHookConnection(roomId, validState, hookId, validState.name, messageClient, config.generic, as, intent);
@ -197,8 +216,11 @@ export class GenericHookConnection extends BaseConnection implements IConnection
GenericHookConnection.LegacyCanonicalEventType,
];
private transformationFunction?: Script;
private transformationFunction?: string;
private cachedDisplayname?: string;
/**
* @param state Should be a pre-validated state object returned by {@link validateState}
*/
constructor(
roomId: string,
private state: GenericHookConnectionState,
@ -210,11 +232,19 @@ export class GenericHookConnection extends BaseConnection implements IConnection
private readonly intent: Intent,
) {
super(roomId, stateKey, GenericHookConnection.CanonicalEventType);
if (state.transformationFunction && config.allowJsTransformationFunctions) {
this.transformationFunction = new Script(state.transformationFunction);
if (state.transformationFunction && GenericHookConnection.quickModule) {
this.transformationFunction = state.transformationFunction;
}
}
/**
* Should the webhook handler wait for this to finish before
* sending a response back.
*/
public get waitForComplete(): boolean {
return this.state.waitForComplete ?? false;
}
public get priority(): number {
return this.state.priority || super.priority;
}
@ -259,12 +289,26 @@ export class GenericHookConnection extends BaseConnection implements IConnection
}
public async onStateUpdate(stateEv: MatrixEvent<unknown>) {
const validatedConfig = GenericHookConnection.validateState(stateEv.content as Record<string, unknown>, this.config.allowJsTransformationFunctions || false);
const validatedConfig = GenericHookConnection.validateState(stateEv.content as Record<string, unknown>);
if (validatedConfig.transformationFunction) {
try {
this.transformationFunction = new Script(validatedConfig.transformationFunction);
} catch (ex) {
await this.messageClient.sendMatrixText(this.roomId, 'Could not compile transformation function:' + ex, "m.text", this.intent.userId);
const ctx = GenericHookConnection.quickModule!.newContext();
const codeEvalResult = ctx.evalCode(`function f(data) {${validatedConfig.transformationFunction}}`, undefined, { compileOnly: true });
if (codeEvalResult.error) {
const errorString = JSON.stringify(ctx.dump(codeEvalResult.error), null, 2);
codeEvalResult.error.dispose();
ctx.dispose();
const errorPrefix = "Could not compile transformation function:";
await this.intent.sendEvent(this.roomId, {
msgtype: "m.text",
body: errorPrefix + "\n\n```json\n\n" + errorString + "\n\n```",
formatted_body: `<p>${errorPrefix}</p><p><pre><code class=\\"language-json\\">${errorString}</code></pre></p>`,
format: "org.matrix.custom.html",
});
} else {
codeEvalResult.value.dispose();
ctx.dispose();
this.transformationFunction = validatedConfig.transformationFunction;
}
} else {
this.transformationFunction = undefined;
@ -281,8 +325,10 @@ export class GenericHookConnection extends BaseConnection implements IConnection
} else if (typeof safeData?.text === "string") {
msg.plain = safeData.text;
} else {
msg.plain = "Received webhook data:\n\n" + "```json\n\n" + JSON.stringify(data, null, 2) + "\n\n```";
msg.html = `<p>Received webhook data:</p><p><pre><code class=\\"language-json\\">${JSON.stringify(data, null, 2)}</code></pre></p>`
const dataString = JSON.stringify(data, null, 2);
const dataPrefix = "Received webhook data:";
msg.plain = dataPrefix + "\n\n```json\n\n" + dataString + "\n\n```";
msg.html = `<p>${dataPrefix}</p><p><pre><code class=\\"language-json\\">${dataString}</code></pre></p>`
}
if (typeof safeData?.html === "string") {
@ -300,52 +346,76 @@ export class GenericHookConnection extends BaseConnection implements IConnection
return msg;
}
public executeTransformationFunction(data: unknown): {plain: string, html?: string, msgtype?: string}|null {
public executeTransformationFunction(data: unknown): {content?: {plain: string, html?: string, msgtype?: string}, webhookResponse?: WebhookResponse} {
if (!this.transformationFunction) {
throw Error('Transformation function not defined');
}
const vm = new NodeVM({
console: 'off',
wrapper: 'none',
wasm: false,
eval: false,
timeout: TRANSFORMATION_TIMEOUT_MS,
});
vm.setGlobal('HookshotApiVersion', 'v2');
vm.setGlobal('data', data);
vm.run(this.transformationFunction);
const result = vm.getGlobal('result');
let result;
const ctx = GenericHookConnection.quickModule!.newContext();
ctx.runtime.setInterruptHandler(shouldInterruptAfterDeadline(Date.now() + TRANSFORMATION_TIMEOUT_MS));
try {
ctx.setProp(ctx.global, 'HookshotApiVersion', ctx.newString('v2'));
const ctxResult = ctx.evalCode(`const data = ${JSON.stringify(data)};\n(() => { ${this.state.transformationFunction} })();`);
if (ctxResult.error) {
const e = Error(`Transformation failed to run: ${JSON.stringify(ctx.dump(ctxResult.error))}`);
ctxResult.error.dispose();
throw e;
} else {
const value = ctx.getProp(ctx.global, 'result');
result = ctx.dump(value);
value.dispose();
ctxResult.value.dispose();
}
} finally {
ctx.global.dispose();
ctx.dispose();
}
// Legacy v1 api
if (typeof result === "string") {
return {plain: `Received webhook: ${result}`};
return {content: {plain: `Received webhook: ${result}`}};
} else if (typeof result !== "object") {
return {plain: `No content`};
return {content: {plain: `No content`}};
}
const transformationResult = result as WebhookTransformationResult;
if (transformationResult.version !== "v2") {
throw Error("Result returned from transformation didn't specify version = v2");
}
if (transformationResult.empty) {
return null; // No-op
let content;
if (!transformationResult.empty) {
if (typeof transformationResult.plain !== "string") {
throw Error("Result returned from transformation didn't provide a string value for plain");
}
if (transformationResult.html !== undefined && typeof transformationResult.html !== "string") {
throw Error("Result returned from transformation didn't provide a string value for html");
}
if (transformationResult.msgtype !== undefined && typeof transformationResult.msgtype !== "string") {
throw Error("Result returned from transformation didn't provide a string value for msgtype");
}
content = {
plain: transformationResult.plain,
html: transformationResult.html,
msgtype: transformationResult.msgtype,
};
}
const plain = transformationResult.plain;
if (typeof plain !== "string") {
throw Error("Result returned from transformation didn't provide a string value for plain");
}
if (transformationResult.html && typeof transformationResult.html !== "string") {
throw Error("Result returned from transformation didn't provide a string value for html");
}
if (transformationResult.msgtype && typeof transformationResult.msgtype !== "string") {
throw Error("Result returned from transformation didn't provide a string value for msgtype");
if (transformationResult.webhookResponse) {
if (typeof transformationResult.webhookResponse.body !== "string") {
throw Error("Result returned from transformation didn't provide a string value for webhookResponse.body");
}
if (transformationResult.webhookResponse.statusCode !== undefined && typeof transformationResult.webhookResponse.statusCode !== "number" && Number.isInteger(transformationResult.webhookResponse.statusCode)) {
throw Error("Result returned from transformation didn't provide a number value for webhookResponse.statusCode");
}
if (transformationResult.webhookResponse.contentType !== undefined && typeof transformationResult.webhookResponse.contentType !== "string") {
throw Error("Result returned from transformation didn't provide a contentType value for msgtype");
}
}
return {
plain: plain,
html: transformationResult.html,
msgtype: transformationResult.msgtype,
content,
webhookResponse: transformationResult.webhookResponse,
}
}
@ -354,45 +424,49 @@ export class GenericHookConnection extends BaseConnection implements IConnection
* @param data Structured data. This may either be a string, or an object.
* @returns `true` if the webhook completed, or `false` if it failed to complete
*/
public async onGenericHook(data: unknown): Promise<boolean> {
public async onGenericHook(data: unknown): Promise<{successful: boolean, response?: WebhookResponse}> {
log.info(`onGenericHook ${this.roomId} ${this.hookId}`);
let content: {plain: string, html?: string, msgtype?: string};
let success = true;
let content: {plain: string, html?: string, msgtype?: string}|undefined;
let webhookResponse: WebhookResponse|undefined;
let successful = true;
if (!this.transformationFunction) {
content = this.transformHookData(data);
} else {
try {
const potentialContent = this.executeTransformationFunction(data);
if (potentialContent === null) {
// Explitly no action
return true;
}
content = potentialContent;
const result = this.executeTransformationFunction(data);
content = result.content;
webhookResponse = result.webhookResponse;
} catch (ex) {
log.warn(`Failed to run transformation function`, ex);
content = {plain: `Webhook received but failed to process via transformation function`};
success = false;
successful = false;
}
}
const sender = this.getUserId();
const senderIntent = this.as.getIntentForUserId(sender);
await this.ensureDisplayname(senderIntent);
if (content) {
const sender = this.getUserId();
const senderIntent = this.as.getIntentForUserId(sender);
await this.ensureDisplayname(senderIntent);
await ensureUserIsInRoom(senderIntent, this.intent.underlyingClient, this.roomId);
await ensureUserIsInRoom(senderIntent, this.intent.underlyingClient, this.roomId);
// Matrix cannot handle float data, so make sure we parse out any floats.
const safeData = GenericHookConnection.sanitiseObjectForMatrixJSON(data);
// Matrix cannot handle float data, so make sure we parse out any floats.
const safeData = GenericHookConnection.sanitiseObjectForMatrixJSON(data);
await this.messageClient.sendMatrixMessage(this.roomId, {
msgtype: content.msgtype || "m.notice",
body: content.plain,
// render can output redundant trailing newlines, so trim it.
formatted_body: content.html || md.render(content.plain).trim(),
format: "org.matrix.custom.html",
"uk.half-shot.hookshot.webhook_data": safeData,
}, 'm.room.message', sender);
return success;
await this.messageClient.sendMatrixMessage(this.roomId, {
msgtype: content.msgtype || "m.notice",
body: content.plain,
// render can output redundant trailing newlines, so trim it.
formatted_body: content.html || md.render(content.plain).trim(),
format: "org.matrix.custom.html",
"uk.half-shot.hookshot.webhook_data": safeData,
}, 'm.room.message', sender);
}
return {
successful,
response: webhookResponse,
};
}
@ -412,6 +486,7 @@ export class GenericHookConnection extends BaseConnection implements IConnection
id: this.connectionId,
config: {
transformationFunction: this.state.transformationFunction,
waitForComplete: this.waitForComplete,
name: this.state.name,
},
...(showSecrets ? { secrets: {
@ -437,7 +512,7 @@ export class GenericHookConnection extends BaseConnection implements IConnection
public async provisionerUpdateConfig(userId: string, config: Record<string, unknown>) {
// Apply previous state to the current config, as provisioners might not return "unknown" keys.
config = { ...this.state, ...config };
const validatedConfig = GenericHookConnection.validateState(config, this.config.allowJsTransformationFunctions || false);
const validatedConfig = GenericHookConnection.validateState(config);
await this.intent.underlyingClient.sendStateEvent(this.roomId, GenericHookConnection.CanonicalEventType, this.stateKey,
{
...validatedConfig,

View File

@ -9,10 +9,10 @@ import { Discussion } from "@octokit/webhooks-types";
import emoji from "node-emoji";
import markdown from "markdown-it";
import { DiscussionCommentCreatedEvent } from "@octokit/webhooks-types";
import { GithubGraphQLClient } from "../Github/GithubInstance";
import { GithubGraphQLClient } from "../github/GithubInstance";
import { Logger } from "matrix-appservice-bridge";
import { BaseConnection } from "./BaseConnection";
import { BridgeConfig, BridgeConfigGitHub } from "../Config/Config";
import { BridgeConfig, BridgeConfigGitHub } from "../config/Config";
import { ConfigGrantChecker, GrantChecker } from "../grants/GrantCheck";
import QuickLRU from "@alloc/quick-lru";
export interface GitHubDiscussionConnectionState {

View File

@ -1,13 +1,13 @@
import { Connection, IConnection, InstantiateConnectionOpts } from "./IConnection";
import { Appservice, Space, StateEvent } from "matrix-bot-sdk";
import { Logger } from "matrix-appservice-bridge";
import { ReposGetResponseData } from "../Github/Types";
import { ReposGetResponseData } from "../github/Types";
import axios from "axios";
import { GitHubDiscussionConnection } from "./GithubDiscussion";
import { GithubInstance } from "../Github/GithubInstance";
import { GithubInstance } from "../github/GithubInstance";
import { BaseConnection } from "./BaseConnection";
import { ConfigGrantChecker, GrantChecker } from "../grants/GrantCheck";
import { BridgeConfig } from "../Config/Config";
import { BridgeConfig } from "../config/Config";
const log = new Logger("GitHubDiscussionSpace");

View File

@ -9,11 +9,11 @@ import { MessageSenderClient } from "../MatrixSender";
import { ensureUserIsInRoom, getIntentForUser } from "../IntentUtils";
import { FormatUtil } from "../FormatUtil";
import axios from "axios";
import { GithubInstance } from "../Github/GithubInstance";
import { IssuesGetCommentResponseData, IssuesGetResponseData, ReposGetResponseData} from "../Github/Types";
import { GithubInstance } from "../github/GithubInstance";
import { IssuesGetCommentResponseData, IssuesGetResponseData, ReposGetResponseData} from "../github/Types";
import { IssuesEditedEvent, IssueCommentCreatedEvent } from "@octokit/webhooks-types";
import { BaseConnection } from "./BaseConnection";
import { BridgeConfigGitHub } from "../Config/Config";
import { BridgeConfigGitHub } from "../config/Config";
export interface GitHubIssueConnectionState {
org: string;

View File

@ -1,10 +1,10 @@
import { Connection, IConnection, InstantiateConnectionOpts } from "./IConnection";
import { Appservice, Intent, StateEvent } from "matrix-bot-sdk";
import { Logger } from "matrix-appservice-bridge";
import { ProjectsGetResponseData } from "../Github/Types";
import { ProjectsGetResponseData } from "../github/Types";
import { BaseConnection } from "./BaseConnection";
import { ConfigGrantChecker, GrantChecker } from "../grants/GrantCheck";
import { BridgeConfig } from "../Config/Config";
import { BridgeConfig } from "../config/Config";
export interface GitHubProjectConnectionState {
// eslint-disable-next-line camelcase

View File

@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
import { Appservice, Intent, IRichReplyMetadata, StateEvent } from "matrix-bot-sdk";
import { BotCommands, botCommand, compileBotCommands, HelpFunction } from "../BotCommands";
import { CommentProcessor } from "../CommentProcessor";
@ -13,22 +12,22 @@ import { IssuesOpenedEvent, IssuesReopenedEvent, IssuesEditedEvent, PullRequestO
import { MatrixMessageContent, MatrixEvent, MatrixReactionContent } from "../MatrixEvent";
import { MessageSenderClient } from "../MatrixSender";
import { CommandError, NotLoggedInError } from "../errors";
import { NAMELESS_ORG_PLACEHOLDER, ReposGetResponseData } from "../Github/Types";
import { ReposGetResponseData } from "../github/Types";
import { UserTokenStore } from "../UserTokenStore";
import axios, { AxiosError } from "axios";
import emoji from "node-emoji";
import { emojify } from "node-emoji";
import { Logger } from "matrix-appservice-bridge";
import markdown from "markdown-it";
import { CommandConnection } from "./CommandConnection";
import { GithubInstance } from "../Github/GithubInstance";
import { getNameForGitHubAccount, GithubInstance } from "../github/GithubInstance";
import { GitHubIssueConnection } from "./GithubIssue";
import { BridgeConfigGitHub } from "../Config/Config";
import { BridgeConfigGitHub } from "../config/Config";
import { ApiError, ErrCode, ValidatorApiError } from "../api";
import { PermissionCheckFn } from ".";
import { GitHubRepoMessageBody, MinimalGitHubIssue } from "../libRs";
import Ajv, { JSONSchemaType } from "ajv";
import { HookFilter } from "../HookFilter";
import { GitHubGrantChecker } from "../Github/GrantChecker";
import { GitHubGrantChecker } from "../github/GrantChecker";
const log = new Logger("GitHubRepoConnection");
const md = new markdown();
@ -316,13 +315,14 @@ const EMOJI_TO_REVIEW_STATE = {
};
const WORKFLOW_CONCLUSION_TO_NOTICE: Record<WorkflowRunCompletedEvent["workflow_run"]["conclusion"], string> = {
success: "completed sucessfully 🎉",
success: "completed successfully 🎉",
failure: "failed 😟",
neutral: "completed neutrally 😐",
cancelled: "was cancelled 🙅",
timed_out: "timed out ⏰",
action_required: "requires further action 🖱️",
stale: "completed, but is stale 🍞"
stale: "completed, but is stale 🍞",
skipped: "skipped ⏭️"
}
const TRUNCATE_COMMENT_SIZE = 256;
@ -669,7 +669,7 @@ export class GitHubRepoConnection extends CommandConnection<GitHubRepoConnection
if (this.showIssueRoomLink) {
message += ` [Issue Room](https://matrix.to/#/${this.as.getAlias(GitHubIssueConnection.generateAliasLocalpart(this.org, this.repo, issue.number))})`;
}
const content = emoji.emojify(message);
const content = emojify(message);
await this.intent.sendEvent(this.roomId, {
msgtype: "m.notice",
body: content ,
@ -698,7 +698,7 @@ export class GitHubRepoConnection extends CommandConnection<GitHubRepoConnection
)
);
// Typescript is dumb.
// @ts-ignore - property is used
// @ts-expect-error - property is used
const reviewEvent = reviewKey && EMOJI_TO_REVIEW_STATE[reviewKey];
if (body && repoInfo && pullRequestId && reviewEvent) {
log.info(`Handling reply to PR ${pullRequestId}`);
@ -862,7 +862,7 @@ export class GitHubRepoConnection extends CommandConnection<GitHubRepoConnection
inputs: workflowArgs,
});
} catch (ex) {
const httpError = ex as AxiosError;
const httpError = ex as AxiosError<{message: string}>;
if (httpError.response?.data) {
throw new CommandError(httpError.response?.data.message, httpError.response?.data.message);
}
@ -885,7 +885,8 @@ export class GitHubRepoConnection extends CommandConnection<GitHubRepoConnection
}
const orgRepoName = event.repository.full_name;
let message = `**${event.issue.user.login}** created new issue [${orgRepoName}#${event.issue.number}](${event.issue.html_url}): "${event.issue.title}"`;
const icon = '📥';
let message = emojify(`${icon} **${event.issue.user.login}** created new issue [${orgRepoName}#${event.issue.number}](${event.issue.html_url}): "${event.issue.title}"`);
message += (event.issue.assignee ? ` assigned to ${event.issue.assignee.login}` : '');
if (this.showIssueRoomLink) {
const appInstance = await this.githubInstance.getSafeOctokitForRepo(this.org, this.repo);
@ -895,7 +896,7 @@ export class GitHubRepoConnection extends CommandConnection<GitHubRepoConnection
log.warn(`Cannot show issue room link, no app install for ${orgRepoName}`);
}
}
const content = emoji.emojify(message);
const content = emojify(message);
const labels = FormatUtil.formatLabels(event.issue.labels?.map(l => ({ name: l.name, description: l.description || undefined, color: l.color || undefined })));
await this.intent.sendEvent(this.roomId, {
msgtype: "m.notice",
@ -907,12 +908,13 @@ export class GitHubRepoConnection extends CommandConnection<GitHubRepoConnection
}
public async onIssueCommentCreated(event: IssueCommentCreatedEvent) {
if (this.hookFilter.shouldSkip('issue.comment.created', 'issue.comment', 'issue') || !this.matchesLabelFilter(event.issue)) {
if (this.hookFilter.shouldSkip('issue.comment.created', 'issue.comment') || !this.matchesLabelFilter(event.issue)) {
return;
}
let message = `**${event.comment.user.login}** [commented](${event.issue.html_url}) on [${event.repository.full_name}#${event.issue.number}](${event.issue.html_url}) `;
message += "\n > " + event.comment.body.substring(0, TRUNCATE_COMMENT_SIZE) + (event.comment.body.length > TRUNCATE_COMMENT_SIZE ? "…" : "");
const icon = '🗣';
let message = emojify(`${icon} **${event.comment.user.login}** [commented](${event.issue.html_url}) on [${event.repository.full_name}#${event.issue.number}](${event.issue.html_url}) `);
message += "\n> " + event.comment.body.substring(0, TRUNCATE_COMMENT_SIZE) + (event.comment.body.length > TRUNCATE_COMMENT_SIZE ? "…" : "");
await this.intent.sendEvent(this.roomId, {
msgtype: "m.notice",
@ -960,7 +962,8 @@ export class GitHubRepoConnection extends CommandConnection<GitHubRepoConnection
}
}
}
const content = `**${event.sender.login}** ${state} issue [${orgRepoName}#${event.issue.number}](${event.issue.html_url}): "${emoji.emojify(event.issue.title)}"${withComment}`;
const icon = state === 'reopened' ? '🔷' : '⬛';
const content = emojify(`${icon} **${event.sender.login}** ${state} issue [${orgRepoName}#${event.issue.number}](${event.issue.html_url}): "${emojify(event.issue.title)}"${withComment}`);
await this.intent.sendEvent(this.roomId, {
msgtype: "m.notice",
body: content,
@ -979,7 +982,8 @@ export class GitHubRepoConnection extends CommandConnection<GitHubRepoConnection
}
log.info(`onIssueEdited ${this.roomId} ${this.org}/${this.repo} #${event.issue.number}`);
const orgRepoName = event.repository.full_name;
const content = `**${event.sender.login}** edited issue [${orgRepoName}#${event.issue.number}](${event.issue.html_url}): "${emoji.emojify(event.issue.title)}"`;
const icon = '✏';
const content = emojify(`${icon} **${event.sender.login}** edited issue [${orgRepoName}#${event.issue.number}](${event.issue.html_url}): "${emojify(event.issue.title)}"`);
await this.intent.sendEvent(this.roomId, {
msgtype: "m.notice",
body: content,
@ -1010,7 +1014,8 @@ export class GitHubRepoConnection extends CommandConnection<GitHubRepoConnection
}
const orgRepoName = event.repository.full_name;
const {plain, html} = FormatUtil.formatLabels(event.issue.labels?.map(l => ({ name: l.name, description: l.description || undefined, color: l.color || undefined })));
const content = `**${event.sender.login}** labeled issue [${orgRepoName}#${event.issue.number}](${event.issue.html_url}): "${emoji.emojify(event.issue.title)}"`;
const icon = '🗃';
const content = emojify(`${icon} **${event.sender.login}** labeled issue [${orgRepoName}#${event.issue.number}](${event.issue.html_url}): "${emojify(event.issue.title)}"`);
this.intent.sendEvent(this.roomId, {
msgtype: "m.notice",
body: content + (plain.length > 0 ? ` with labels ${plain}`: ""),
@ -1067,7 +1072,8 @@ export class GitHubRepoConnection extends CommandConnection<GitHubRepoConnection
diffContentHtml = `\n<pre><code class="language-diff">${diff.data}\n</code></pre>`;
}
}
const content = emoji.emojify(`**${event.sender.login}** ${verb} a new PR [${orgRepoName}#${event.pull_request.number}](${event.pull_request.html_url}): "${event.pull_request.title}"`);
const icon = verb === 'opened' ? '🔵' : '⚪';
const content = emojify(`${icon} **${event.sender.login}** ${verb} a new PR [${orgRepoName}#${event.pull_request.number}](${event.pull_request.html_url}): "${event.pull_request.title}"`);
const labels = FormatUtil.formatLabels(event.pull_request.labels?.map(l => ({ name: l.name, description: l.description || undefined, color: l.color || undefined })));
await this.intent.sendEvent(this.roomId, {
msgtype: "m.notice",
@ -1091,7 +1097,8 @@ export class GitHubRepoConnection extends CommandConnection<GitHubRepoConnection
throw Error('No repository content!');
}
const orgRepoName = event.repository.full_name;
const content = emoji.emojify(`**${event.sender.login}** has marked [${orgRepoName}#${event.pull_request.number}](${event.pull_request.html_url}) as ready to review "${event.pull_request.title}"`);
const icon = '🔬';
const content = emojify(`${icon} **${event.sender.login}** has marked [${orgRepoName}#${event.pull_request.number}](${event.pull_request.html_url}) as ready to review "${event.pull_request.title}"`);
await this.intent.sendEvent(this.roomId, {
msgtype: "m.notice",
body: content,
@ -1124,7 +1131,7 @@ export class GitHubRepoConnection extends CommandConnection<GitHubRepoConnection
// We don't recongnise this state, run away!
return;
}
const content = emoji.emojify(`**${event.sender.login}** ${emojiForReview} ${event.review.state.toLowerCase()} [${orgRepoName}#${event.pull_request.number}](${event.pull_request.html_url}) "${event.pull_request.title}"`);
const content = emojify(`${emojiForReview} **${event.sender.login}** ${event.review.state.toLowerCase()} [${orgRepoName}#${event.pull_request.number}](${event.pull_request.html_url}) "${event.pull_request.title}"`);
await this.intent.sendEvent(this.roomId, {
msgtype: "m.notice",
body: content,
@ -1170,7 +1177,8 @@ export class GitHubRepoConnection extends CommandConnection<GitHubRepoConnection
}
}
const content = emoji.emojify(`**${event.sender.login}** ${verb} PR [${orgRepoName}#${event.pull_request.number}](${event.pull_request.html_url}): "${event.pull_request.title}"${withComment}`);
const icon = verb === 'merged' ? '✳' : '⚫';
const content = emojify(`${icon} **${event.sender.login}** ${verb} PR [${orgRepoName}#${event.pull_request.number}](${event.pull_request.html_url}): "${event.pull_request.title}"${withComment}`);
await this.intent.sendEvent(this.roomId, {
msgtype: "m.notice",
body: content,
@ -1197,7 +1205,8 @@ export class GitHubRepoConnection extends CommandConnection<GitHubRepoConnection
throw Error('No repository content!');
}
const orgRepoName = event.repository.full_name;
let content = `**${event.sender.login}** 🪄 released [${event.release.name ?? event.release.tag_name}](${event.release.html_url}) for ${orgRepoName}`;
const icon = '📣';
let content = emojify(`${icon} **${event.sender.login}** released [${event.release.name ?? event.release.tag_name}](${event.release.html_url}) for ${orgRepoName}`);
if (event.release.body) {
content += `\n\n${event.release.body}`
}
@ -1222,8 +1231,9 @@ export class GitHubRepoConnection extends CommandConnection<GitHubRepoConnection
if (!event.repository) {
throw Error('No repository content!');
}
const icon = '📝';
const orgRepoName = event.repository.full_name;
let content = `**${event.sender.login}** 🪄 drafted release [${event.release.name ?? event.release.tag_name}](${event.release.html_url}) for ${orgRepoName}`;
let content = emojify(`${icon} **${event.sender.login}** drafted release [${event.release.name ?? event.release.tag_name}](${event.release.html_url}) for ${orgRepoName}`);
if (event.release.body) {
content += `\n\n${event.release.body}`
}
@ -1259,7 +1269,8 @@ export class GitHubRepoConnection extends CommandConnection<GitHubRepoConnection
log.info(`onWorkflowCompleted ${this.roomId} ${this.org}/${this.repo} '${workflowRun.id}'`);
const orgRepoName = event.repository.full_name;
const content = `Workflow **${event.workflow.name}** [${WORKFLOW_CONCLUSION_TO_NOTICE[workflowRun.conclusion]}](${workflowRun.html_url}) for ${orgRepoName} on branch \`${workflowRun.head_branch}\``;
const icon = '☑';
const content = emojify(`${icon} Workflow **${event.workflow.name}** [${WORKFLOW_CONCLUSION_TO_NOTICE[workflowRun.conclusion]}](${workflowRun.html_url}) for ${orgRepoName} on branch \`${workflowRun.head_branch}\``);
await this.intent.sendEvent(this.roomId, {
msgtype: "m.notice",
body: content,
@ -1419,7 +1430,7 @@ export class GitHubRepoConnection extends CommandConnection<GitHubRepoConnection
for (const install of installs.data.installations) {
if (install.account) {
results.push({
name: install.account.login || NAMELESS_ORG_PLACEHOLDER, // org or user name
name: getNameForGitHubAccount(install.account), // org or user name
});
} else {
log.debug(`Skipping install ${install.id}, has no attached account`);

View File

@ -3,10 +3,10 @@ import { Appservice, Space, StateEvent } from "matrix-bot-sdk";
import { Logger } from "matrix-appservice-bridge";
import axios from "axios";
import { GitHubDiscussionSpace } from ".";
import { GithubInstance } from "../Github/GithubInstance";
import { GithubInstance } from "../github/GithubInstance";
import { BaseConnection } from "./BaseConnection";
import { ConfigGrantChecker, GrantChecker } from "../grants/GrantCheck";
import { BridgeConfig } from "../Config/Config";
import { BridgeConfig } from "../config/Config";
const log = new Logger("GitHubOwnerSpace");

View File

@ -5,7 +5,7 @@ import { UserTokenStore } from "../UserTokenStore";
import { Logger } from "matrix-appservice-bridge";
import { CommentProcessor } from "../CommentProcessor";
import { MessageSenderClient } from "../MatrixSender";
import { BridgeConfig, BridgeConfigGitLab, GitLabInstance } from "../Config/Config";
import { BridgeConfig, BridgeConfigGitLab, GitLabInstance } from "../config/Config";
import { GetIssueResponse } from "../Gitlab/Types";
import { IGitLabWebhookNoteEvent } from "../Gitlab/WebhookTypes";
import { ensureUserIsInRoom, getIntentForUser } from "../IntentUtils";

View File

@ -1,18 +1,16 @@
// We need to instantiate some functions which are not directly called, which confuses typescript.
/* eslint-disable @typescript-eslint/ban-ts-comment */
import { UserTokenStore } from "../UserTokenStore";
import { Appservice, Intent, StateEvent } from "matrix-bot-sdk";
import { BotCommands, botCommand, compileBotCommands } from "../BotCommands";
import { MatrixEvent, MatrixMessageContent } from "../MatrixEvent";
import markdown from "markdown-it";
import { Logger } from "matrix-appservice-bridge";
import { BridgeConfigGitLab, GitLabInstance } from "../Config/Config";
import { BridgeConfigGitLab, GitLabInstance } from "../config/Config";
import { IGitlabMergeRequest, IGitlabProject, IGitlabUser, IGitLabWebhookMREvent, IGitLabWebhookNoteEvent, IGitLabWebhookPushEvent, IGitLabWebhookReleaseEvent, IGitLabWebhookTagPushEvent, IGitLabWebhookWikiPageEvent } from "../Gitlab/WebhookTypes";
import { CommandConnection } from "./CommandConnection";
import { Connection, IConnection, IConnectionState, InstantiateConnectionOpts, ProvisionConnectionOpts } from "./IConnection";
import { ConnectionWarning, GetConnectionsResponseItem } from "../provisioning/api";
import { ErrCode, ApiError, ValidatorApiError } from "../api"
import { AccessLevel } from "../Gitlab/Types";
import { AccessLevel, SerializedGitlabDiscussionThreads } from "../Gitlab/Types";
import Ajv, { JSONSchemaType } from "ajv";
import { CommandError } from "../errors";
import QuickLRU from "@alloc/quick-lru";
@ -59,8 +57,6 @@ const log = new Logger("GitLabRepoConnection");
const md = new markdown();
const PUSH_MAX_COMMITS = 5;
const MRRCOMMENT_DEBOUNCE_MS = 5000;
export type GitLabRepoResponseItem = GetConnectionsResponseItem<GitLabRepoConnectionState>;
@ -70,6 +66,7 @@ type AllowedEventsNames =
"merge_request.close" |
"merge_request.merge" |
"merge_request.review" |
"merge_request.review.individual" |
"merge_request.ready_for_review" |
"merge_request.review.comments" |
`merge_request.${string}` |
@ -86,6 +83,7 @@ const AllowedEvents: AllowedEventsNames[] = [
"merge_request.close",
"merge_request.merge",
"merge_request.review",
"merge_request.review.individual",
"merge_request.ready_for_review",
"merge_request.review.comments",
"merge_request",
@ -203,7 +201,7 @@ export class GitLabRepoConnection extends CommandConnection<GitLabRepoConnection
throw new ValidatorApiError(validator.errors);
}
static async createConnectionForState(roomId: string, event: StateEvent<Record<string, unknown>>, {as, intent, tokenStore, config}: InstantiateConnectionOpts) {
static async createConnectionForState(roomId: string, event: StateEvent<Record<string, unknown>>, {as, intent, storage, tokenStore, config}: InstantiateConnectionOpts) {
if (!config.gitlab) {
throw Error('GitLab is not configured');
}
@ -212,7 +210,13 @@ export class GitLabRepoConnection extends CommandConnection<GitLabRepoConnection
if (!instance) {
throw Error('Instance name not recognised');
}
return new GitLabRepoConnection(roomId, event.stateKey, as, config.gitlab, intent, state, tokenStore, instance);
const connection = new GitLabRepoConnection(roomId, event.stateKey, as, config.gitlab, intent, state, tokenStore, instance, storage);
const discussionThreads = await storage.getGitlabDiscussionThreads(connection.connectionId);
connection.setDiscussionThreads(discussionThreads);
return connection;
}
public static async assertUserHasAccessToProject(
@ -240,7 +244,12 @@ export class GitLabRepoConnection extends CommandConnection<GitLabRepoConnection
return permissionLevel;
}
public static async provisionConnection(roomId: string, requester: string, data: Record<string, unknown>, { as, config, intent, tokenStore, getAllConnectionsOfType }: ProvisionConnectionOpts) {
public static async provisionConnection(
roomId: string,
requester: string,
data: Record<string, unknown>,
{ as, config, intent, storage, tokenStore, getAllConnectionsOfType }: ProvisionConnectionOpts
) {
if (!config.gitlab) {
throw Error('GitLab is not configured');
}
@ -258,7 +267,8 @@ export class GitLabRepoConnection extends CommandConnection<GitLabRepoConnection
const project = await client.projects.get(validData.path);
const stateEventKey = `${validData.instance}/${validData.path}`;
const connection = new GitLabRepoConnection(roomId, stateEventKey, as, gitlabConfig, intent, validData, tokenStore, instance);
const connection = new GitLabRepoConnection(roomId, stateEventKey, as, gitlabConfig, intent, validData, tokenStore, instance, storage);
const existingConnections = getAllConnectionsOfType(GitLabRepoConnection);
const existing = existingConnections.find(c => c.roomId === roomId && c.instance.url === connection.instance.url && c.path === connection.path);
@ -380,21 +390,19 @@ export class GitLabRepoConnection extends CommandConnection<GitLabRepoConnection
private readonly debounceMRComments = new Map<string, {
commentCount: number,
commentNotes?: string[],
discussions: string[],
author: string,
timeout: NodeJS.Timeout,
approved?: boolean,
skip?: boolean,
}>();
/**
* GitLab provides NO threading information in its webhook response objects,
* so we need to determine if we've seen a comment for a line before, and
* skip it if we have (because it's probably a reply).
*/
private readonly mergeRequestSeenDiscussionIds = new QuickLRU<string, undefined>({ maxSize: 100 });
private readonly discussionThreads = new QuickLRU<string, Promise<string|undefined>>({ maxSize: 100});
private readonly hookFilter: HookFilter<AllowedEventsNames>;
private readonly grantChecker;
private readonly commentDebounceMs: number;
constructor(
roomId: string,
@ -405,6 +413,7 @@ export class GitLabRepoConnection extends CommandConnection<GitLabRepoConnection
state: ConnectionStateValidated,
private readonly tokenStore: UserTokenStore,
private readonly instance: GitLabInstance,
private readonly storage: IBridgeStorageProvider,
) {
super(
roomId,
@ -425,7 +434,8 @@ export class GitLabRepoConnection extends CommandConnection<GitLabRepoConnection
this.hookFilter = new HookFilter(
state.enableHooks ?? DefaultHooks,
);
}
this.commentDebounceMs = config.commentDebounceMs;
}
public get path() {
return this.state.path.toLowerCase();
@ -721,7 +731,7 @@ ${data.description}`;
});
}
private renderDebouncedMergeRequest(uniqueId: string, mergeRequest: IGitlabMergeRequest, project: IGitlabProject) {
private async renderDebouncedMergeRequest(uniqueId: string, mergeRequest: IGitlabMergeRequest, project: IGitlabProject) {
const result = this.debounceMRComments.get(uniqueId);
if (!result) {
// Always defined, but for type checking purposes.
@ -737,26 +747,52 @@ ${data.description}`;
comments = ` with ${result.commentCount} comments`;
}
let approvalState = 'commented on';
if (result.approved === true) {
approvalState = '✅ approved'
} else if (result.approved === false) {
approvalState = '🔴 unapproved';
let relation;
const discussionWithThread = result.discussions.find(discussionId => this.discussionThreads.has(discussionId));
if (discussionWithThread) {
const threadEventId = await this.discussionThreads.get(discussionWithThread)?.catch(() => { /* already logged */ });
if (threadEventId) {
relation = {
"m.relates_to": {
"event_id": threadEventId,
"rel_type": "m.thread"
},
};
}
}
let content = `**${result.author}** ${approvalState} MR [${orgRepoName}#${mergeRequest.iid}](${mergeRequest.url}): "${mergeRequest.title}"${comments}`;
let action = relation ? 'replied' : 'commented on'; // this is the only place we need this, approve/unapprove don't appear in discussions
if (result.approved === true) {
action = '✅ approved'
} else if (result.approved === false) {
action = '🔴 unapproved';
}
const target = relation ? '' : ` MR [${orgRepoName}#${mergeRequest.iid}](${mergeRequest.url}): "${mergeRequest.title}"`;
let content = `**${result.author}** ${action}${target} ${comments}`;
if (result.commentNotes) {
content += "\n\n> " + result.commentNotes.join("\n\n> ");
}
this.intent.sendEvent(this.roomId, {
const eventPromise = this.intent.sendEvent(this.roomId, {
msgtype: "m.notice",
body: content,
formatted_body: md.renderInline(content),
format: "org.matrix.custom.html",
...relation,
}).catch(ex => {
log.error('Failed to send MR review message', ex);
return undefined;
});
for (const discussionId of result.discussions) {
if (!this.discussionThreads.has(discussionId)) {
this.discussionThreads.set(discussionId, eventPromise);
}
}
void this.persistDiscussionThreads().catch(ex => {
log.error(`Failed to persistently store Gitlab discussion threads for connection ${this.connectionId}:`, ex);
});
}
@ -768,6 +804,7 @@ ${data.description}`;
commentCount: number,
commentNotes?: string[],
approved?: boolean,
discussionId?: string,
/**
* If the MR contains only comments, skip it.
*/
@ -787,16 +824,20 @@ ${data.description}`;
if (!opts.skip) {
existing.skip = false;
}
existing.timeout = setTimeout(() => this.renderDebouncedMergeRequest(uniqueId, mergeRequest, project), MRRCOMMENT_DEBOUNCE_MS);
if (opts.discussionId) {
existing.discussions.push(opts.discussionId);
}
existing.timeout = setTimeout(() => this.renderDebouncedMergeRequest(uniqueId, mergeRequest, project), this.commentDebounceMs);
return;
}
this.debounceMRComments.set(uniqueId, {
commentCount: commentCount,
commentNotes: commentNotes,
discussions: opts.discussionId ? [opts.discussionId] : [],
skip: opts.skip,
approved,
author: user.name,
timeout: setTimeout(() => this.renderDebouncedMergeRequest(uniqueId, mergeRequest, project), MRRCOMMENT_DEBOUNCE_MS),
timeout: setTimeout(() => this.renderDebouncedMergeRequest(uniqueId, mergeRequest, project), this.commentDebounceMs),
});
}
@ -806,10 +847,6 @@ ${data.description}`;
}
log.info(`onMergeRequestReviewed ${this.roomId} ${this.instance}/${this.path} ${event.object_attributes.iid}`);
this.validateMREvent(event);
if (event.object_attributes.action !== "approved" && event.object_attributes.action !== "unapproved") {
// Not interested.
return;
}
this.debounceMergeRequestReview(
event.user,
event.object_attributes,
@ -822,17 +859,24 @@ ${data.description}`;
);
}
private shouldHandleMRComment(event: IGitLabWebhookNoteEvent) {
// Check to see if this line has had a comment before
if (event.object_attributes.discussion_id) {
if (this.mergeRequestSeenDiscussionIds.has(event.object_attributes.discussion_id)) {
// If it has, this is probably a reply. Skip repeated replies.
return false;
}
// Otherwise, record that we have seen the line and continue (it's probably a genuine comment).
this.mergeRequestSeenDiscussionIds.set(event.object_attributes.discussion_id, undefined);
public async onMergeRequestIndividualReview(event: IGitLabWebhookMREvent) {
if (this.hookFilter.shouldSkip('merge_request', 'merge_request.review.individual') || !this.matchesLabelFilter(event)) {
return;
}
return true;
log.info(`onMergeRequestReviewed ${this.roomId} ${this.instance}/${this.path} ${event.object_attributes.iid}`);
this.validateMREvent(event);
this.debounceMergeRequestReview(
event.user,
event.object_attributes,
event.project,
{
commentCount: 0,
approved: "approved" === event.object_attributes.action,
skip: false,
}
);
}
public async onCommentCreated(event: IGitLabWebhookNoteEvent) {
@ -844,13 +888,11 @@ ${data.description}`;
// Not a MR comment
return;
}
if (!this.shouldHandleMRComment(event)) {
// Skip it.
return;
}
this.debounceMergeRequestReview(event.user, event.merge_request, event.project, {
commentCount: 1,
commentNotes: this.state.includeCommentBody ? [event.object_attributes.note] : undefined,
discussionId: event.object_attributes.discussion_id,
skip: this.hookFilter.shouldSkip('merge_request.review.comments'),
});
}
@ -894,6 +936,24 @@ ${data.description}`;
}
// TODO: Clean up webhooks
}
private setDiscussionThreads(discussionThreads: SerializedGitlabDiscussionThreads): void {
for (const { discussionId, eventId } of discussionThreads) {
this.discussionThreads.set(discussionId, Promise.resolve(eventId));
}
}
private async persistDiscussionThreads(): Promise<void> {
const serialized: SerializedGitlabDiscussionThreads = [];
for (const [discussionId, eventIdPromise] of this.discussionThreads.entriesAscending()) {
const eventId = await eventIdPromise.catch(() => { /* logged elsewhere */ });
if (eventId) {
serialized.push({ discussionId, eventId });
}
}
return this.storage.setGitlabDiscussionThreads(this.connectionId, serialized);
}
}
// Typescript doesn't understand Prototypes very well yet.

View File

@ -2,12 +2,12 @@ import { MatrixEvent, MatrixMessageContent } from "../MatrixEvent";
import { IssuesOpenedEvent, IssuesEditedEvent } from "@octokit/webhooks-types";
import { ConnectionWarning, GetConnectionsResponseItem } from "../provisioning/api";
import { Appservice, Intent, IRichReplyMetadata, StateEvent } from "matrix-bot-sdk";
import { BridgeConfig, BridgePermissionLevel } from "../Config/Config";
import { BridgeConfig, BridgePermissionLevel } from "../config/Config";
import { UserTokenStore } from "../UserTokenStore";
import { CommentProcessor } from "../CommentProcessor";
import { MessageSenderClient } from "../MatrixSender";
import { IBridgeStorageProvider } from "../Stores/StorageProvider";
import { GithubInstance } from "../Github/GithubInstance";
import { GithubInstance } from "../github/GithubInstance";
import "reflect-metadata";
export type PermissionCheckFn = (service: string, level: BridgePermissionLevel) => boolean;

View File

@ -1,11 +1,11 @@
import { Connection, IConnection, IConnectionState, InstantiateConnectionOpts, ProvisionConnectionOpts } from "./IConnection";
import { Appservice, Intent, StateEvent } from "matrix-bot-sdk";
import { Logger } from "matrix-appservice-bridge";
import { JiraIssueEvent, JiraIssueUpdatedEvent, JiraVersionEvent } from "../Jira/WebhookTypes";
import { JiraIssueEvent, JiraIssueUpdatedEvent, JiraVersionEvent } from "../jira/WebhookTypes";
import { FormatUtil } from "../FormatUtil";
import markdownit from "markdown-it";
import { generateJiraWebLinkFromIssue, generateJiraWebLinkFromVersion } from "../Jira";
import { JiraProject, JiraVersion } from "../Jira/Types";
import { generateJiraWebLinkFromIssue, generateJiraWebLinkFromVersion } from "../jira";
import { JiraProject, JiraVersion } from "../jira/Types";
import { botCommand, BotCommands, compileBotCommands } from "../BotCommands";
import { MatrixMessageContent } from "../MatrixEvent";
import { CommandConnection } from "./CommandConnection";
@ -14,10 +14,10 @@ import { CommandError, NotLoggedInError } from "../errors";
import { ApiError, ErrCode } from "../api";
import JiraApi from "jira-client";
import { GetConnectionsResponseItem } from "../provisioning/api";
import { BridgeConfigJira } from "../Config/Config";
import { HookshotJiraApi } from "../Jira/Client";
import { BridgeConfigJira } from "../config/Config";
import { HookshotJiraApi } from "../jira/Client";
import { GrantChecker } from "../grants/GrantCheck";
import { JiraGrantChecker } from "../Jira/GrantChecker";
import { JiraGrantChecker } from "../jira/GrantChecker";
type JiraAllowedEventsNames =
"issue_created" |

View File

@ -1,9 +1,9 @@
// We need to instantiate some functions which are not directly called, which confuses typescript.
import { BotCommands, botCommand, compileBotCommands, HelpFunction } from "../BotCommands";
import { CommandConnection } from "./CommandConnection";
import { GenericHookConnection, GitHubRepoConnection, JiraProjectConnection, JiraProjectConnectionState } from ".";
import { GenericHookConnection, GenericHookConnectionState, GitHubRepoConnection, JiraProjectConnection, JiraProjectConnectionState } from ".";
import { CommandError } from "../errors";
import { BridgePermissionLevel } from "../Config/Config";
import { BridgePermissionLevel } from "../config/Config";
import markdown from "markdown-it";
import { FigmaFileConnection } from "./FigmaFileConnection";
import { FeedConnection, FeedConnectionState } from "./FeedConnection";
@ -14,6 +14,7 @@ import { GitLabRepoConnection } from "./GitlabRepo";
import { IConnection, IConnectionState, ProvisionConnectionOpts } from "./IConnection";
import { ApiError, Logger } from "matrix-appservice-bridge";
import { Intent } from "matrix-bot-sdk";
import YAML from 'yaml';
const md = new markdown();
const log = new Logger("SetupConnection");
@ -231,6 +232,58 @@ export class SetupConnection extends CommandConnection {
return this.client.sendNotice(this.roomId, `Room configured to bridge webhooks. See admin room for secret url.`);
}
@botCommand("webhook list", { help: "Show webhooks currently configured.", category: "generic"})
public async onWebhookList() {
const webhooks: GenericHookConnectionState[] = await this.client.getRoomState(this.roomId).catch((err: any) => {
if (err.body.errcode === 'M_NOT_FOUND') {
return []; // not an error to us
}
throw err;
}).then(events =>
events.filter(
(ev: any) => ev.type === GenericHookConnection.CanonicalEventType && ev.content.name
).map(ev => ev.content)
);
if (webhooks.length === 0) {
return this.client.sendHtmlNotice(this.roomId, md.renderInline('No webhooks configured'));
} else {
const feedDescriptions = webhooks.sort(
(a, b) => a.name.localeCompare(b.name)
).map(feed => {
return feed.name;
});
return this.client.sendHtmlNotice(this.roomId, md.render(
'Webhooks configured:\n\n' +
feedDescriptions.map(desc => ` - ${desc}`).join('\n')
));
}
}
@botCommand("webhook remove", { help: "Remove a webhook from the room.", requiredArgs: ["name"], includeUserId: true, category: "generic"})
public async onWebhookRemove(userId: string, name: string) {
await this.checkUserPermissions(userId, "generic", GenericHookConnection.CanonicalEventType);
const event = await this.client.getRoomStateEvent(this.roomId, GenericHookConnection.CanonicalEventType, name).catch((err: any) => {
if (err.body.errcode === 'M_NOT_FOUND') {
return null; // not an error to us
}
throw err;
});
if (!event || event.disabled === true || Object.keys(event).length === 0) {
throw new CommandError("Invalid webhook name", `No webhook by the name of "${name}" is configured.`);
}
await this.client.sendStateEvent(this.roomId, GenericHookConnection.CanonicalEventType, name, {
disabled: true
});
return this.client.sendHtmlNotice(this.roomId, md.renderInline(`Removed webhook \`${name}\``));
}
@botCommand("figma file", { help: "Bridge a Figma file to the room.", requiredArgs: ["url"], includeUserId: true, category: "figma"})
public async onFigma(userId: string, url: string) {
if (!this.config.figma) {
@ -274,8 +327,11 @@ export class SetupConnection extends CommandConnection {
return this.client.sendHtmlNotice(this.roomId, md.renderInline(`Room configured to bridge \`${url}\``));
}
@botCommand("feed list", { help: "Show feeds currently subscribed to.", category: "feeds"})
public async onFeedList() {
@botCommand("feed list", { help: "Show feeds currently subscribed to. Supported formats `json` and `yaml`.", optionalArgs: ["format"], category: "feeds"})
public async onFeedList(format?: string) {
const useJsonFormat = format?.toLowerCase() === 'json';
const useYamlFormat = format?.toLowerCase() === 'yaml';
const feeds: FeedConnectionState[] = await this.client.getRoomState(this.roomId).catch((err: any) => {
if (err.body.errcode === 'M_NOT_FOUND') {
return []; // not an error to us
@ -290,17 +346,28 @@ export class SetupConnection extends CommandConnection {
if (feeds.length === 0) {
return this.client.sendHtmlNotice(this.roomId, md.renderInline('Not subscribed to any feeds'));
} else {
const feedDescriptions = feeds.map(feed => {
const feedDescriptions = feeds.sort(
(a, b) => (a.label ?? a.url).localeCompare(b.label ?? b.url)
).map(feed => {
if (useJsonFormat || useYamlFormat) {
return feed;
}
if (feed.label) {
return `[${feed.label}](${feed.url})`;
}
return feed.url;
});
return this.client.sendHtmlNotice(this.roomId, md.render(
'Currently subscribed to these feeds:\n\n' +
feedDescriptions.map(desc => ` - ${desc}`).join('\n')
));
let message = 'Currently subscribed to these feeds:\n';
if (useJsonFormat) {
message += `\`\`\`json\n${JSON.stringify(feedDescriptions, null, 4)}\n\`\`\``
} else if (useYamlFormat) {
message += `\`\`\`yaml\n${YAML.stringify(feedDescriptions)}\`\`\``
} else {
message += feedDescriptions.map(desc => `- ${desc}`).join('\n')
}
return this.client.sendHtmlNotice(this.roomId, md.render(message));
}
}

Some files were not shown because too many files have changed in this diff Show More