From 7fd79596ca70bd0a86ecd968ff7da103ac23366d Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Wed, 14 May 2025 14:06:28 +0100 Subject: [PATCH 01/22] Apply prettier --- docs/SUMMARY.md | 1 + docs/_site/main.js | 72 +- docs/_site/style.css | 62 +- docs/_site/version.js | 2 +- docs/advanced/encryption.md | 10 +- docs/advanced/provisioning.md | 3 +- docs/advanced/service_bots.md | 21 +- docs/advanced/widgets.md | 50 +- docs/advanced/workers.md | 4 +- docs/metrics.md | 131 +- docs/sentry.md | 3 +- docs/setup.md | 51 +- docs/setup/figma.md | 16 +- docs/setup/github.md | 24 +- docs/setup/gitlab.md | 2 - docs/setup/jira.md | 19 +- docs/setup/openproject.md | 19 +- docs/setup/webhooks.md | 49 +- docs/troubleshooting.md | 3 +- docs/usage.md | 3 +- docs/usage/auth.md | 35 +- docs/usage/dynamic_rooms.md | 4 +- docs/usage/room_configuration.md | 5 +- docs/usage/room_configuration/github_repo.md | 70 +- .../room_configuration/gitlab_project.md | 49 +- docs/usage/room_configuration/jira_project.md | 14 +- package.json | 4 +- scripts/build-metrics-docs.ts | 45 +- scripts/definitions-fixer.ts | 16 +- spec/basic.spec.ts | 45 +- spec/e2ee.spec.ts | 126 +- spec/generic-hooks.spec.ts | 244 +- spec/github.spec.ts | 262 +- spec/jira.spec.ts | 299 +- spec/openproject.spec.ts | 4 +- spec/permissions.spec.ts | 162 +- spec/util/bridge-api.ts | 24 +- spec/util/containers.ts | 448 +-- spec/util/e2e-test.ts | 826 ++-- spec/util/fixtures.ts | 5 +- spec/util/helpers.ts | 22 +- spec/util/homerunner.ts | 198 +- spec/webhooks.spec.ts | 420 ++- spec/widgets.spec.ts | 63 +- src/AdminRoom.ts | 1281 ++++--- src/AdminRoomCommandHandler.ts | 82 +- src/App/BridgeApp.ts | 142 +- src/App/GithubWebhookApp.ts | 59 +- src/App/MatrixSenderApp.ts | 61 +- src/App/ResetCryptoStore.ts | 147 +- src/BotCommands.ts | 293 +- src/Bridge.ts | 3321 ++++++++++------- src/CommentProcessor.ts | 312 +- src/ConnectionManager.ts | 1168 +++--- src/Connections/BaseConnection.ts | 27 +- src/Connections/CommandConnection.ts | 160 +- src/Connections/FeedConnection.ts | 530 +-- src/Connections/FigmaFileConnection.ts | 343 +- src/Connections/GenericHook.ts | 1232 +++--- src/Connections/GithubDiscussion.ts | 393 +- src/Connections/GithubDiscussionSpace.ts | 399 +- src/Connections/GithubIssue.ts | 795 ++-- src/Connections/GithubProject.ts | 177 +- src/Connections/GithubRepo.ts | 3114 +++++++++------- src/Connections/GithubUserSpace.ts | 340 +- src/Connections/GitlabIssue.ts | 503 ++- src/Connections/GitlabRepo.ts | 2054 +++++----- src/Connections/HoundConnection.ts | 417 ++- src/Connections/IConnection.ts | 226 +- src/Connections/JiraProject.ts | 1248 ++++--- src/Connections/OpenProjectConnection.ts | 740 ++-- src/Connections/OutboundHook.ts | 556 +-- src/Connections/SetupConnection.ts | 1397 ++++--- src/Connections/index.ts | 2 +- src/FormatUtil.ts | 204 +- src/Gitlab/Client.ts | 393 +- src/Gitlab/GrantChecker.ts | 69 +- src/Gitlab/Types.ts | 347 +- src/Gitlab/WebhookTypes.ts | 336 +- src/HookFilter.ts | 47 +- src/IntentUtils.ts | 129 +- src/ListenerService.ts | 178 +- src/Managers/BotUsersManager.ts | 587 +-- src/MatrixEvent.ts | 56 +- src/MatrixSender.ts | 192 +- src/MessageQueue/LocalMQ.ts | 102 +- src/MessageQueue/MessageQueue.ts | 16 +- src/MessageQueue/RedisQueue.ts | 246 +- src/MessageQueue/Types.ts | 35 +- src/MessageQueue/index.ts | 2 +- src/Metrics.ts | 320 +- src/NotificationFilters.ts | 143 +- src/Notifications/GitHubWatcher.ts | 271 +- src/Notifications/GitLabWatcher.ts | 53 +- src/Notifications/NotificationWatcherTask.ts | 20 +- src/Notifications/UserNotificationWatcher.ts | 213 +- src/NotificationsProcessor.ts | 524 +-- src/PromiseUtil.ts | 68 +- src/Stores/MemoryStorageProvider.ts | 380 +- src/Stores/RedisStorageProvider.ts | 617 +-- src/Stores/StorageProvider.ts | 122 +- src/Webhooks.ts | 681 ++-- src/Widgets/BridgeWidgetApi.ts | 770 ++-- src/Widgets/BridgeWidgetInterface.ts | 68 +- src/Widgets/SetupWidget.ts | 175 +- src/Widgets/WidgetKind.ts | 8 +- src/Widgets/api.ts | 184 +- src/api/error.ts | 226 +- src/api/index.ts | 2 +- src/appservice.ts | 102 +- src/config/Config.ts | 1383 ++++--- src/config/Decorators.ts | 16 +- src/config/Defaults.ts | 480 +-- src/config/sections/cache.ts | 12 +- src/config/sections/encryption.ts | 43 +- src/config/sections/generichooks.ts | 118 +- src/config/sections/index.ts | 2 +- src/config/sections/openproject.ts | 89 +- src/config/sections/queue.ts | 22 +- src/errors.ts | 38 +- src/feeds/FeedReader.ts | 648 ++-- src/figma/index.ts | 202 +- src/figma/router.ts | 73 +- src/figma/types.ts | 38 +- src/generic/Router.ts | 208 +- src/generic/transformer.ts | 316 +- src/generic/types.ts | 24 +- src/github/AdminCommands.ts | 157 +- src/github/Discussion.ts | 32 +- src/github/GithubInstance.ts | 500 ++- src/github/GrantChecker.ts | 66 +- src/github/Router.ts | 408 +- src/github/Types.ts | 130 +- src/grants/GrantCheck.ts | 207 +- src/hound/reader.ts | 286 +- src/jira/AdminCommands.ts | 135 +- src/jira/Client.ts | 122 +- src/jira/GrantChecker.ts | 55 +- src/jira/OAuth.ts | 33 +- src/jira/Router.ts | 484 ++- src/jira/Types.ts | 189 +- src/jira/WebhookTypes.ts | 50 +- src/jira/client/CloudClient.ts | 317 +- src/jira/client/OnPremClient.ts | 175 +- src/jira/index.ts | 3 +- src/jira/oauth/CloudOAuth.ts | 79 +- src/jira/oauth/OnPremOAuth.ts | 496 ++- src/libRs.js | 18 +- src/openproject/GrantChecker.ts | 53 +- src/openproject/Router.ts | 160 +- src/openproject/adminCommands.ts | 40 +- src/openproject/client.ts | 147 +- src/openproject/format.ts | 237 +- src/openproject/oauth.ts | 152 +- src/openproject/state.ts | 50 +- src/openproject/types.ts | 160 +- src/openproject/widgetApi.ts | 42 +- src/tokens/UserTokenStore.ts | 687 ++-- src/tokens/oauth.ts | 14 +- tests/AdminRoomTest.ts | 50 +- tests/FeedReader.spec.ts | 316 +- tests/FormatUtilTest.ts | 237 +- tests/HookFilter.ts | 120 +- tests/IntentUtilsTest.ts | 102 +- tests/MessageQueueTest.ts | 84 +- tests/config/config.ts | 164 +- tests/config/permissions.ts | 278 +- tests/connections/FeedTest.spec.ts | 217 +- tests/connections/GenericHookTest.ts | 1014 ++--- tests/connections/GithubRepoTest.ts | 400 +- tests/connections/GitlabRepoTest.ts | 655 ++-- tests/github/AdminCommands.ts | 102 +- tests/grants/GrantChecker.spec.ts | 350 +- tests/init.ts | 2 +- tests/jira/Utils.ts | 57 +- tests/tokens/tokenencryption.spec.ts | 178 +- tests/utils/AppserviceMock.ts | 50 +- tests/utils/IntentMock.ts | 242 +- web/App.tsx | 291 +- web/BridgeAPI.ts | 381 +- web/components/AdminSettings.module.scss | 58 +- web/components/AdminSettings.tsx | 161 +- web/components/ConnectionCard.module.scss | 87 +- web/components/ConnectionCard.tsx | 34 +- web/components/GitHubState.css | 6 +- web/components/GitHubState.tsx | 34 +- web/components/RoomConfigView.module.scss | 44 +- web/components/RoomConfigView.tsx | 223 +- web/components/ServiceCard.module.scss | 24 +- web/components/ServiceCard.tsx | 25 +- web/components/configs/GeneralConfig.tsx | 22 +- web/components/elements/Button.module.scss | 42 +- web/components/elements/Button.tsx | 14 +- web/components/elements/ButtonSet.module.scss | 8 +- web/components/elements/ButtonSet.tsx | 6 +- web/components/elements/Card.tsx | 12 +- .../elements/DropdownSearch.module.scss | 49 +- .../elements/EventHookCheckbox.module.scss | 8 +- web/components/elements/EventHookCheckbox.tsx | 107 +- .../elements/InputField.module.scss | 109 +- web/components/elements/InputField.tsx | 40 +- web/components/elements/ListItem.module.scss | 8 +- web/components/elements/ListItem.tsx | 32 +- .../elements/LoadingSpinner.module.scss | 24 +- web/components/elements/LoadingSpinner.tsx | 136 +- web/components/roomConfig/Auth.tsx | 153 +- .../roomConfig/FeedConnection.module.scss | 6 +- web/components/roomConfig/FeedsConfig.tsx | 260 +- .../roomConfig/GenericWebhookConfig.tsx | 420 ++- .../roomConfig/GithubRepoConfig.tsx | 626 +++- .../roomConfig/GitlabRepoConfig.tsx | 425 ++- .../roomConfig/JiraProjectConfig.tsx | 313 +- .../roomConfig/OutboundWebhookConfig.tsx | 197 +- .../roomConfig/RoomConfig.module.scss | 36 +- web/components/roomConfig/RoomConfig.tsx | 425 ++- web/context.ts | 11 +- web/fonts/fonts.scss | 6 +- web/index.html | 2 +- web/index.tsx | 14 +- web/oauth.html | 10 +- web/oauth.scss | 2 +- web/oauth.tsx | 106 +- web/styling.scss | 13 +- web/tsconfig.json | 4 +- web/typings/images.d.ts | 6 +- web/typings/sass.d.ts | 8 +- yarn.lock | 5 + 227 files changed, 30499 insertions(+), 21888 deletions(-) diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index 7e1701dfc..0dde03836 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -23,6 +23,7 @@ - [🆘 Troubleshooting](./troubleshooting.md) # 🧑‍💻 Development + - [Contributing](./contributing.md) # 🥼 Advanced diff --git a/docs/_site/main.js b/docs/_site/main.js index 9e208f801..0bc745696 100644 --- a/docs/_site/main.js +++ b/docs/_site/main.js @@ -1,40 +1,46 @@ window.addEventListener("load", () => { - const scrollbox = document.querySelector(".sidebar-scrollbox"); - scrollbox.innerHTML = `
Version:
${scrollbox.innerHTML}`; - const currentVersion = window.HOOKSHOT_VERSION || 'latest'; + const scrollbox = document.querySelector(".sidebar-scrollbox"); + scrollbox.innerHTML = `
Version:
${scrollbox.innerHTML}`; + const currentVersion = window.HOOKSHOT_VERSION || "latest"; - const selectElement = document.createElement("select"); - - fetch("https://api.github.com/repos/matrix-org/matrix-hookshot/releases", { - cache: "force-cache", - }).then(res => - res.json() - ).then(releases => { - selectElement.innerHTML = ""; - 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); - if (currentVersion === version) { - option.setAttribute('selected', ''); - } + const selectElement = document.createElement("select"); + + fetch("https://api.github.com/repos/matrix-org/matrix-hookshot/releases", { + cache: "force-cache", + }) + .then((res) => res.json()) + .then((releases) => { + selectElement.innerHTML = ""; + 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); + if (currentVersion === version) { + option.setAttribute("selected", ""); } - }).catch(ex => { - console.error("Failed to fetch version data", ex); + } }) - - const option = document.createElement("option"); - option.innerHTML = 'loading...'; - selectElement.add(option); - - selectElement.addEventListener('change', (event) => { - const path = [ - ...window.location.pathname.split('/').slice(0, 2), - event.target.value, - ...window.location.pathname.split('/').slice(3), - ].join('/'); - window.location = `${window.location.origin}${path}`; + .catch((ex) => { + console.error("Failed to fetch version data", ex); }); - document.querySelector(".version-box").appendChild(selectElement); + const option = document.createElement("option"); + option.innerHTML = "loading..."; + selectElement.add(option); + + selectElement.addEventListener("change", (event) => { + const path = [ + ...window.location.pathname.split("/").slice(0, 2), + event.target.value, + ...window.location.pathname.split("/").slice(3), + ].join("/"); + window.location = `${window.location.origin}${path}`; + }); + + document.querySelector(".version-box").appendChild(selectElement); }); diff --git a/docs/_site/style.css b/docs/_site/style.css index 12beda021..4fe146c79 100644 --- a/docs/_site/style.css +++ b/docs/_site/style.css @@ -1,67 +1,73 @@ .notice { - color: black; - border: 2px solid #0098d4; - border-left-width: 2px; - border-left-width: 5px; - background: #e5f5fb; - padding: 10px 20px; + color: black; + border: 2px solid #0098d4; + border-left-width: 2px; + border-left-width: 5px; + background: #e5f5fb; + padding: 10px 20px; } .notice::before { - content: "INFO: "; - font-weight: 700; + content: "INFO: "; + font-weight: 700; } .warning { - color: black; - border: 2px solid #dda02f; - border-left-width: 2px; - border-left-width: 5px; - background: #fdcd74; - padding: 10px 20px; + color: black; + border: 2px solid #dda02f; + border-left-width: 2px; + border-left-width: 5px; + background: #fdcd74; + padding: 10px 20px; } .warning::before { - content: "WARNING: "; - font-weight: 700; + content: "WARNING: "; + font-weight: 700; } - /* icons for headers */ /* We use base64 to avoid having to deal with pathing issues. */ .chapter > li:nth-child(3) > ol:nth-child(1) > li:nth-child(2) strong:after { - content: ' ' url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAMAAAAoLQ9TAAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAABGlBMVEXqeBnqeBnqeBnqdxfqdhXvl030tYLxpWbuj0DrfCDqdhbqeBj0uYj////99Oz51rnxpGTrfSHwnlr40bD64cz+9/H//Pr40K/tjDvqeRvqehzrfiLuk0f2w5n99e375dLuk0j40rP0uIbukUPvmlL75NHtizr0uYf1wJTrgCftjj775dP4z63qdxjukEH0toP52Lz++/j40rLvm1T+9e/xomDpdBLqeh3yqW398un1wJP2xp3407Xqex7rfSLxpGXxo2Pyqm7++/n98+vvlUv++PPtjTzxp2j52b70t4TrfyT75ND//v7xomHxqGv//fz//Pvxpmf0uIX40bH41LXzs33rfiPyqWzukUT0u4vxp2nwoV/1vI3vl04j+OXeAAAAAnRSTlNd4NmMDFkAAAABYktHRA32tGH1AAAAB3RJTUUH5gQWESsnQqV9+QAAAM1JREFUGNNjYGBkQgKMDEA+MwsLM5IIEzMrGzsHJxc3VISBiYWHl5ePX0CQGSbALCQsIsorJi7BDBVgkpSSlpGV45VXYIaqYFdUUpZUkedVVWOGmKEONENDU0ubV4eTGySgq6dvYMhrpGlsYmjKBTaD2czcwpLXStKa18YWJMBtZ+9g6+jkrOfiaunGDBTgchczFLD14PX08vbxZQEJ+PkHBJoF8Qabh/CGggS4w8L97LgiIqMko2NigVoYmbi5gB7R5WJmYgZisHdRvA8A4+sWdRXffIcAAAAldEVYdGRhdGU6Y3JlYXRlADIwMjItMDQtMjJUMTc6NDI6MDcrMDA6MDAJz20rAAAAJXRFWHRkYXRlOm1vZGlmeQAyMDIyLTA0LTIyVDE3OjQyOjA3KzAwOjAweJLVlwAAAABJRU5ErkJggg=='); + content: " " + url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAMAAAAoLQ9TAAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAABGlBMVEXqeBnqeBnqeBnqdxfqdhXvl030tYLxpWbuj0DrfCDqdhbqeBj0uYj////99Oz51rnxpGTrfSHwnlr40bD64cz+9/H//Pr40K/tjDvqeRvqehzrfiLuk0f2w5n99e375dLuk0j40rP0uIbukUPvmlL75NHtizr0uYf1wJTrgCftjj775dP4z63qdxjukEH0toP52Lz++/j40rLvm1T+9e/xomDpdBLqeh3yqW398un1wJP2xp3407Xqex7rfSLxpGXxo2Pyqm7++/n98+vvlUv++PPtjTzxp2j52b70t4TrfyT75ND//v7xomHxqGv//fz//Pvxpmf0uIX40bH41LXzs33rfiPyqWzukUT0u4vxp2nwoV/1vI3vl04j+OXeAAAAAnRSTlNd4NmMDFkAAAABYktHRA32tGH1AAAAB3RJTUUH5gQWESsnQqV9+QAAAM1JREFUGNNjYGBkQgKMDEA+MwsLM5IIEzMrGzsHJxc3VISBiYWHl5ePX0CQGSbALCQsIsorJi7BDBVgkpSSlpGV45VXYIaqYFdUUpZUkedVVWOGmKEONENDU0ubV4eTGySgq6dvYMhrpGlsYmjKBTaD2czcwpLXStKa18YWJMBtZ+9g6+jkrOfiaunGDBTgchczFLD14PX08vbxZQEJ+PkHBJoF8Qabh/CGggS4w8L97LgiIqMko2NigVoYmbi5gB7R5WJmYgZisHdRvA8A4+sWdRXffIcAAAAldEVYdGRhdGU6Y3JlYXRlADIwMjItMDQtMjJUMTc6NDI6MDcrMDA6MDAJz20rAAAAJXRFWHRkYXRlOm1vZGlmeQAyMDIyLTA0LTIyVDE3OjQyOjA3KzAwOjAweJLVlwAAAABJRU5ErkJggg=="); } .chapter > li:nth-child(3) > ol:nth-child(1) > li:nth-child(3) strong:after { - content: ' ' url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAABcWlDQ1BpY2MAACiRdZE9S8NQFIbftkpFKx3qIOKQoYpDC0VFOmoduhQptYJVl+Q2aYUkDTcpUlwFF4eCg+ji1+A/0FVwVRAERRBx8gf4tUiJ5zaFFmlPuDkP7z3v4d5zAX9GZ4bdlwAM0+G5dEpaLaxJwXf4EEIEs0jKzLYWstkMesbPI1VTPMRFr951XWOoqNoM8A0QzzGLO8TzxJktxxK8RzzCynKR+IQ4xumAxLdCVzx+E1zy+Eswz+cWAb/oKZU6WOlgVuYG8RRx1NCrrHUecZOQaq4sUx6jNQ4bOaSRggQFVWxCh4M4ZZNm1t2XaPqWUCEPo7+FGjg5SiiTN0ZqlbqqlDXSVfp01MTc/8/T1mamve6hFND/6rqfE0BwH2jUXff31HUbZ0DgBbg22/4KzSn5TXq9rUWPgfAOcHnT1pQD4GoXGH22ZC43pQAtv6YBHxfAcAGI3AOD696sWvs4fwLy2/REd8DhETBJ9eGNPzxOaCbSjI5SAAAACXBIWXMAAAsSAAALEgHS3X78AAABAUlEQVQ4T6WSPRIBQRCFu3sTCSVUiswF3GBUCclFbDmCXIDYGTYQkG0gQ5W9AXcQCGUEmDb+p836qfJF+95uv9l51QB/gq/GppJTHlFoHtNnnSyUny+RI+wHpacBQLbYVPO+GZ7DbdiBUXGrybYlAjzEwNa/IAKAORL6B0RAarwS94tF644tnRIHNVaaIES+9lAfImame0Xohebyy3WZ3pcY1NhnhPl9+ExmcvDN8KVYc5rKzvSnEsEpkYgcz0YEMENk64sHrmcjAhojdEp8vTPw8XOJiUVPmdTHJm6LbecbG/EHiUXXN8b7TYxBBBD8uYnfCotDBOyKbVGYBrl1cZwAOfxEBEfXmiUAAAAASUVORK5CYII='); + content: " " + url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAABcWlDQ1BpY2MAACiRdZE9S8NQFIbftkpFKx3qIOKQoYpDC0VFOmoduhQptYJVl+Q2aYUkDTcpUlwFF4eCg+ji1+A/0FVwVRAERRBx8gf4tUiJ5zaFFmlPuDkP7z3v4d5zAX9GZ4bdlwAM0+G5dEpaLaxJwXf4EEIEs0jKzLYWstkMesbPI1VTPMRFr951XWOoqNoM8A0QzzGLO8TzxJktxxK8RzzCynKR+IQ4xumAxLdCVzx+E1zy+Eswz+cWAb/oKZU6WOlgVuYG8RRx1NCrrHUecZOQaq4sUx6jNQ4bOaSRggQFVWxCh4M4ZZNm1t2XaPqWUCEPo7+FGjg5SiiTN0ZqlbqqlDXSVfp01MTc/8/T1mamve6hFND/6rqfE0BwH2jUXff31HUbZ0DgBbg22/4KzSn5TXq9rUWPgfAOcHnT1pQD4GoXGH22ZC43pQAtv6YBHxfAcAGI3AOD696sWvs4fwLy2/REd8DhETBJ9eGNPzxOaCbSjI5SAAAACXBIWXMAAAsSAAALEgHS3X78AAABAUlEQVQ4T6WSPRIBQRCFu3sTCSVUiswF3GBUCclFbDmCXIDYGTYQkG0gQ5W9AXcQCGUEmDb+p836qfJF+95uv9l51QB/gq/GppJTHlFoHtNnnSyUny+RI+wHpacBQLbYVPO+GZ7DbdiBUXGrybYlAjzEwNa/IAKAORL6B0RAarwS94tF644tnRIHNVaaIES+9lAfImame0Xohebyy3WZ3pcY1NhnhPl9+ExmcvDN8KVYc5rKzvSnEsEpkYgcz0YEMENk64sHrmcjAhojdEp8vTPw8XOJiUVPmdTHJm6LbecbG/EHiUXXN8b7TYxBBBD8uYnfCotDBOyKbVGYBrl1cZwAOfxEBEfXmiUAAAAASUVORK5CYII="); } .chapter > li:nth-child(3) > ol:nth-child(1) > li:nth-child(4) strong:after { - content: ' ' url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAABcWlDQ1BpY2MAACiRdZE9S8NQFIbftkpFKx3qIOKQoYpDC8UvHLUOXYqUWsGqS3KbtEKShpsUKa6Ci0PBQXTxa/Af6Cq4KgiCIog4+QP8WqTEc5tCi7Qn3JyH9573cO+5gD+tM8PuSQCG6fBsKimt5tek4Dt8CCGCGUzLzLYWMpk0usbPI1VTPMRFr+51HWOgoNoM8PURzzKLO8TzxOktxxK8RzzESnKB+IQ4xumAxLdCVzx+E1z0+Eswz2UXAb/oKRXbWGljVuIG8QRx1NArrHkecZOQaq4sUx6hNQobWaSQhAQFFWxCh4M4ZZNm1tmXaPiWUCYPo7+FKjg5iiiRN0ZqhbqqlDXSVfp0VMXc/8/T1qYmve6hJND76rqfY0BwH6jXXPf31HXrZ0DgBbg2W/4yzWnum/RaS4seA+Ed4PKmpSkHwNUuMPxsyVxuSAFafk0DPi6AwTwQuQf6171ZNfdx/gTktumJ7oDDI2Cc6sMbfzXVaCPmlJtVAAAACXBIWXMAAC4jAAAuIwF4pT92AAABl0lEQVQ4T5WTvU4CQRDHd5dTEqvDGMHuiBAhBF0KE0veAEo7fQOxsvYJxCdQ38BHwM7ChAU1nB8EOj9ilARjAh433lyYc0HQ+Csu/5mbmZ3NzHI2ASml2f2EA85Ykblgcs4Vd3n51lYn47FcNzDxvee+6b5xuMHKd5f13cAmkcxkJLihKtm/AYy3m41aHLXAD54cJHM41WJHEbzjX4eBtZxea6HL7yCRXgWKuW/UfV9KSstWqk1+KfOmUpVOSm5YTu/DTzbCIi5kPm9SkI6ejGCyLxzHIp/Th6roPr2WySFAbJOehn11UQkMAFNwwQtkTxrTJLwcRVrgnPWf/wU7CKolstmgm98AFyRpb4zuYfDH4dNHOGQlLXd0Oxij18r+AGALZxxionTTUN+FPZLJdclm+0f66QDiOIQiEo1GPGtvJixygwErAoPNxdjS2cvzY5uC56MLD94KxshGmnYtZ/jiul7yNqvg9NxWc7hIf2GE5/xVHgnG9cQroKaNJPRtxWRbnbdR+2+BwAdClX/gvQO8MxamZOQLp/igo1dFFCgAAAAASUVORK5CYII='); + content: " " + url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAABcWlDQ1BpY2MAACiRdZE9S8NQFIbftkpFKx3qIOKQoYpDC8UvHLUOXYqUWsGqS3KbtEKShpsUKa6Ci0PBQXTxa/Af6Cq4KgiCIog4+QP8WqTEc5tCi7Qn3JyH9573cO+5gD+tM8PuSQCG6fBsKimt5tek4Dt8CCGCGUzLzLYWMpk0usbPI1VTPMRFr+51HWOgoNoM8PURzzKLO8TzxOktxxK8RzzESnKB+IQ4xumAxLdCVzx+E1z0+Eswz2UXAb/oKRXbWGljVuIG8QRx1NArrHkecZOQaq4sUx6hNQobWaSQhAQFFWxCh4M4ZZNm1tmXaPiWUCYPo7+FKjg5iiiRN0ZqhbqqlDXSVfp0VMXc/8/T1qYmve6hJND76rqfY0BwH6jXXPf31HXrZ0DgBbg2W/4yzWnum/RaS4seA+Ed4PKmpSkHwNUuMPxsyVxuSAFafk0DPi6AwTwQuQf6171ZNfdx/gTktumJ7oDDI2Cc6sMbfzXVaCPmlJtVAAAACXBIWXMAAC4jAAAuIwF4pT92AAABl0lEQVQ4T5WTvU4CQRDHd5dTEqvDGMHuiBAhBF0KE0veAEo7fQOxsvYJxCdQ38BHwM7ChAU1nB8EOj9ilARjAh433lyYc0HQ+Csu/5mbmZ3NzHI2ASml2f2EA85Ykblgcs4Vd3n51lYn47FcNzDxvee+6b5xuMHKd5f13cAmkcxkJLihKtm/AYy3m41aHLXAD54cJHM41WJHEbzjX4eBtZxea6HL7yCRXgWKuW/UfV9KSstWqk1+KfOmUpVOSm5YTu/DTzbCIi5kPm9SkI6ejGCyLxzHIp/Th6roPr2WySFAbJOehn11UQkMAFNwwQtkTxrTJLwcRVrgnPWf/wU7CKolstmgm98AFyRpb4zuYfDH4dNHOGQlLXd0Oxij18r+AGALZxxionTTUN+FPZLJdclm+0f66QDiOIQiEo1GPGtvJixygwErAoPNxdjS2cvzY5uC56MLD94KxshGmnYtZ/jiul7yNqvg9NxWc7hIf2GE5/xVHgnG9cQroKaNJPRtxWRbnbdR+2+BwAdClX/gvQO8MxamZOQLp/igo1dFFCgAAAAASUVORK5CYII="); } .chapter > li:nth-child(3) > ol:nth-child(1) > li:nth-child(5) strong:after { - content: ' ' url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAABbmlDQ1BpY2MAACiRdZE9S0JhFMd/alGU4WBDRIODRYOCFIRj2eAiIWaQ1aLXt0Cvl3uVkNagpUFoiFp6G/oGtQatBUFQBBFNfYDelpDbuSoooefy3PPj/5z/4XnOA/ZIQSkafQEoqmU9Fg55VhNrnoF3bDhxE8SWVAxtIRqN0DN+HqVa4sFv9epd1zWG0xlDAdug8Jyi6WXheeHIVlmzeE94VMkn08Inwj5dDih8a+mpJr9ZnGvyl8V6PLYIdqunJ9fBqQ5W8npReFrYWyxUlNZ5rJs4M+rKsuRxWRMYxAgTwkOKCpsUKOOXrMrMuvsCDd8SJfEo8teooosjR168PlEr0jUjOSt6Rr4CVWvu/+dpZGdnmt2dIeh/Nc3PSRjYh3rNNH9PTbN+Bo4XuFbb/pLMKfgteq2teY/BtQOXN20tdQBXuzD2rCX1ZENyyLJns/BxASMJcN/D0HpzVq19zp8gvi1PdAeHRzAl9a6NP84cZ/OkIlzqAAAACXBIWXMAAAsSAAALEgHS3X78AAABr0lEQVQ4T6VSzUrDQBCebNI2lqCXUqhSsBWaUjxVFASPngWPioJP4ANIH0DER/DoD4iP4FmLiBdB8NKDerEFD1JKbJus32x+mjbUix/szs7km5lvd6JJKTX6B0Tced+steP+NIDnheeoAIInMLnQnwbwjgOrcjW+AhwDZ4f8govF25e3WE6EgNcP3Bx4X6oKDkMaqbkObAIBL8QDbyoJlRuxD2ux8xjAO4i5Zd60z+2qrrF8T12B4Q1+tEN3QN0YWcG05A1MFBd5qqk3cK9sj+4QCQY6cKjXaenZkKggiQo1l7Rw6HNoaNOjcM/tLXIRWKXofimTxpOBjCWdKJmHWCET+wa/waUKCjKomJQdIlfyOMFXuR5YQOh7rxbst/IKZOGDGlN+yev5FB+SVTJWoFRG4TLPlcE/0C7WGWaQpnsiIyOzLFlKJb+LwhaVcG+hpBMaj36koIgC3sRTw20SfTzrQ6GTUaji8VLoXOcMOkXyUchPFGCgyBN6VftNTXRaIr2w7PIjk77vd40jEWCgQx1Cy+a8FDOz0sG12uicnuQpsIK/Vrdh7wwvKvpkPFy/C9SuZFOE3X8AAAAASUVORK5CYII='); + content: " " + url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAABbmlDQ1BpY2MAACiRdZE9S0JhFMd/alGU4WBDRIODRYOCFIRj2eAiIWaQ1aLXt0Cvl3uVkNagpUFoiFp6G/oGtQatBUFQBBFNfYDelpDbuSoooefy3PPj/5z/4XnOA/ZIQSkafQEoqmU9Fg55VhNrnoF3bDhxE8SWVAxtIRqN0DN+HqVa4sFv9epd1zWG0xlDAdug8Jyi6WXheeHIVlmzeE94VMkn08Inwj5dDih8a+mpJr9ZnGvyl8V6PLYIdqunJ9fBqQ5W8npReFrYWyxUlNZ5rJs4M+rKsuRxWRMYxAgTwkOKCpsUKOOXrMrMuvsCDd8SJfEo8teooosjR168PlEr0jUjOSt6Rr4CVWvu/+dpZGdnmt2dIeh/Nc3PSRjYh3rNNH9PTbN+Bo4XuFbb/pLMKfgteq2teY/BtQOXN20tdQBXuzD2rCX1ZENyyLJns/BxASMJcN/D0HpzVq19zp8gvi1PdAeHRzAl9a6NP84cZ/OkIlzqAAAACXBIWXMAAAsSAAALEgHS3X78AAABr0lEQVQ4T6VSzUrDQBCebNI2lqCXUqhSsBWaUjxVFASPngWPioJP4ANIH0DER/DoD4iP4FmLiBdB8NKDerEFD1JKbJus32x+mjbUix/szs7km5lvd6JJKTX6B0Tced+steP+NIDnheeoAIInMLnQnwbwjgOrcjW+AhwDZ4f8govF25e3WE6EgNcP3Bx4X6oKDkMaqbkObAIBL8QDbyoJlRuxD2ux8xjAO4i5Zd60z+2qrrF8T12B4Q1+tEN3QN0YWcG05A1MFBd5qqk3cK9sj+4QCQY6cKjXaenZkKggiQo1l7Rw6HNoaNOjcM/tLXIRWKXofimTxpOBjCWdKJmHWCET+wa/waUKCjKomJQdIlfyOMFXuR5YQOh7rxbst/IKZOGDGlN+yev5FB+SVTJWoFRG4TLPlcE/0C7WGWaQpnsiIyOzLFlKJb+LwhaVcG+hpBMaj36koIgC3sRTw20SfTzrQ6GTUaji8VLoXOcMOkXyUchPFGCgyBN6VftNTXRaIr2w7PIjk77vd40jEWCgQx1Cy+a8FDOz0sG12uicnuQpsIK/Vrdh7wwvKvpkPFy/C9SuZFOE3X8AAAAASUVORK5CYII="); } .chapter > li:nth-child(3) > ol:nth-child(1) > li:nth-child(6) strong:after { - content: ' ' url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAABcmlDQ1BpY2MAACiRdZE9S8NQFIbftkpFKx3qIOKQoYpDC0VBxUnr0KVIqRWsuiS3SSskabhJkeIquDgUHEQXvwb/ga6Cq4IgKIKIkz/Ar0VKPLcptEh7ws15eO95D/eeC/jTOjPsngRgmA7PppLSan5NCr7DhxAimMOMzGxrIZNJo2v8PFI1xUNc9Ope1zEGCqrNAF8f8TSzuEM8T5zecizBe8RDrCQXiE+IY5wOSHwrdMXjN8FFj78E81x2EfCLnlKxjZU2ZiVuEE8QRw29wprnETcJqebKMuURWqOwkUUKSUhQUMEmdDiIUzZpZp19iYZvCWXyMPpbqIKTo4gSeWOkVqirSlkjXaVPR1XM/f88bW1q0useSgK9r677OQYE94F6zXV/T123fgYEXoBrs+Uv05xmv0mvtbToMRDeAS5vWppyAFztAsPPlszlhhSg5dc04OMCGMwDkXugf92bVXMf509Abpue6A44PALGqT688QdHJ2grp2cfigAAAAlwSFlzAAALEgAACxIB0t1+/AAAAhJJREFUOE91Uz1vE0EQfbPe3fuIERFKUAgfQobYFBFKzY+ggiBRUqSgpAABLigACapIdFFEQe0iPwBRpEpBgSiQE4ToUIRcIGPsu/XdLbPnj5zP8HRzuzc783bf7BxZawn/QeMFNi3wruyfgBPbsuwsokLYTywSDgzLa2NcFmVPEV+e4JgDtnhqymsTUFHC2hto/MJVNzd/ejDGwPR6UFWvplR4Uwjh+wthDUQ3xindGQnqN84kAp9hrZCeRpqlEDz2u33Egw6GcZxdbDRafrgwzZmRMD7yeyJCRSl4QVCwEMrzhU2zqJgzVwOu+j1+H0xJwnBsQW7GxC0Om5JQ/bm9k6WpNlGEaBDBjcM4EkrrDapgdXH5bIcg7seDAQxbmsXrS+cu1Tl3l01K3rFJQqxLraDSFDZzliDus+5oAKS0t3ThfC7DVTsZKhw+xd6VV/ggDDaksNi2RLsVycf1C0LyxyJD+iOXI2VOIrw4j/j2CF0e9kW7ibcc94CDOk6z9n22ADrwmTBwlf/kAh2JYBKtqpNdcuRFPGxi+9QyVhNBKyT19RHJyJQXZpnALe6WFpMkM9mMaR983IJb/Fl7yU5SIyfL4ObB18fo8Nfmtdeonl48uQGHuX/h+xo69SM6lkqtcEOB5MlNtx+iVwjNMd8Ht5Fx3jOnWWoN359W9p+YI3A4amKHT3+XSQ6oovrl9SL+AhmcsPdAgrGeAAAAAElFTkSuQmCC'); + content: " " + url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAABcmlDQ1BpY2MAACiRdZE9S8NQFIbftkpFKx3qIOKQoYpDC0VBxUnr0KVIqRWsuiS3SSskabhJkeIquDgUHEQXvwb/ga6Cq4IgKIKIkz/Ar0VKPLcptEh7ws15eO95D/eeC/jTOjPsngRgmA7PppLSan5NCr7DhxAimMOMzGxrIZNJo2v8PFI1xUNc9Ope1zEGCqrNAF8f8TSzuEM8T5zecizBe8RDrCQXiE+IY5wOSHwrdMXjN8FFj78E81x2EfCLnlKxjZU2ZiVuEE8QRw29wprnETcJqebKMuURWqOwkUUKSUhQUMEmdDiIUzZpZp19iYZvCWXyMPpbqIKTo4gSeWOkVqirSlkjXaVPR1XM/f88bW1q0useSgK9r677OQYE94F6zXV/T123fgYEXoBrs+Uv05xmv0mvtbToMRDeAS5vWppyAFztAsPPlszlhhSg5dc04OMCGMwDkXugf92bVXMf509Abpue6A44PALGqT688QdHJ2grp2cfigAAAAlwSFlzAAALEgAACxIB0t1+/AAAAhJJREFUOE91Uz1vE0EQfbPe3fuIERFKUAgfQobYFBFKzY+ggiBRUqSgpAABLigACapIdFFEQe0iPwBRpEpBgSiQE4ToUIRcIGPsu/XdLbPnj5zP8HRzuzc783bf7BxZawn/QeMFNi3wruyfgBPbsuwsokLYTywSDgzLa2NcFmVPEV+e4JgDtnhqymsTUFHC2hto/MJVNzd/ejDGwPR6UFWvplR4Uwjh+wthDUQ3xindGQnqN84kAp9hrZCeRpqlEDz2u33Egw6GcZxdbDRafrgwzZmRMD7yeyJCRSl4QVCwEMrzhU2zqJgzVwOu+j1+H0xJwnBsQW7GxC0Om5JQ/bm9k6WpNlGEaBDBjcM4EkrrDapgdXH5bIcg7seDAQxbmsXrS+cu1Tl3l01K3rFJQqxLraDSFDZzliDus+5oAKS0t3ThfC7DVTsZKhw+xd6VV/ggDDaksNi2RLsVycf1C0LyxyJD+iOXI2VOIrw4j/j2CF0e9kW7ibcc94CDOk6z9n22ADrwmTBwlf/kAh2JYBKtqpNdcuRFPGxi+9QyVhNBKyT19RHJyJQXZpnALe6WFpMkM9mMaR983IJb/Fl7yU5SIyfL4ObB18fo8Nfmtdeonl48uQGHuX/h+xo69SM6lkqtcEOB5MlNtx+iVwjNMd8Ht5Fx3jOnWWoN359W9p+YI3A4amKHT3+XSQ6oovrl9SL+AhmcsPdAgrGeAAAAAElFTkSuQmCC"); } .chapter > li:nth-child(3) > ol:nth-child(1) > li:nth-child(7) strong:after { - content: ' ' url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAABbmlDQ1BpY2MAACiRdZE9S0JhFMd/amGU0ZBDRIODRYOCFERj2eAiIWaQ1XK9+RJc7XKvEtIatDQIDVFLb0PfoNagtSAIiiCiqQ/Q2xJxO48GSti5PPf8+D/nfzj3XHDHDb1od0SgWCpbyVg0sJBeDHhfcOHDD/g13TanE4k4/8bnnVRL3IZVr//r2kbPStbWwdUlPKGbVll4Sji+XjYVb6sZ9IK2InwoHLJkQOErpWca/Kw43+B3xVYqOQNu1TOQb+FMC+sFqyg8KhwsGhX9dx71Jb5saX5O8qCcIWySxIgSIEOFVQzKhCWXZGftfZG6b5Y18ejyNqliiSNPQbwhUSvSNSs5J3pWHoOq2vvffdq58bFGd18UOp8c520YvDvwXXOcryPH+T4GzyNclJr+NdnT5IfotaYWPIC+TTi7bGqZXTjfgoEHU7O0uuSR487l4PUUetPQfwPdS41d/d5zcg+pDflF17C3DyNS37f8A3/sZ8/TN03XAAAACXBIWXMAAAsSAAALEgHS3X78AAAAMnRFWHRSYXcgcHJvZmlsZSB0eXBlIGV4aWYACmV4aWYKICAgICAgIDYKNDU3ODY5NjYwMDAwCvm8oQ4AAABadEVYdFJhdyBwcm9maWxlIHR5cGUgaXB0YwAKaXB0YwogICAgICAyNgo1MDY4NmY3NDZmNzM2ODZmNzAyMDMzMmUzMDAwMzg0MjQ5NGQwNDA0MDAwMDAwMDAwMDAwCsfRadwAAAAjdEVYdFJhdyBwcm9maWxlIHR5cGUgeG1wAAp4bXAKICAgICAgIDAKTRhOWwAAAitJREFUOE+VU71L8lEUfvwqJXFQIRVEQvEPEIdQaBFpEBSXhDYRbBP/h0hIEEQXwaEclIjctMmPUVwEQTAdolREDBTNBDE173mpl37yDu8z3XvPuc99znPO5W22wD/AQjwej3v8C0LuAcN8Psfd3R3a7TYRmEwmuN1uCIW76Tyugs/PT4TDYchkMpyenmK1WiGbzUKpVMLv9+8o4v/abdFoNLBYLHB2doabmxs8PDzQRaam1+tx03cJ+v0+9Ho9arUaRCIR3t/f6eLh4SGGwyE3fZfg6OgIT09PMJvN2NvbI0VyuRwulwsGg4GbTk7/wnq93sRisU0kEqF9Op3exONxTtZf/CjodDp4eXkhkzQaDUnfxuF0Oqn+29tbpFIplMtlMvobRFCtVnF1dYXpdIrRaIRSqYSLiwsik0qlOD8/x2QyoU5UKhWEQiFqNRHMZjNkMhkEAgEMBgMEg0GcnJxApVL9vHJ8fAyHw0Fxr9dLLc7lchQTdrtdYmaO5/N5aLVaWjebTdzf35ORbBbq9TqsVisSiQQ8Hg/FiODg4ABMBWPd398nqUajEcViEWq1mqZQIBCAz+fj+fkZFosF4/EYEomECHhb9s319TX12W63k0Gvr68k8fLykhJbrRbEYjGWyyX5Eo1GqRRGTqPMzEsmkzQobN7Ziz6fDzqdjl55fHxEoVAAU/vx8UF+2Gy2PwoYAa22eHt7IwVMOhesTFaeQqEgNd/Y+Uz/iy/JFyvRO9cdAwAAAABJRU5ErkJggg=='); + content: " " + url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAABbmlDQ1BpY2MAACiRdZE9S0JhFMd/amGU0ZBDRIODRYOCFERj2eAiIWaQ1XK9+RJc7XKvEtIatDQIDVFLb0PfoNagtSAIiiCiqQ/Q2xJxO48GSti5PPf8+D/nfzj3XHDHDb1od0SgWCpbyVg0sJBeDHhfcOHDD/g13TanE4k4/8bnnVRL3IZVr//r2kbPStbWwdUlPKGbVll4Sji+XjYVb6sZ9IK2InwoHLJkQOErpWca/Kw43+B3xVYqOQNu1TOQb+FMC+sFqyg8KhwsGhX9dx71Jb5saX5O8qCcIWySxIgSIEOFVQzKhCWXZGftfZG6b5Y18ejyNqliiSNPQbwhUSvSNSs5J3pWHoOq2vvffdq58bFGd18UOp8c520YvDvwXXOcryPH+T4GzyNclJr+NdnT5IfotaYWPIC+TTi7bGqZXTjfgoEHU7O0uuSR487l4PUUetPQfwPdS41d/d5zcg+pDflF17C3DyNS37f8A3/sZ8/TN03XAAAACXBIWXMAAAsSAAALEgHS3X78AAAAMnRFWHRSYXcgcHJvZmlsZSB0eXBlIGV4aWYACmV4aWYKICAgICAgIDYKNDU3ODY5NjYwMDAwCvm8oQ4AAABadEVYdFJhdyBwcm9maWxlIHR5cGUgaXB0YwAKaXB0YwogICAgICAyNgo1MDY4NmY3NDZmNzM2ODZmNzAyMDMzMmUzMDAwMzg0MjQ5NGQwNDA0MDAwMDAwMDAwMDAwCsfRadwAAAAjdEVYdFJhdyBwcm9maWxlIHR5cGUgeG1wAAp4bXAKICAgICAgIDAKTRhOWwAAAitJREFUOE+VU71L8lEUfvwqJXFQIRVEQvEPEIdQaBFpEBSXhDYRbBP/h0hIEEQXwaEclIjctMmPUVwEQTAdolREDBTNBDE173mpl37yDu8z3XvPuc99znPO5W22wD/AQjwej3v8C0LuAcN8Psfd3R3a7TYRmEwmuN1uCIW76Tyugs/PT4TDYchkMpyenmK1WiGbzUKpVMLv9+8o4v/abdFoNLBYLHB2doabmxs8PDzQRaam1+tx03cJ+v0+9Ho9arUaRCIR3t/f6eLh4SGGwyE3fZfg6OgIT09PMJvN2NvbI0VyuRwulwsGg4GbTk7/wnq93sRisU0kEqF9Op3exONxTtZf/CjodDp4eXkhkzQaDUnfxuF0Oqn+29tbpFIplMtlMvobRFCtVnF1dYXpdIrRaIRSqYSLiwsik0qlOD8/x2QyoU5UKhWEQiFqNRHMZjNkMhkEAgEMBgMEg0GcnJxApVL9vHJ8fAyHw0Fxr9dLLc7lchQTdrtdYmaO5/N5aLVaWjebTdzf35ORbBbq9TqsVisSiQQ8Hg/FiODg4ABMBWPd398nqUajEcViEWq1mqZQIBCAz+fj+fkZFosF4/EYEomECHhb9s319TX12W63k0Gvr68k8fLykhJbrRbEYjGWyyX5Eo1GqRRGTqPMzEsmkzQobN7Ziz6fDzqdjl55fHxEoVAAU/vx8UF+2Gy2PwoYAa22eHt7IwVMOhesTFaeQqEgNd/Y+Uz/iy/JFyvRO9cdAwAAAABJRU5ErkJggg=="); } .chapter > li:nth-child(3) > ol:nth-child(1) > li:nth-child(8) strong:after { - content: ' ' url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAACXBIWXMAAAOwAAADsAEnxA+tAAAAGXRFWHRTb2Z0d2FyZQB3d3cuaW5rc2NhcGUub3Jnm+48GgAAAk9JREFUOI1tk09IVGEUxX/3vvdmlMYCkTQjJUVBsBJat2tVERZRDa2SClpIBIIKhUggQpCtolULNxHtsqKWGQS10aQggkgkRZM2ko5v5nvfbfEcGxnv+pxzz7l/xMzYtR42tfk4Oo3RiVgAwbIGxWnWl6cZMV+GSZXAWFsjsj5htu+iWCFADDAMBVHEuznCUj+Dv6cBdAf5fke7182PkM2LbAaIggmmhhiIOVyw56gl4QNEBCDcJo/uz1km+1rxrVgI6koee26Re0uxGIvW9KB2NbS4wQm3wy3r2wI+GwzgfSeWRbSwOrO4ce/bfDyfn9qcApjsjf7U56JPZ7rrDoTDK+/LvDTCqIQQXXcRiMQGQd/sj42WwBXvlIEuCfqXCskFhlYeVaZOBWoauzRJmjMlMHVfGV585cKIorI94TAxNKleVhohDlsIUqzT2pkIM8gh2JEnZzOzaSc7jJeXuwuEhSKWAZTIl2pSggdYA955kECkrrp/WSC777sVYw+mGCdk4lAteEAW+l4UbwFMnsq2eqvOkM5g4OcC5uYwAwmbiHVQJBFXcSViSCZRIN3/TgEzE3XjQgQIRnL3/PGGTFft3stlYPta5lpvT8Mbxg9OpVsrC2+fsogfb36KySUAtQSvmRX1/gPChpWSY0RhNxojFt1k6Nfj/w62bGhMn2LPIAER1JJGxM6BXLEw7EY38ZbFLNtXPuWdvzCyuMHwUl41ziPy2VvgTTwmDgs85mU1SNyY5PQkW9arv7GyxlvbkL8dIDWYLlC/+oUbVqqE/AMToAVJZCYDcQAAAABJRU5ErkJggg=='); + content: " " + url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAACXBIWXMAAAOwAAADsAEnxA+tAAAAGXRFWHRTb2Z0d2FyZQB3d3cuaW5rc2NhcGUub3Jnm+48GgAAAk9JREFUOI1tk09IVGEUxX/3vvdmlMYCkTQjJUVBsBJat2tVERZRDa2SClpIBIIKhUggQpCtolULNxHtsqKWGQS10aQggkgkRZM2ko5v5nvfbfEcGxnv+pxzz7l/xMzYtR42tfk4Oo3RiVgAwbIGxWnWl6cZMV+GSZXAWFsjsj5htu+iWCFADDAMBVHEuznCUj+Dv6cBdAf5fke7182PkM2LbAaIggmmhhiIOVyw56gl4QNEBCDcJo/uz1km+1rxrVgI6koee26Re0uxGIvW9KB2NbS4wQm3wy3r2wI+GwzgfSeWRbSwOrO4ce/bfDyfn9qcApjsjf7U56JPZ7rrDoTDK+/LvDTCqIQQXXcRiMQGQd/sj42WwBXvlIEuCfqXCskFhlYeVaZOBWoauzRJmjMlMHVfGV585cKIorI94TAxNKleVhohDlsIUqzT2pkIM8gh2JEnZzOzaSc7jJeXuwuEhSKWAZTIl2pSggdYA955kECkrrp/WSC777sVYw+mGCdk4lAteEAW+l4UbwFMnsq2eqvOkM5g4OcC5uYwAwmbiHVQJBFXcSViSCZRIN3/TgEzE3XjQgQIRnL3/PGGTFft3stlYPta5lpvT8Mbxg9OpVsrC2+fsogfb36KySUAtQSvmRX1/gPChpWSY0RhNxojFt1k6Nfj/w62bGhMn2LPIAER1JJGxM6BXLEw7EY38ZbFLNtXPuWdvzCyuMHwUl41ziPy2VvgTTwmDgs85mU1SNyY5PQkW9arv7GyxlvbkL8dIDWYLlC/+oUbVqqE/AMToAVJZCYDcQAAAABJRU5ErkJggg=="); } .chapter > li:nth-child(3) > ol:nth-child(1) > li:nth-child(9) strong:after { - content: ' ' url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAMAAAAoLQ9TAAAAIGNIUk0AAHomAACAhAAA+gAAAIDoAAB1MAAA6mAAADqYAAAXcJy6UTwAAAFiUExURQRxuAZyuQl0uwt2vA53vRB5vhJ7wBV8wRd+whqAwxyCxB+DxiCFxyGHyCGJyiKKyxB5vxV9wRyCxR+ExgRwuBF6wBR8wRyBwxuAxBuBxA13vSeGxZPC4rza7afO6EOXzgl1u4i83s/k8nq027jX7LfX7CCExQp0u6vP6Ii83wVzvFmi09Dk8iiHxhmAwwJvuAFvtwJwtwJwuAVyuanO54W63QVyu12k0ySFxRZ+wgNvuB+AwHy02pfE4ZbD4ZbE4t3s9s7j8ZfE48zi8aTM5hZ9wQhyuaDJ5Mng8JXD4ZnF4prF4pfE4pjF4n+23CeGxA94vhV5vczi8FWezwBrtlyi0Xiy2Xmz2V2j0QZyugp1vAFvuBV6vU+bzQBptYW53KvP55zG43ey2Al0uq7R6LTU6nKv183i8YK32x5+vxh8vgNwuABvuC6JxJ7I5LfW64u93ht9vgFutwBvtwhzuf///+BldHgAAAABYktHRHWoapj7AAAAB3RJTUUH6QUIDjIK9/DXEAAAANhJREFUGNNjYGBkYmZhZWPn4OTi5uHl42dgAIsIsAlycAoJA0UYRKAiomLiEpJAEQYRiIiUtIysnDxQBCgAFlFQVFJWUVXj4gYJgETUNTS1tHV09bhAAvoGhkbGJqZm5koWlpxAAStrG1s7ewdHJ2cXVzEOoICbu4enl7ePj6+ffwAbO4ORfmBQcEhoWHiEUWQU0D0MRtExQbFx8QmJSQZgNzMYGSWnpKalZ2RmZYNdCBTIyc3LLygssoK4EChgVJxdkm0FdQ8Tg6ERCGSLiEBFGAwhIiIwEQAy+yXaag0Q4QAAACV0RVh0ZGF0ZTpjcmVhdGUAMjAyNS0wNS0wOFQxNDo1MDoxMCswMDowMOrHpPsAAAAldEVYdGRhdGU6bW9kaWZ5ADIwMjUtMDUtMDhUMTQ6NTA6MTArMDA6MDCbmhxHAAAAKHRFWHRkYXRlOnRpbWVzdGFtcAAyMDI1LTA1LTA4VDE0OjUwOjEwKzAwOjAwzI89mAAAAABJRU5ErkJggg=='); + content: " " + url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAMAAAAoLQ9TAAAAIGNIUk0AAHomAACAhAAA+gAAAIDoAAB1MAAA6mAAADqYAAAXcJy6UTwAAAFiUExURQRxuAZyuQl0uwt2vA53vRB5vhJ7wBV8wRd+whqAwxyCxB+DxiCFxyGHyCGJyiKKyxB5vxV9wRyCxR+ExgRwuBF6wBR8wRyBwxuAxBuBxA13vSeGxZPC4rza7afO6EOXzgl1u4i83s/k8nq027jX7LfX7CCExQp0u6vP6Ii83wVzvFmi09Dk8iiHxhmAwwJvuAFvtwJwtwJwuAVyuanO54W63QVyu12k0ySFxRZ+wgNvuB+AwHy02pfE4ZbD4ZbE4t3s9s7j8ZfE48zi8aTM5hZ9wQhyuaDJ5Mng8JXD4ZnF4prF4pfE4pjF4n+23CeGxA94vhV5vczi8FWezwBrtlyi0Xiy2Xmz2V2j0QZyugp1vAFvuBV6vU+bzQBptYW53KvP55zG43ey2Al0uq7R6LTU6nKv183i8YK32x5+vxh8vgNwuABvuC6JxJ7I5LfW64u93ht9vgFutwBvtwhzuf///+BldHgAAAABYktHRHWoapj7AAAAB3RJTUUH6QUIDjIK9/DXEAAAANhJREFUGNNjYGBkYmZhZWPn4OTi5uHl42dgAIsIsAlycAoJA0UYRKAiomLiEpJAEQYRiIiUtIysnDxQBCgAFlFQVFJWUVXj4gYJgETUNTS1tHV09bhAAvoGhkbGJqZm5koWlpxAAStrG1s7ewdHJ2cXVzEOoICbu4enl7ePj6+ffwAbO4ORfmBQcEhoWHiEUWQU0D0MRtExQbFx8QmJSQZgNzMYGSWnpKalZ2RmZYNdCBTIyc3LLygssoK4EChgVJxdkm0FdQ8Tg6ERCGSLiEBFGAwhIiIwEQAy+yXaag0Q4QAAACV0RVh0ZGF0ZTpjcmVhdGUAMjAyNS0wNS0wOFQxNDo1MDoxMCswMDowMOrHpPsAAAAldEVYdGRhdGU6bW9kaWZ5ADIwMjUtMDUtMDhUMTQ6NTA6MTArMDA6MDCbmhxHAAAAKHRFWHRkYXRlOnRpbWVzdGFtcAAyMDI1LTA1LTA4VDE0OjUwOjEwKzAwOjAwzI89mAAAAABJRU5ErkJggg=="); } - .chapter li:nth-child(7) > a:nth-child(1) > strong:after { - content: ' ' url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAPCAYAAADtc08vAAAB5klEQVQoU4WSQU7bUBCGZ6wkStogwglwdkikqpEI6/gEhF3SVXoCzA3CCWpOUO9sVvgGeB0jYSlGYodvAFKgrmLk6cwztlKUgHfWe++f///mR/jgO9wfW4g5hfHlxaZruOngqDeaEoIl50hgz2Jvuu7uWgFDH3bqW60HADorHuGvbJF2o8R/ei+yVqD/beQAoR7G7kAe9HvjgG0k4dybfCrQ3/9hoEa3BGCHc1c5OOyNBhridU5k3sResCqy1oGCp5FkvuKpP5UL5Qp2w9gzNwrwpQkD22Vg58beSG/UUKbdzmL3pODSTPjfYlGnFKkcrIBDzv+QPadm/esXHbQ8oBynN3euXWwGT1eBVgJyyLQny+fUaLSbATE1EWlstQbM43eW0UF07yVHvTG7IKdcayVQkIbvkKOZvfxJGu1WVF6URyzi81asEugyo64IKgHJKz8KFOCxWKy1mwYi+Nnir87Zh9IF3spOtVYgbqhnYqW4SHekKBwlYvuBTJPJObfw9SV1OMpjuUYFuI5cNDjB95lkE+U0jmUzio4USN3jas/mrv/mwkakYyXA+R4571tNscPWjSU7YpiWUGe3ESAaSHQh8BQvgG0W76JqHuac8f9v+QpOrQa6BjAoT3KAQJpYbIxHkub/A+GHBVloER6qAAAAAElFTkSuQmCC'); + content: " " + url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAPCAYAAADtc08vAAAB5klEQVQoU4WSQU7bUBCGZ6wkStogwglwdkikqpEI6/gEhF3SVXoCzA3CCWpOUO9sVvgGeB0jYSlGYodvAFKgrmLk6cwztlKUgHfWe++f///mR/jgO9wfW4g5hfHlxaZruOngqDeaEoIl50hgz2Jvuu7uWgFDH3bqW60HADorHuGvbJF2o8R/ei+yVqD/beQAoR7G7kAe9HvjgG0k4dybfCrQ3/9hoEa3BGCHc1c5OOyNBhridU5k3sResCqy1oGCp5FkvuKpP5UL5Qp2w9gzNwrwpQkD22Vg58beSG/UUKbdzmL3pODSTPjfYlGnFKkcrIBDzv+QPadm/esXHbQ8oBynN3euXWwGT1eBVgJyyLQny+fUaLSbATE1EWlstQbM43eW0UF07yVHvTG7IKdcayVQkIbvkKOZvfxJGu1WVF6URyzi81asEugyo64IKgHJKz8KFOCxWKy1mwYi+Nnir87Zh9IF3spOtVYgbqhnYqW4SHekKBwlYvuBTJPJObfw9SV1OMpjuUYFuI5cNDjB95lkE+U0jmUzio4USN3jas/mrv/mwkakYyXA+R4571tNscPWjSU7YpiWUGe3ESAaSHQh8BQvgG0W76JqHuac8f9v+QpOrQa6BjAoT3KAQJpYbIxHkub/A+GHBVloER6qAAAAAElFTkSuQmCC"); } - diff --git a/docs/_site/version.js b/docs/_site/version.js index ca4ea7f19..a7a1ca035 100644 --- a/docs/_site/version.js +++ b/docs/_site/version.js @@ -1,2 +1,2 @@ // This is modified by the build script -window.HOOKSHOT_VERSION = "latest"; \ No newline at end of file +window.HOOKSHOT_VERSION = "latest"; diff --git a/docs/advanced/encryption.md b/docs/advanced/encryption.md index 6d74c989e..8fd8ed0a7 100644 --- a/docs/advanced/encryption.md +++ b/docs/advanced/encryption.md @@ -1,5 +1,4 @@ -Encryption -========== +# Encryption
Support for encryption is considered stable, but the underlying specification changes are not yet. @@ -7,15 +6,15 @@ Support for encryption is considered stable, but the underlying specification ch Hookshot supports end-to-bridge encryption via [MSC3202](https://github.com/matrix-org/matrix-spec-proposals/pull/3202), and [MSC4203](https://github.com/matrix-org/matrix-spec-proposals/pull/4203). Hookshot needs to be configured against a a homeserver that supports these features, such as [Synapse](#running-with-synapse). Please check with your homeserver implementation before reporting bugs against matrix-hookshot. -
- + ## Enabling encryption in Hookshot In order for Hookshot to use encryption, it must be configured as follows: + - The `encryption.storagePath` setting must point to a directory that Hookshot has permissions to write files into. If running with Docker, this path should be within a volume (for persistency). Hookshot uses this directory for its crypto store (i.e. long-lived state relating to its encryption keys). - - 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`. + - 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` MUST **NOT be configured**. If you ever reset your homeserver's state, ensure you also reset Hookshot's encryption state. This includes clearing the `storagePath` directory and all worker state stored in your redis instance. Otherwise, Hookshot may fail on start up with registration errors. @@ -34,4 +33,3 @@ experimental_features: msc3202_transaction_extensions: true msc2409_to_device_messages_enabled: true ``` - diff --git a/docs/advanced/provisioning.md b/docs/advanced/provisioning.md index 27dd3e9c8..8e0485201 100644 --- a/docs/advanced/provisioning.md +++ b/docs/advanced/provisioning.md @@ -1,5 +1,4 @@ -Provisioning -============ +# Provisioning This section is not complete yet for end users. For developers, you can read the documentation for the API below: diff --git a/docs/advanced/service_bots.md b/docs/advanced/service_bots.md index d05926dfb..9ceaddff3 100644 --- a/docs/advanced/service_bots.md +++ b/docs/advanced/service_bots.md @@ -8,15 +8,17 @@ These bots can coexist in a room, each handling a different service. Service bots can be given a different localpart, display name, avatar, and command prefix. They will only handle connections for the specified service, which can be one of: -* `feeds` - [Feeds](../setup/feeds.md) -* `figma` - [Figma](../setup/figma.md) -* `generic` - [Webhooks](../setup/webhooks.md) -* `github` - [GitHub](../setup/github.md) -* `gitlab` - [GitLab](../setup/gitlab.md) -* `jira` - [Jira](../setup/jira.md) -* `openproject` - [OpenProject](../setup/openproject.md) + +- `feeds` - [Feeds](../setup/feeds.md) +- `figma` - [Figma](../setup/figma.md) +- `generic` - [Webhooks](../setup/webhooks.md) +- `github` - [GitHub](../setup/github.md) +- `gitlab` - [GitLab](../setup/gitlab.md) +- `jira` - [Jira](../setup/jira.md) +- `openproject` - [OpenProject](../setup/openproject.md) For example with this configuration: + ```yaml serviceBots: - localpart: feeds @@ -31,7 +33,8 @@ There will be a bot user `@feeds:example.com` which responds to commands prefixe For the homeserver to allow hookshot control over users, they need to be added to the list of user namespaces in the `registration.yml` file provided to the homeserver. In the example above, you would need to add these lines: + ```yaml - - regex: "@feeds:example.com" # Where example.com is your homeserver's domain - exclusive: true +- regex: "@feeds:example.com" # Where example.com is your homeserver's domain + exclusive: true ``` diff --git a/docs/advanced/widgets.md b/docs/advanced/widgets.md index d835d6ac5..e9be777cb 100644 --- a/docs/advanced/widgets.md +++ b/docs/advanced/widgets.md @@ -1,5 +1,4 @@ -Widgets -======= +# Widgets
Widgets themselves are still not part of the stable Matrix spec (currently it's defined as a proposal in matrix-spec/285, and @@ -11,7 +10,6 @@ the existing command system, rather than replace it. Example of a configuration widget - ### Configuration ```yaml @@ -19,26 +17,26 @@ widgets: addToAdminRooms: false roomSetupWidget: addOnInvite: 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 + # 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 publicUrl: https://example.com/widgetapi/v1/static branding: widgetTitle: Hookshot Configuration @@ -60,12 +58,13 @@ Unless you know what you are doing, it is recommended to not include this key. T `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. + +- 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 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, +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"`. In addition to setting up the widgets config, you must bind a listener for the widgets resource in your `listeners` config. @@ -85,4 +84,3 @@ See the [setup page](../setup#listeners-configuration) for more information on l The API for widgets is currently in flux due to being fairly new, and it's not recommended to develop against it at this time. At a future date this API will be merged with the existing provisioning API and the details will be published. - diff --git a/docs/advanced/workers.md b/docs/advanced/workers.md index 1decc97ce..8b88dd58d 100644 --- a/docs/advanced/workers.md +++ b/docs/advanced/workers.md @@ -1,5 +1,4 @@ -Workers -======= +# Workers Hookshot supports running in a worker configuration, using Redis as the middleman process to handle traffic between processes. @@ -26,6 +25,7 @@ Note that if [encryption](./encryption.md) is enabled, you MUST enable the `cach storage in Redis, but cannot make use of worker-mode queues. Once that is done, you can simply start the processes by name using yarn: + ``` yarn start:webhooks yarn start:matrixsender diff --git a/docs/metrics.md b/docs/metrics.md index 94cdfa679..ebd01b3fa 100644 --- a/docs/metrics.md +++ b/docs/metrics.md @@ -1,5 +1,4 @@ -Prometheus Metrics -================== +# Prometheus Metrics You can configure metrics support by adding the following to your config: @@ -21,68 +20,76 @@ Select the Prometheus instance with your Hookshot metrics as Data Source. Set In Below is the generated list of Prometheus metrics for Hookshot. - ## 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 provisioner handler | path, method | -| hookshot_queue_event_pushes | Number of events pushed through the queue | event | -| 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 | Whether the notification service is up or down | service | -| hookshot_notifications_watchers | Number of notifications watchers running | service | -| 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 | + +| 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 provisioner handler | path, method | +| hookshot_queue_event_pushes | Number of events pushed through the queue | event | +| 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 | Whether the notification service is up or down | service | +| hookshot_notifications_watchers | Number of notifications watchers running | service | +| 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 | 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 | | + +| Metric | Help | Labels | +| ----------------------------------- | ------------------------------------------------------------ | ------ | +| 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) 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 | + +| Metric | Help | Labels | +| ------------- | ----------------------------------------------------------------- | ------ | +| 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 | -|--------|------|--------| -| process_cpu_user_seconds_total | Total user CPU time spent in seconds. | | -| process_cpu_system_seconds_total | Total system CPU time spent in seconds. | | -| process_cpu_seconds_total | Total user and system CPU time spent in seconds. | | -| process_start_time_seconds | Start time of the process since unix epoch in seconds. | | -| process_resident_memory_bytes | Resident memory size in bytes. | | -| process_virtual_memory_bytes | Virtual memory size in bytes. | | -| process_heap_bytes | Process heap size in bytes. | | -| process_open_fds | Number of open file descriptors. | | -| process_max_fds | Maximum number of open file descriptors. | | + +| Metric | Help | Labels | +| -------------------------------- | ------------------------------------------------------ | ------ | +| process_cpu_user_seconds_total | Total user CPU time spent in seconds. | | +| process_cpu_system_seconds_total | Total system CPU time spent in seconds. | | +| process_cpu_seconds_total | Total user and system CPU time spent in seconds. | | +| process_start_time_seconds | Start time of the process since unix epoch in seconds. | | +| process_resident_memory_bytes | Resident memory size in bytes. | | +| process_virtual_memory_bytes | Virtual memory size in bytes. | | +| process_heap_bytes | Process heap size in bytes. | | +| process_open_fds | Number of open file descriptors. | | +| process_max_fds | Maximum number of open file descriptors. | | + ## nodejs -| Metric | Help | Labels | -|--------|------|--------| -| nodejs_eventloop_lag_seconds | Lag of event loop in seconds. | | -| nodejs_eventloop_lag_min_seconds | The minimum recorded event loop delay. | | -| nodejs_eventloop_lag_max_seconds | The maximum recorded event loop delay. | | -| nodejs_eventloop_lag_mean_seconds | The mean of the recorded event loop delays. | | -| nodejs_eventloop_lag_stddev_seconds | The standard deviation of the recorded event loop delays. | | -| 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 | -| nodejs_active_requests_total | Total number of active requests. | | -| nodejs_heap_size_total_bytes | Process heap size from Node.js in bytes. | | -| nodejs_heap_size_used_bytes | Process heap size used from Node.js in bytes. | | -| nodejs_external_memory_bytes | Node.js external memory size in bytes. | | -| nodejs_heap_space_size_total_bytes | Process heap space size total from Node.js in bytes. | space | -| nodejs_heap_space_size_used_bytes | Process heap space size used from Node.js in bytes. | space | -| nodejs_heap_space_size_available_bytes | Process heap space size available from Node.js in bytes. | space | -| nodejs_version_info | Node.js version info. | version, major, minor, patch | -| nodejs_gc_duration_seconds | Garbage collection duration by kind, one of major, minor, incremental or weakcb. | kind | + +| Metric | Help | Labels | +| -------------------------------------- | ----------------------------------------------------------------------------------------------------------- | ---------------------------- | +| nodejs_eventloop_lag_seconds | Lag of event loop in seconds. | | +| nodejs_eventloop_lag_min_seconds | The minimum recorded event loop delay. | | +| nodejs_eventloop_lag_max_seconds | The maximum recorded event loop delay. | | +| nodejs_eventloop_lag_mean_seconds | The mean of the recorded event loop delays. | | +| nodejs_eventloop_lag_stddev_seconds | The standard deviation of the recorded event loop delays. | | +| 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 | +| nodejs_active_requests_total | Total number of active requests. | | +| nodejs_heap_size_total_bytes | Process heap size from Node.js in bytes. | | +| nodejs_heap_size_used_bytes | Process heap size used from Node.js in bytes. | | +| nodejs_external_memory_bytes | Node.js external memory size in bytes. | | +| nodejs_heap_space_size_total_bytes | Process heap space size total from Node.js in bytes. | space | +| nodejs_heap_space_size_used_bytes | Process heap space size used from Node.js in bytes. | space | +| nodejs_heap_space_size_available_bytes | Process heap space size available from Node.js in bytes. | space | +| nodejs_version_info | Node.js version info. | version, major, minor, patch | +| nodejs_gc_duration_seconds | Garbage collection duration by kind, one of major, minor, incremental or weakcb. | kind | diff --git a/docs/sentry.md b/docs/sentry.md index 0dd23037a..1db49a89b 100644 --- a/docs/sentry.md +++ b/docs/sentry.md @@ -1,5 +1,4 @@ -Sentry -====== +# Sentry Hookshot supports [Sentry](https://sentry.io/welcome/) error reporting. diff --git a/docs/setup.md b/docs/setup.md index 57cb4d9b5..b14c14111 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -1,5 +1,4 @@ -Getting set up -============== +# Getting set up This page explains how to set up Hookshot for use with a Matrix homeserver. @@ -29,7 +28,6 @@ yarn # or npm i Starting the bridge (after configuring it), is a matter of setting the `NODE_ENV` environment variable to `production` or `development`, depending if you want [better performance or more verbose logging](https://expressjs.com/en/advanced/best-practice-performance.html#set-node_env-to-production), and then running it: - ```bash NODE_ENV=production yarn start ``` @@ -152,7 +150,6 @@ permissions: would grant `@badapple:example.com` the right to `manageConnections` for GitHub, even though they were explicitly named for a lower permission. - #### Example A typical setup might be. @@ -232,11 +229,10 @@ However, if you use Nginx, have a look at this example: 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. - Note that OAuth requests also go through this listener. Previous versions of the bridge listened for requests on `/` rather than `/webhook`. While this behaviour will continue to work, administators are advised to use `/webhook`. + Note that OAuth requests also go through this listener. Previous versions of the bridge listened for requests on `/` rather than `/webhook`. While this behaviour will continue to work, administators are advised to use `/webhook`. - The `metrics` resource handles resources under `/metrics`. - The `provisioning` resource handles resources under `/v1/...`. - The `widgets` resource handles resources under `/widgetapi/v1...`. This may only be bound to **one** listener at present. @@ -259,7 +255,6 @@ cache: redisUri: "redis://redis-host:3679" ``` - ### Services configuration You will need to configure some services. Each service has its own documentation file inside the setup subdirectory. @@ -294,26 +289,26 @@ Enabling the `json` option will configure hookshot to output structured JSON log ```json5 { - // The level of the log. - "level": "WARN", - // The log message. - "message": "Failed to connect to homeserver", - // The module which emitted the log line. - "module": "Bridge", - // The timestamp of the log line. - "timestamp": "11:45:02:198", - // Optional error field, if the log includes an Error - "error": "connect ECONNREFUSED 127.0.0.1:8008", - // Additional context, possibly including the error body. - "args": [ - { - "address": "127.0.0.1", - "code": "ECONNREFUSED", - "errno": -111, - "port": 8008, - "syscall": "connect" - }, - "retrying in 5s" - ] + // The level of the log. + level: "WARN", + // The log message. + message: "Failed to connect to homeserver", + // The module which emitted the log line. + module: "Bridge", + // The timestamp of the log line. + timestamp: "11:45:02:198", + // Optional error field, if the log includes an Error + error: "connect ECONNREFUSED 127.0.0.1:8008", + // Additional context, possibly including the error body. + args: [ + { + address: "127.0.0.1", + code: "ECONNREFUSED", + errno: -111, + port: 8008, + syscall: "connect", + }, + "retrying in 5s", + ], } ``` diff --git a/docs/setup/figma.md b/docs/setup/figma.md index b2e73f913..671c72aa0 100644 --- a/docs/setup/figma.md +++ b/docs/setup/figma.md @@ -3,9 +3,10 @@ ## Setting up To bridge Figma webhooks with Hookshot, you will need: - - A personal access token with admin access to the team you intend to bridge. - - A figma account that is on the professional tier, as the free tier does provide webhook access. - - Your team ID. You can get this by going to the team page on Figma, and looking for the ID in the url (e.g. 12345 in `https://www.figma.com/files/team/12345/...`) + +- A personal access token with admin access to the team you intend to bridge. +- A figma account that is on the professional tier, as the free tier does provide webhook access. +- Your team ID. You can get this by going to the team page on Figma, and looking for the ID in the url (e.g. 12345 in `https://www.figma.com/files/team/12345/...`) ## Configuration @@ -36,7 +37,8 @@ The bridge will automatically set up a webhook on Figma for you upon startup, an If you have followed these steps correctly, Figma should now be configured with hookshot 🥳. To bridge a figma file into your room, you should: - - 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 figma file fileUrl` where `fileUrl` is the URL to the figma file e.g `https://www.figma.com/files/project/12345/...` - - Figma comments will now be bridged into the 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 figma file fileUrl` where `fileUrl` is the URL to the figma file e.g `https://www.figma.com/files/project/12345/...` +- Figma comments will now be bridged into the room. diff --git a/docs/setup/github.md b/docs/setup/github.md index d80a9674a..07b324a0c 100644 --- a/docs/setup/github.md +++ b/docs/setup/github.md @@ -1,5 +1,5 @@ -Setting up GitHub -====== +# Setting up GitHub + ## GitHub App This bridge requires a [GitHub App](https://github.com/settings/apps/new). You will need to create one. @@ -13,16 +13,16 @@ You **MUST** also provide a secret, which should match the `github.webhook.secre You will need to enable the following permissions: - - Repository - - Actions (`read`) - - Contents (`read`) - - Discussions (`read & write`) - - Issues (`read & write`) - - Metadata - - Projects (`read & write`) - - Pull requests (`read & write`) - - Organisation - - Team Discussions (`read & write`) +- Repository + - Actions (`read`) + - Contents (`read`) + - Discussions (`read & write`) + - Issues (`read & write`) + - Metadata + - Projects (`read & write`) + - Pull requests (`read & write`) +- Organisation + - Team Discussions (`read & write`) Hookshot handles the following webhook event types: diff --git a/docs/setup/gitlab.md b/docs/setup/gitlab.md index 76b3a85d2..d802e4bac 100644 --- a/docs/setup/gitlab.md +++ b/docs/setup/gitlab.md @@ -26,7 +26,6 @@ You should generate a webhook `secret` (e.g. `pwgen -n 64 -s 1`) and then use th The `publicUrl` must be the URL where GitLab webhook events are received (i.e. the path to `/` for your `webhooks` listener). -
The GitLab hooks "test" button allows you to check that your webhooks are configured properly. The merge request, issue and release notifications lack a action field in the body of the @@ -37,4 +36,3 @@ if the request made it through, however. ## Adding a repository You can now follow the guide on [authenticating with GitLab](../usage/auth.md#gitlab), and then [bridging a room](../usage/room_configuration/gitlab_project.md#setting-up) - diff --git a/docs/setup/jira.md b/docs/setup/jira.md index 75d23005f..08f71a288 100644 --- a/docs/setup/jira.md +++ b/docs/setup/jira.md @@ -4,13 +4,12 @@ This should be done for the JIRA instance you wish to bridge. The setup steps vary for Cloud and Enterprise (on-premise). -### Cloud +### Cloud See https://support.atlassian.com/jira-cloud-administration/docs/manage-webhooks/ for documentation on how to setup webhooks. Hookshot **requires** that you use a secret. Please copy the generated secret value to you config (seen below). - ### Enterprise You need to go to the `WebHooks` configuration page under Settings > System. @@ -30,8 +29,7 @@ You can now set some configuration in the bridge `config.yml`: jira: webhook: secret: your-webhook-secret - oauth: - ... # See below + oauth: ... # See below ``` You can omit the `oauth` section if you are not planning to allow users to log in and use interactive features (i.e. webhook only mode). @@ -49,10 +47,11 @@ You'll first need to head to https://developer.atlassian.com/console/myapps/crea "OAuth 2.0 (3LO)" integration. Once named and created, you will need to: - 1. Enable the User REST, Jira Platform REST and User Identity APIs under Permissions. - 2. Use rotating tokens under Authorisation. - 3. Set a callback url. This will be the public URL to hookshot with a path of `/jira/oauth`. - 4. Copy the client ID and Secret from Settings + +1. Enable the User REST, Jira Platform REST and User Identity APIs under Permissions. +2. Use rotating tokens under Authorisation. +3. Set a callback url. This will be the public URL to hookshot with a path of `/jira/oauth`. +4. Copy the client ID and Secret from Settings You can now set some configuration in the bridge `config.yml` @@ -71,7 +70,6 @@ The `redirect_uri` value must be the **public** path to `/jira/oauth` on the web points `https://example.com/hookshot` to the bridge `webhooks` listener, you should use the path `https://example.com/hookshot/jira/oauth`. This value MUST exactly match the **Callback URL** on the JIRA integration page page. - ## JIRA for On-Premise (Datacenter) These instructions are written for Jira Datacenter 8.x. @@ -79,7 +77,6 @@ These instructions use `openssl` to generate certificates, so users on non-Unix To begin, configure your `config.yml`: - ```yaml jira: url: https://yourjirainstance.com # The location of your jira instance. @@ -94,8 +91,8 @@ jira: # The path to your webhooks listener on the "/jira/oauth" path. redirect_uri: http://localhost:5065/jira/oauth ``` -To start with, set up your JIRA instance to support OAuth. +To start with, set up your JIRA instance to support OAuth. 1. Open the **Administration** page for your JIRA instance. 2. Click **Applications**. diff --git a/docs/setup/openproject.md b/docs/setup/openproject.md index d3c5f7aaa..af98d60ce 100644 --- a/docs/setup/openproject.md +++ b/docs/setup/openproject.md @@ -3,7 +3,6 @@ Setting up Hookshot for OpenProject requires setting up webhooks, and configuring an OAuth2 application so that users may login. - ### OpenProject Set up OpenProject to send Webhook requests to hookshot, following [the documentation](https://www.openproject.org/docs/system-admin-guide/api-and-webhooks/#webhooks). Please note the following: @@ -14,7 +13,6 @@ Set up OpenProject to send Webhook requests to hookshot, following [the document 4. You may enable as many projects as you like, but Hookshot must be configured to route the projects via it's connections principle. - You must also setup an OAuth application, following [the documentation](https://www.openproject.org/docs/system-admin-guide/authentication/oauth-applications/). Please note the following: 1. The Redirect URL will be the address of your [`webhooks` listener](https://matrix-org.github.io/matrix-hookshot/latest/setup.html#listeners-configuration), with the path of `/openproject/oauth`. @@ -24,20 +22,19 @@ You must also setup an OAuth application, following [the documentation](https:// Please keep a record of the Client ID and Client Secret to be used in the next step. - ### Hookshot You can now set some configuration in the bridge `config.yml`: ```yaml - # (Optional) Configure OpenProject support - baseUrl: https://your-open-project.com - webhook: - secret: secrettoken - oauth: - clientId: foo - clientSecret: bar - redirectUri: https://example.com/oauth/ +# (Optional) Configure OpenProject support +baseUrl: https://your-open-project.com +webhook: + secret: secrettoken +oauth: + clientId: foo + clientSecret: bar + redirectUri: https://example.com/oauth/ ``` ## Next steps diff --git a/docs/setup/webhooks.md b/docs/setup/webhooks.md index a641f9cf7..d01baa652 100644 --- a/docs/setup/webhooks.md +++ b/docs/setup/webhooks.md @@ -2,7 +2,6 @@ Hookshot supports two kinds of webhooks, inbound (previously known as Generic Webhooks) and outbound. - ## Configuration You will need to add the following configuration to the config file. @@ -47,10 +46,10 @@ but is now disabled due to concerns mentioned below. `maxExpiryTime` sets an upper limit on how long a webhook can be valid for before the bridge expires it. By default this is unlimited. This takes a duration represented by a string. E.g. "30d" is 30 days. See [this page](https://github.com/jkroso/parse-duration?tab=readme-ov-file#available-unit-types-are) -for available units. Additionally: +for available units. Additionally: - - `sendExpiryNotice` configures whether a message is sent into a room when the connection is close to expiring. - - `requireExpiryTime` forbids creating a webhook without a expiry time. This does not apply to existing webhooks. +- `sendExpiryNotice` configures whether a message is sent into a room when the connection is close to expiring. +- `requireExpiryTime` forbids creating a webhook without a expiry time. This does not apply to existing webhooks. You may set a `userIdPrefix` to create a specific user for each new webhook connection in a room. For example, a connection with a name like `example` for a prefix of `webhook_` will create a user called `@webhook_example:example.com`. If you enable this option, @@ -58,7 +57,7 @@ you need to configure the user to be part of your registration file e.g.: ```yaml # registration.yaml -... +--- namespaces: users: - regex: "@webhook_.+:example.com" # Where example.com is your domain name. @@ -68,10 +67,11 @@ namespaces: ### Adding a webhook To add a webhook 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 webhook example` where `example` is a name for your hook. - - The bot will respond with the webhook URL to be sent to services. + +- 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 webhook example` where `example` is a name for your hook. +- The bot will respond with the webhook URL to be sent to services. ### Webhook Handling @@ -86,11 +86,10 @@ a `html` key is provided.). If the body contains a `html` key, then that key will be used as the HTML message body in Matrix (aka `formatted_body`). A `text` key fallback MUST still be provided. -If the body *also* contains a `username` key, then the message will be prepended by the given username. This will be prepended to both `text` and `html`. +If the body _also_ contains a `username` key, then the message will be prepended by the given username. This will be prepended to both `text` and `html`. 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: @@ -126,7 +125,6 @@ can specify this either globally in your config, or on the widget with `waitForC 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. - #### Expiring webhooks Webhooks can be configured to expire, such that beyond a certain date they will fail any incoming requests. Currently this expiry time @@ -174,7 +172,7 @@ 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": "Some 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. - "mentions": { // Explicitly mention these users, see https://spec.matrix.org/latest/client-server-api/#user-and-room-mentions + "mentions": { // Explicitly mention these users, see https://spec.matrix.org/latest/client-server-api/#user-and-room-mentions "room": true, "user_ids": ["@foo:bar"] }, @@ -193,15 +191,20 @@ Where `data` = `{"counter": 5, "maxValue": 4}` ```js if (data.counter === undefined) { // The API didn't give us a counter, send no message. - result = {empty: true, version: "v2"}; + result = { empty: true, version: "v2" }; } else if (data.counter > data.maxValue) { - result = {plain: `**Oh no!** The counter has gone over by ${data.counter - data.maxValue}`, version: "v2"}; + result = { + plain: `**Oh no!** The counter has gone over by ${data.counter - data.maxValue}`, + version: "v2", + }; } else { - result = {plain: `*Everything is fine*, the counter is under by ${data.maxValue - data.counter}`, version: "v2"}; + result = { + plain: `*Everything is fine*, the counter is under by ${data.maxValue - data.counter}`, + version: "v2", + }; } ``` - #### V1 API The v1 API expects `result` to be a string. The string will be automatically interpreted as Markdown and transformed into HTML. All webhook messages @@ -213,9 +216,9 @@ Where `data` = `{"counter": 5, "maxValue": 4}` ```js if (data.counter > data.maxValue) { - result = `**Oh no!** The counter has gone over by ${data.counter - data.maxValue}` + result = `**Oh no!** The counter has gone over by ${data.counter - data.maxValue}`; } else { - result = `*Everything is fine*, the counter is under by ${data.maxValue - data.counter}` + result = `*Everything is fine*, the counter is under by ${data.maxValue - data.counter}`; } ``` @@ -236,10 +239,10 @@ or `POST` methods. Each request will contain 3 headers which you may use to authenticate and direct traffic: - - 'X-Matrix-Hookshot-EventId' contains the event's ID. - - 'X-Matrix-Hookshot-RoomId' contains the room ID where the message was sent. - - 'X-Matrix-Hookshot-Token' is the unique authentication token provided when you created the webhook. Use this - to verify that the message came from Hookshot. +- 'X-Matrix-Hookshot-EventId' contains the event's ID. +- 'X-Matrix-Hookshot-RoomId' contains the room ID where the message was sent. +- 'X-Matrix-Hookshot-Token' is the unique authentication token provided when you created the webhook. Use this + to verify that the message came from Hookshot. The payloads are formatted as `multipart/form-data`. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index f78a37fd4..8de5296bf 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -1,5 +1,4 @@ -Troubleshooting -=============== +# Troubleshooting If you are having difficulties getting set up with hookshot, the advice below might be able to resolve common issues. diff --git a/docs/usage.md b/docs/usage.md index 19c75f89c..a14047902 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -1,5 +1,4 @@ -Usage -===== +# Usage This section covers how to use the bridge, once it's set up. We break these down into categories: diff --git a/docs/usage/auth.md b/docs/usage/auth.md index 539724120..5e409efdd 100644 --- a/docs/usage/auth.md +++ b/docs/usage/auth.md @@ -5,14 +5,11 @@ on the service. This can be used to ensure you have the required permission on t or be able to execute commands like creating tickets. All services mentioned below support authenticating via a DM room with the bridge bot, using bot commands. -In this guide, we are going to assume the bot is called `@hookshot:example.com` but this will vary for your setup. For all the instructions below, commands should only be executed in **your DM room** with hookshot. +In this guide, we are going to assume the bot is called `@hookshot:example.com` but this will vary for your setup. For all the instructions below, commands should only be executed in **your DM room** with hookshot. Some services additionally support logging in via the widget, without the use of a DM room or bot commands. These are mentioned explicitly. - - - ## GitHub ### Personal access token @@ -25,27 +22,27 @@ Please note that you will need a Personal Access Token in order to bridge your p This is a limitation of GitHub's API.
- To authenticate with a personal access token: + 1. Open [https://github.com/settings/tokens](https://github.com/settings/tokens) (Github > Settings > Developer Settings / Personal access tokens) 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) - - 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 - - read:user - - write:discussion (for GitHub discussion support) - - read:discussion + + - Repo (to access repo information) + - 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 + - read:user + - write:discussion (for GitHub discussion support) + - read:discussion 1. Send the generated token to the bridge by saying `github setpersonaltoken %your-token%`. You can redact - the message afterwards if you like. + the message afterwards if you like. 1. The bridge will have connected you. - ### OAuth To authenticate via OAuth, you will need to have configured OAuth support in your config.yml, and have the endpoints required accessible from the internet. @@ -71,7 +68,6 @@ yet. the message afterwards if you like. - The bridge will have connected you. You can check the status at any time by saying `gitlab hastoken %instance% ` - ## JIRA You can log in to JIRA via OAuth. This means you will need to have configured OAuth support in your `config.yml`, and @@ -82,7 +78,6 @@ have the endpoints required accessible from the internet. Authentication is requ - Follow the steps, ensuring you authenticate with the right user. - If all goes well, you will now be connected. You can check the status of authenticated instances by saying `jira whoami` - ## OpenProject You can log in to OpenProject via OAuth. Authentication is required when trying to bridge OpenProject into rooms. @@ -92,4 +87,4 @@ You can log in to OpenProject via OAuth. Authentication is required when trying - Follow the steps, ensuring you authenticate with the right user. - If all goes well, you will now be connected. -You may also authenticate via a widget, if you have [configured widgets](../advanced/widgets.md) \ No newline at end of file +You may also authenticate via a widget, if you have [configured widgets](../advanced/widgets.md) diff --git a/docs/usage/dynamic_rooms.md b/docs/usage/dynamic_rooms.md index 8ab930cc8..8a498f6fb 100644 --- a/docs/usage/dynamic_rooms.md +++ b/docs/usage/dynamic_rooms.md @@ -1,5 +1,4 @@ -Dynamic Rooms -============= +# Dynamic Rooms
Anyone who has access to your homeserver can query these aliases (even over federation), and aliases @@ -7,7 +6,6 @@ Anyone who has access to your homeserver can query these aliases (even over fede content, it is advisable to disable this feature.
- Some bridges support dynamically creating rooms that point to resources based on an alias given by a user. Presently, the following are supported: diff --git a/docs/usage/room_configuration.md b/docs/usage/room_configuration.md index 1c370fe87..ce70ba47a 100644 --- a/docs/usage/room_configuration.md +++ b/docs/usage/room_configuration.md @@ -1,9 +1,7 @@ -Room Configuration -================== +# Room Configuration Hookshot works off the principle of **Connections**. - A room can have many connections to different services. The connections are defined in the room state of a room. A connection defines the service it connects to, the type of integration (e.g. GitHub repo, Jira Project) and any additional configuration. @@ -18,7 +16,6 @@ heading. The availability of connection types depends on the configuration provided to hookshot. - ### The `!hookshot` command Rooms can be bridged by inviting the hookshot bot into a room, and then running the diff --git a/docs/usage/room_configuration/github_repo.md b/docs/usage/room_configuration/github_repo.md index 70db9c326..881c4b599 100644 --- a/docs/usage/room_configuration/github_repo.md +++ b/docs/usage/room_configuration/github_repo.md @@ -1,5 +1,4 @@ -GitHub Repository -================= +# GitHub Repository This connection type connects a GitHub repository (e.g. https://github.com/matrix-org/matrix-hookshot) to a room. @@ -13,8 +12,8 @@ To set up a connection to a GitHub Repository in a new room: (NB you must have permission to bridge GitHub repositories before you can use this command, see [auth](../auth.html#github).) 1. The bridge will need to either: - - Have a GitHub installation registered with the organisation (or GitHub user account) - - The requesting user must be authenticated with the bridge via OAuth and the repository must be part of their GitHub account. + - Have a GitHub installation registered with the organisation (or GitHub user account) + - The requesting user must be authenticated with the bridge via OAuth and the repository must be part of their GitHub account. 2. Create a new, unencrypted room. It can be public or private. 3. Invite the bridge bot (e.g. `@hookshot:example.com`). 4. Give the bridge bot moderator permissions or higher (power level 50) (or otherwise configure the room so the bot can edit room state). @@ -25,49 +24,46 @@ To set up a connection to a GitHub Repository in a new room: This connection supports a few options which can be defined in the room state: -| Option | Description | Allowed values | Default | -|--------|-------------|----------------|---------| -|enableHooks [^1]|Enable notifications for some event types|Array of: [Supported event types](#supported-event-types) |If not defined, defaults are mentioned below| -|ignoreHooks [^1]|**deprecated** Choose to exclude notifications for some event types|Array of: [Supported event types](#supported-event-types) |*empty*| -|commandPrefix|Choose the prefix to use when sending commands to the bot|A string, ideally starts with "!"|`!gh`| -|showIssueRoomLink|When new issues are created, provide a Matrix alias link to the issue room|`true/false`|`false`| -|prDiff|Show a diff in the room when a PR is created, subject to limits|`{enabled: boolean, maxLines: number}`|`{enabled: false}`| -|includingLabels|Only notify on issues matching these label names|Array of: String matching a label name|*empty*| -|excludingLabels|Never notify on issues matching these label names|Array of: String matching a label name|*empty*| -|hotlinkIssues|Send a link to an issue/PR in the room when a user mentions a prefix followed by a number|` { prefix: string }`|`{prefix: "#"}`| -|newIssue|Configuration options for new issues|`{ labels: string[] }`|*empty*| -|newIssue.labels|Automatically set these labels on issues created via commands|Array of: String matching a label name|*empty*| -|workflowRun|Configuration options for workflow run results|`{ matchingBranch: string }`|*empty*| -|workflowRun.matchingBranch|Only report workflow runs if it matches this regex.|Regex string|*empty*| -|workflowRun.includingWorkflows|Only report workflow runs with a matching workflow name.|Array of: String matching a workflow name|*empty*| -|workflowRun.excludingWorkflows|Never report workflow runs with a matching workflow name.|Array of: String matching a workflow name|*empty*| - +| Option | Description | Allowed values | Default | +| ------------------------------ | ----------------------------------------------------------------------------------------- | --------------------------------------------------------- | -------------------------------------------- | +| enableHooks [^1] | Enable notifications for some event types | Array of: [Supported event types](#supported-event-types) | If not defined, defaults are mentioned below | +| ignoreHooks [^1] | **deprecated** Choose to exclude notifications for some event types | Array of: [Supported event types](#supported-event-types) | _empty_ | +| commandPrefix | Choose the prefix to use when sending commands to the bot | A string, ideally starts with "!" | `!gh` | +| showIssueRoomLink | When new issues are created, provide a Matrix alias link to the issue room | `true/false` | `false` | +| prDiff | Show a diff in the room when a PR is created, subject to limits | `{enabled: boolean, maxLines: number}` | `{enabled: false}` | +| includingLabels | Only notify on issues matching these label names | Array of: String matching a label name | _empty_ | +| excludingLabels | Never notify on issues matching these label names | Array of: String matching a label name | _empty_ | +| hotlinkIssues | Send a link to an issue/PR in the room when a user mentions a prefix followed by a number | ` { prefix: string }` | `{prefix: "#"}` | +| newIssue | Configuration options for new issues | `{ labels: string[] }` | _empty_ | +| newIssue.labels | Automatically set these labels on issues created via commands | Array of: String matching a label name | _empty_ | +| workflowRun | Configuration options for workflow run results | `{ matchingBranch: string }` | _empty_ | +| workflowRun.matchingBranch | Only report workflow runs if it matches this regex. | Regex string | _empty_ | +| workflowRun.includingWorkflows | Only report workflow runs with a matching workflow name. | Array of: String matching a workflow name | _empty_ | +| workflowRun.excludingWorkflows | Never report workflow runs with a matching workflow name. | Array of: String matching a workflow name | _empty_ | [^1]: `ignoreHooks` is no longer accepted for new state events. Use `enableHooks` to explicitly state all events you want to see. - - ### Supported event types This connection supports sending messages when the following actions happen on the repository. -Note: Some of these event types are enabled by default (marked with a `*`). When `ignoreHooks` *is* defined, +Note: Some of these event types are enabled by default (marked with a `*`). When `ignoreHooks` _is_ defined, the events marked as default below will be enabled. Otherwise, this is ignored. -- issue * - - issue.created * - - issue.changed * - - issue.edited * - - issue.labeled * -- pull_request * - - pull_request.closed * - - pull_request.merged * - - pull_request.opened * - - pull_request.ready_for_review * - - pull_request.reviewed * +- issue \* + - issue.created \* + - issue.changed \* + - issue.edited \* + - issue.labeled \* +- pull_request \* + - pull_request.closed \* + - pull_request.merged \* + - pull_request.opened \* + - pull_request.ready_for_review \* + - pull_request.reviewed \* - push -- release * - - release.created * +- release \* + - release.created \* - release.drafted - workflow.run - workflow.run.success diff --git a/docs/usage/room_configuration/gitlab_project.md b/docs/usage/room_configuration/gitlab_project.md index 23e69b268..1f6f0962a 100644 --- a/docs/usage/room_configuration/gitlab_project.md +++ b/docs/usage/room_configuration/gitlab_project.md @@ -1,5 +1,4 @@ -GitLab Project -================= +# GitLab Project This connection type connects a GitLab project (e.g. https://gitlab.matrix.org/matrix-org/olm) to a room. @@ -21,39 +20,37 @@ To set up a connection to a GitLab project in a new room: This connection supports a few options which can be defined in the room state[^2]: -| Option | Description | Allowed values | Default | -|--------|-------------|----------------|---------| -|commandPrefix|Choose the prefix to use when sending commands to the bot|A string, ideally starts with "!"|`!gh`| -|enableHooks [^1]|Enable notifications for some event types|Array of: [Supported event types](#supported-event-types) |If not defined, defaults are mentioned below| -|excludingLabels|Never notify on issues matching these label names|Array of: String matching a label name|*empty*| -|ignoreHooks [^1]|**deprecated** Choose to exclude notifications for some event types|Array of: [Supported event types](#supported-event-types) |*empty*| -|includeCommentBody|Include the body of a comment when notifying on merge requests|Boolean|false| -|includingLabels|Only notify on issues matching these label names|Array of: String matching a label name|*empty*| -|pushTagsRegex|Only mention pushed tags which match this regex|Regex string|*empty*| - +| Option | Description | Allowed values | Default | +| ------------------ | ------------------------------------------------------------------- | --------------------------------------------------------- | -------------------------------------------- | +| commandPrefix | Choose the prefix to use when sending commands to the bot | A string, ideally starts with "!" | `!gh` | +| enableHooks [^1] | Enable notifications for some event types | Array of: [Supported event types](#supported-event-types) | If not defined, defaults are mentioned below | +| excludingLabels | Never notify on issues matching these label names | Array of: String matching a label name | _empty_ | +| ignoreHooks [^1] | **deprecated** Choose to exclude notifications for some event types | Array of: [Supported event types](#supported-event-types) | _empty_ | +| includeCommentBody | Include the body of a comment when notifying on merge requests | Boolean | false | +| includingLabels | Only notify on issues matching these label names | Array of: String matching a label name | _empty_ | +| pushTagsRegex | Only mention pushed tags which match this regex | Regex string | _empty_ | [^1]: `ignoreHooks` is no longer accepted for new state events. Use `enableHooks` to explicitly state all events you want to see. [^2]: To change room state with Element Web/Desktop: open devtools by typing `/devtools` + enter in the chat box, and click on "Explore room state". Next, choose `uk.half-shot.matrix-hookshot.gitlab.repository`. You'll then be able to see and edit (if you have the required permissions) the settings by setting properties on the JSON object directly. - ### Supported event types This connection supports sending messages when the following actions happen on the repository. -Note: Some of these event types are enabled by default (marked with a `*`). When `ignoreHooks` *is* defined, +Note: Some of these event types are enabled by default (marked with a `*`). When `ignoreHooks` _is_ defined, the events marked as default below will be enabled. Otherwise, this is ignored. -- merge_request * - - merge_request.close * - - merge_request.merge * - - merge_request.open * - - merge_request.reopen * - - merge_request.review.comments * - - merge_request.review * +- merge_request \* + - merge_request.close \* + - merge_request.merge \* + - merge_request.open \* + - merge_request.reopen \* + - merge_request.review.comments \* + - merge_request.review \* - merge_request.review.individual -- push * -- release * - - release.created * -- tag_push * -- wiki * +- push \* +- release \* + - release.created \* +- tag_push \* +- wiki \* diff --git a/docs/usage/room_configuration/jira_project.md b/docs/usage/room_configuration/jira_project.md index 61ea0d032..dede5477b 100644 --- a/docs/usage/room_configuration/jira_project.md +++ b/docs/usage/room_configuration/jira_project.md @@ -1,5 +1,4 @@ -JIRA Project -================= +# JIRA Project This connection type connects a JIRA project to a room. @@ -27,11 +26,10 @@ Send the command `!hookshot jira remove project ` to remove a room's connec This connection supports a few options which can be defined in the room state: -| Option | Description | Allowed values | Default | -|--------|-------------|----------------|---------| -|events|Choose to include notifications for some event types|Array of: [Supported event types](#supported-event-types) |`issue_created`| -|commandPrefix|Choose the prefix to use when sending commands to the bot|A string, ideally starts with "!"|`!jira`| - +| Option | Description | Allowed values | Default | +| ------------- | --------------------------------------------------------- | --------------------------------------------------------- | --------------- | +| events | Choose to include notifications for some event types | Array of: [Supported event types](#supported-event-types) | `issue_created` | +| commandPrefix | Choose the prefix to use when sending commands to the bot | A string, ideally starts with "!" | `!jira` | ### Supported event types @@ -43,4 +41,4 @@ This connection supports sending messages when the following actions happen on t - version - version_created - version_updated - - version_released \ No newline at end of file + - version_released diff --git a/package.json b/package.json index 68a409f27..24aa4498a 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,8 @@ "test:e2e": "vitest", "test:cover": "NODE_OPTIONS=--no-experimental-strip-types nyc --reporter=lcov --reporter=text yarn test", "lint": "yarn run lint:js && yarn run lint:rs", - "lint:js": "eslint", + "lint:js": "yarn prettier -c src tests spec web scripts docs && eslint", + "lint:js:apply": "yarn prettier -w src tests spec web scripts docs && eslint --fix", "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", @@ -115,6 +116,7 @@ "mocha": "^10.8.2", "nyc": "^17.1.0", "preact": "^10.26.2", + "prettier": "^3.5.3", "rimraf": "6.0.1", "sass": "^1.81.0", "testcontainers": "^10.25.0", diff --git a/scripts/build-metrics-docs.ts b/scripts/build-metrics-docs.ts index f942b3b09..38b136be5 100644 --- a/scripts/build-metrics-docs.ts +++ b/scripts/build-metrics-docs.ts @@ -6,20 +6,25 @@ import { register } from "prom-client"; Metrics.getMetrics(); // eslint-disable-next-line @typescript-eslint/no-explicit-any -const anyRegister = register as any as {_metrics: {[metricName: string]: {labelNames: string[], name: string, help: string}}}; - -const categories: {[title: string]: {name: string, labels: string[], help: string}[]} = {}; - -Object.entries(anyRegister._metrics).map( - ([key, value]) => { - const [categoryName] = key.split('_'); - categories[categoryName] = categories[categoryName] || []; - categories[categoryName].push({ - name: key, - labels: value.labelNames, - help: value.help, - }); - }); +const anyRegister = register as any as { + _metrics: { + [metricName: string]: { labelNames: string[]; name: string; help: string }; + }; +}; + +const categories: { + [title: string]: { name: string; labels: string[]; help: string }[]; +} = {}; + +Object.entries(anyRegister._metrics).map(([key, value]) => { + const [categoryName] = key.split("_"); + categories[categoryName] = categories[categoryName] || []; + categories[categoryName].push({ + name: key, + labels: value.labelNames, + help: value.help, + }); +}); // Generate some markdown @@ -46,11 +51,13 @@ Select the Prometheus instance with your Hookshot metrics as Data Source. Set In Below is the generated list of Prometheus metrics for Hookshot. -`) +`); Object.entries(categories).forEach(([name, entries]) => { - console.log(`## ${name}`); - console.log('| Metric | Help | Labels |'); - console.log('|--------|------|--------|'); - entries.forEach((e) => console.log(`| ${e.name} | ${e.help} | ${e.labels.join(', ')} |`)); + console.log(`## ${name}`); + console.log("| Metric | Help | Labels |"); + console.log("|--------|------|--------|"); + entries.forEach((e) => + console.log(`| ${e.name} | ${e.help} | ${e.labels.join(", ")} |`), + ); }); diff --git a/scripts/definitions-fixer.ts b/scripts/definitions-fixer.ts index 42718e486..a318093a7 100644 --- a/scripts/definitions-fixer.ts +++ b/scripts/definitions-fixer.ts @@ -3,19 +3,21 @@ import { promises as fs } from "fs"; async function processDefFile() { - const path = process.argv[process.argv.length-1]; + const path = process.argv[process.argv.length - 1]; // Read the whole file in to prevent us writing over ourselves. const file = await fs.readFile(path, "utf-8"); - const out = await fs.open(path, 'w'); - for (const line of file.split('\n')) { + const out = await fs.open(path, "w"); + for (const line of file.split("\n")) { const match = / {2}(\w+\.[\w.-]+):/g.exec(line); - await out.write((match ? line.replace(match[1], `"${match[1]}"`) : line) + "\n"); + await out.write( + (match ? line.replace(match[1], `"${match[1]}"`) : line) + "\n", + ); } await out.close(); } processDefFile().catch((ex) => { - console.error('Failed to process def file!', ex); - process.exit(1); -}) + console.error("Failed to process def file!", ex); + process.exit(1); +}); diff --git a/spec/basic.spec.ts b/spec/basic.spec.ts index c7009a1e9..63f2d2cba 100644 --- a/spec/basic.spec.ts +++ b/spec/basic.spec.ts @@ -3,27 +3,34 @@ import { E2ESetupTestTimeout, E2ETestEnv } from "./util/e2e-test"; import { expect } from "chai"; import { describe, test, beforeEach, afterEach } from "vitest"; -describe('Basic test setup', () => { - let testEnv: E2ETestEnv; +describe("Basic test setup", () => { + let testEnv: E2ETestEnv; - beforeEach(async () => { - testEnv = await E2ETestEnv.createTestEnv({matrixLocalparts: ['user']}); - await testEnv.setUp(); - }, E2ESetupTestTimeout); + beforeEach(async () => { + testEnv = await E2ETestEnv.createTestEnv({ matrixLocalparts: ["user"] }); + await testEnv.setUp(); + }, E2ESetupTestTimeout); - afterEach(() => { - return testEnv?.tearDown(); - }); + afterEach(() => { + return testEnv?.tearDown(); + }); - test('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 }); - const msg = user.waitForRoomEvent({ - eventType: 'm.room.message', sender: testEnv.botMxid, roomId - }); - await user.sendText(roomId, "!hookshot help"); - // Expect help text. - expect((await msg).data.content.body).to.include('!hookshot help` - This help text\n'); + test("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 }); + const msg = user.waitForRoomEvent({ + eventType: "m.room.message", + sender: testEnv.botMxid, + roomId, }); + await user.sendText(roomId, "!hookshot help"); + // Expect help text. + expect((await msg).data.content.body).to.include( + "!hookshot help` - This help text\n", + ); + }); }); diff --git a/spec/e2ee.spec.ts b/spec/e2ee.spec.ts index 4bf05343e..01ecd738f 100644 --- a/spec/e2ee.spec.ts +++ b/spec/e2ee.spec.ts @@ -2,65 +2,89 @@ import { MessageEventContent } from "matrix-bot-sdk"; import { E2ESetupTestTimeout, E2ETestEnv } from "./util/e2e-test"; import { describe, test, beforeEach, afterEach } from "vitest"; -const CryptoRoomState = [{ +const CryptoRoomState = [ + { content: { - "algorithm": "m.megolm.v1.aes-sha2" + algorithm: "m.megolm.v1.aes-sha2", }, state_key: "", - type: "m.room.encryption" -}]; + type: "m.room.encryption", + }, +]; -describe('End-2-End Encryption support', () => { - let testEnv: E2ETestEnv; +describe("End-2-End Encryption support", () => { + let testEnv: E2ETestEnv; - beforeEach(async () => { - testEnv = await E2ETestEnv.createTestEnv({ matrixLocalparts: ['user'], enableE2EE: true }); - await testEnv.setUp(); - }, E2ESetupTestTimeout); - - afterEach(() => { - return testEnv?.tearDown(); + beforeEach(async () => { + testEnv = await E2ETestEnv.createTestEnv({ + matrixLocalparts: ["user"], + enableE2EE: true, }); + await testEnv.setUp(); + }, E2ESetupTestTimeout); - test('should be able to send the help command', async () => { - const user = testEnv.getUser('user'); - const testRoomId = await user.createRoom({ name: 'Test room', invite:[testEnv.botMxid], initial_state: CryptoRoomState}); - await user.setUserPowerLevel(testEnv.botMxid, testRoomId, 50); - await user.waitForRoomJoin({sender: testEnv.botMxid, roomId: testRoomId }); - await user.sendText(testRoomId, "!hookshot help"); - await user.waitForRoomEvent({ - eventType: 'm.room.message', sender: testEnv.botMxid, roomId: testRoomId, - }); - }); - test('should send notices in an encrypted format', async () => { - const user = testEnv.getUser('user'); - const testRoomId = await user.createRoom({ name: 'Test room', invite:[testEnv.botMxid], initial_state: CryptoRoomState}); - await user.setUserPowerLevel(testEnv.botMxid, testRoomId, 50); - await user.waitForRoomJoin({sender: testEnv.botMxid, roomId: testRoomId }); - await user.sendText(testRoomId, "!hookshot webhook test-webhook"); - const inviteResponse = await user.waitForRoomInvite({sender: testEnv.botMxid}); - await user.waitForEncryptedEvent({ - eventType: 'm.room.message', sender: testEnv.botMxid, roomId: testRoomId, - body: 'Room configured to bridge webhooks. See admin room for secret url.' - }); - const webhookUrlMessage = user.waitForEncryptedEvent({ - 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.waitForEncryptedEvent({ - eventType: 'm.room.message', sender: testEnv.botMxid, roomId: testRoomId, body: 'Hello world!' - }); + afterEach(() => { + return testEnv?.tearDown(); + }); - // Send a webhook - await fetch(webhookUrl, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({text: 'Hello world!'}) - }); + test("should be able to send the help command", async () => { + const user = testEnv.getUser("user"); + const testRoomId = await user.createRoom({ + name: "Test room", + invite: [testEnv.botMxid], + initial_state: CryptoRoomState, + }); + await user.setUserPowerLevel(testEnv.botMxid, testRoomId, 50); + await user.waitForRoomJoin({ sender: testEnv.botMxid, roomId: testRoomId }); + await user.sendText(testRoomId, "!hookshot help"); + await user.waitForRoomEvent({ + eventType: "m.room.message", + sender: testEnv.botMxid, + roomId: testRoomId, + }); + }); + test("should send notices in an encrypted format", async () => { + const user = testEnv.getUser("user"); + const testRoomId = await user.createRoom({ + name: "Test room", + invite: [testEnv.botMxid], + initial_state: CryptoRoomState, + }); + await user.setUserPowerLevel(testEnv.botMxid, testRoomId, 50); + await user.waitForRoomJoin({ sender: testEnv.botMxid, roomId: testRoomId }); + await user.sendText(testRoomId, "!hookshot webhook test-webhook"); + const inviteResponse = await user.waitForRoomInvite({ + sender: testEnv.botMxid, + }); + await user.waitForEncryptedEvent({ + eventType: "m.room.message", + sender: testEnv.botMxid, + roomId: testRoomId, + body: "Room configured to bridge webhooks. See admin room for secret url.", + }); + const webhookUrlMessage = user.waitForEncryptedEvent({ + 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.waitForEncryptedEvent({ + eventType: "m.room.message", + sender: testEnv.botMxid, + roomId: testRoomId, + body: "Hello world!", + }); - // And await the notice. - await webhookNotice; + // Send a webhook + await fetch(webhookUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ text: "Hello world!" }), }); + + // And await the notice. + await webhookNotice; + }); }); diff --git a/spec/generic-hooks.spec.ts b/spec/generic-hooks.spec.ts index f13c1f257..13678cb12 100644 --- a/spec/generic-hooks.spec.ts +++ b/spec/generic-hooks.spec.ts @@ -1,120 +1,162 @@ -import { E2ESetupTestTimeout, E2ETestEnv, E2ETestMatrixClient } from "./util/e2e-test"; +import { + E2ESetupTestTimeout, + E2ETestEnv, + E2ETestMatrixClient, +} from "./util/e2e-test"; import { describe, test, beforeAll, afterAll, expect, vitest } from "vitest"; import { GenericHookConnection } from "../src/Connections"; import { TextualMessageEventContent } from "matrix-bot-sdk"; import { add } from "date-fns/add"; +async function createInboundConnection( + user: E2ETestMatrixClient, + botMxid: string, + roomId: string, + duration?: string, +) { + const join = user.waitForRoomJoin({ sender: botMxid, roomId }); + const connectionEvent = user.waitForRoomEvent({ + eventType: GenericHookConnection.CanonicalEventType, + stateKey: "test", + sender: botMxid, + }); + await user.inviteUser(botMxid, roomId); + await user.setUserPowerLevel(botMxid, roomId, 50); + await join; -async function createInboundConnection(user: E2ETestMatrixClient, botMxid: string, roomId: string, duration?: string) { - const join = user.waitForRoomJoin({ sender: botMxid, roomId }); - const connectionEvent = user.waitForRoomEvent({ - eventType: GenericHookConnection.CanonicalEventType, - stateKey: 'test', - sender: botMxid - }); - await user.inviteUser(botMxid, roomId); - await user.setUserPowerLevel(botMxid, roomId, 50); - await join; - - // Note: Here we create the DM proactively so this works across multiple - // tests. - // Get the DM room so we can get the token. - const dmRoomId = await user.dms.getOrCreateDm(botMxid); + // Note: Here we create the DM proactively so this works across multiple + // tests. + // Get the DM room so we can get the token. + const dmRoomId = await user.dms.getOrCreateDm(botMxid); - await user.sendText(roomId, '!hookshot webhook test' + (duration ? ` ${duration}` : "")); - // Test the contents of this. - await connectionEvent; + await user.sendText( + roomId, + "!hookshot webhook test" + (duration ? ` ${duration}` : ""), + ); + // Test the contents of this. + await connectionEvent; - const msgPromise = user.waitForRoomEvent({ sender: botMxid, eventType: "m.room.message", roomId: dmRoomId }); - const { data: msgData } = await msgPromise; - const msgContent = msgData.content as unknown as TextualMessageEventContent; - const [_unused1, _unused2, url] = msgContent.body.split('\n'); - return url; + const msgPromise = user.waitForRoomEvent({ + sender: botMxid, + eventType: "m.room.message", + roomId: dmRoomId, + }); + const { data: msgData } = await msgPromise; + const msgContent = msgData.content as unknown as TextualMessageEventContent; + const [_unused1, _unused2, url] = msgContent.body.split("\n"); + return url; } -describe('Inbound (Generic) Webhooks', () => { - let testEnv: E2ETestEnv; - - beforeAll(async () => { - const webhooksPort = 9500 + E2ETestEnv.workerId; - testEnv = await E2ETestEnv.createTestEnv({ - matrixLocalparts: ['user'], - config: { - generic: { - enabled: true, - // Prefer to wait for complete as it reduces the concurrency of the test. - waitForComplete: true, - urlPrefix: `http://localhost:${webhooksPort}` - }, - listeners: [{ - port: webhooksPort, - bindAddress: '0.0.0.0', - // Bind to the SAME listener to ensure we don't have conflicts. - resources: ['webhooks'], - }], - } - }); - await testEnv.setUp(); - }, E2ESetupTestTimeout); +describe("Inbound (Generic) Webhooks", () => { + let testEnv: E2ETestEnv; - afterAll(() => { - return testEnv?.tearDown(); + beforeAll(async () => { + const webhooksPort = 9500 + E2ETestEnv.workerId; + testEnv = await E2ETestEnv.createTestEnv({ + matrixLocalparts: ["user"], + config: { + generic: { + enabled: true, + // Prefer to wait for complete as it reduces the concurrency of the test. + waitForComplete: true, + urlPrefix: `http://localhost:${webhooksPort}`, + }, + listeners: [ + { + port: webhooksPort, + bindAddress: "0.0.0.0", + // Bind to the SAME listener to ensure we don't have conflicts. + resources: ["webhooks"], + }, + ], + }, }); + await testEnv.setUp(); + }, E2ESetupTestTimeout); - test('should be able to create a new webhook and handle an incoming request.', async () => { - const user = testEnv.getUser('user'); - const roomId = await user.createRoom({ name: 'My Test Webhooks room'}); - const okMsg = user.waitForRoomEvent({ eventType: "m.room.message", sender: testEnv.botMxid, roomId }); - const url = await createInboundConnection(user, testEnv.botMxid, roomId); - expect((await okMsg).data.content.body).toEqual('Room configured to bridge webhooks. See admin room for secret url.'); + afterAll(() => { + return testEnv?.tearDown(); + }); - const expectedMsg = user.waitForRoomEvent({ eventType: "m.room.message", sender: testEnv.botMxid, roomId }); - const req = await fetch(url, { - method: "PUT", - body: "Hello world" - }); - expect(req.status).toEqual(200); - expect(await req.json()).toEqual({ ok: true }); - expect((await expectedMsg).data.content).toEqual({ - msgtype: 'm.notice', - body: 'Received webhook data: Hello world', - formatted_body: '

Received webhook data: Hello world

', - format: 'org.matrix.custom.html', - 'uk.half-shot.hookshot.webhook_data': 'Hello world' - }); + test("should be able to create a new webhook and handle an incoming request.", async () => { + const user = testEnv.getUser("user"); + const roomId = await user.createRoom({ name: "My Test Webhooks room" }); + const okMsg = user.waitForRoomEvent({ + eventType: "m.room.message", + sender: testEnv.botMxid, + roomId, + }); + const url = await createInboundConnection(user, testEnv.botMxid, roomId); + expect((await okMsg).data.content.body).toEqual( + "Room configured to bridge webhooks. See admin room for secret url.", + ); + + const expectedMsg = user.waitForRoomEvent({ + eventType: "m.room.message", + sender: testEnv.botMxid, + roomId, + }); + const req = await fetch(url, { + method: "PUT", + body: "Hello world", }); + expect(req.status).toEqual(200); + expect(await req.json()).toEqual({ ok: true }); + expect((await expectedMsg).data.content).toEqual({ + msgtype: "m.notice", + body: "Received webhook data: Hello world", + formatted_body: "

Received webhook data: Hello world

", + format: "org.matrix.custom.html", + "uk.half-shot.hookshot.webhook_data": "Hello world", + }); + }); - test('should be able to create a new expiring webhook and handle valid requests.', async () => { - vitest.useFakeTimers(); - const user = testEnv.getUser('user'); - const roomId = await user.createRoom({ name: 'My Test Webhooks room'}); - const okMsg = user.waitForRoomEvent({ eventType: "m.room.message", sender: testEnv.botMxid, roomId }); - const url = await createInboundConnection(user, testEnv.botMxid, roomId, '2h'); - expect((await okMsg).data.content.body).toEqual('Room configured to bridge webhooks. See admin room for secret url.'); + test("should be able to create a new expiring webhook and handle valid requests.", async () => { + vitest.useFakeTimers(); + const user = testEnv.getUser("user"); + const roomId = await user.createRoom({ name: "My Test Webhooks room" }); + const okMsg = user.waitForRoomEvent({ + eventType: "m.room.message", + sender: testEnv.botMxid, + roomId, + }); + const url = await createInboundConnection( + user, + testEnv.botMxid, + roomId, + "2h", + ); + expect((await okMsg).data.content.body).toEqual( + "Room configured to bridge webhooks. See admin room for secret url.", + ); - const expectedMsg = user.waitForRoomEvent({ eventType: "m.room.message", sender: testEnv.botMxid, roomId }); - const req = await fetch(url, { - method: "PUT", - body: "Hello world" - }); - expect(req.status).toEqual(200); - expect(await req.json()).toEqual({ ok: true }); - expect((await expectedMsg).data.content).toEqual({ - msgtype: 'm.notice', - body: 'Received webhook data: Hello world', - formatted_body: '

Received webhook data: Hello world

', - format: 'org.matrix.custom.html', - 'uk.half-shot.hookshot.webhook_data': 'Hello world' - }); - vitest.setSystemTime(add(new Date(), { hours: 3 })); - const expiredReq = await fetch(url, { - method: "PUT", - body: "Hello world" - }); - expect(expiredReq.status).toEqual(404); - expect(await expiredReq.json()).toEqual({ - ok: false, - error: "This hook has expired", - }); + const expectedMsg = user.waitForRoomEvent({ + eventType: "m.room.message", + sender: testEnv.botMxid, + roomId, + }); + const req = await fetch(url, { + method: "PUT", + body: "Hello world", + }); + expect(req.status).toEqual(200); + expect(await req.json()).toEqual({ ok: true }); + expect((await expectedMsg).data.content).toEqual({ + msgtype: "m.notice", + body: "Received webhook data: Hello world", + formatted_body: "

Received webhook data: Hello world

", + format: "org.matrix.custom.html", + "uk.half-shot.hookshot.webhook_data": "Hello world", + }); + vitest.setSystemTime(add(new Date(), { hours: 3 })); + const expiredReq = await fetch(url, { + method: "PUT", + body: "Hello world", + }); + expect(expiredReq.status).toEqual(404); + expect(await expiredReq.json()).toEqual({ + ok: false, + error: "This hook has expired", }); + }); }); diff --git a/spec/github.spec.ts b/spec/github.spec.ts index 182a1bbdd..dd4018978 100644 --- a/spec/github.spec.ts +++ b/spec/github.spec.ts @@ -2,134 +2,164 @@ import { E2ESetupTestTimeout, E2ETestEnv } from "./util/e2e-test"; import { describe, test, beforeAll, afterAll, expect } from "vitest"; import { createHmac, randomUUID } from "crypto"; -import { GitHubRepoConnection, GitHubRepoConnectionState } from "../src/Connections"; +import { + GitHubRepoConnection, + GitHubRepoConnectionState, +} from "../src/Connections"; import { MessageEventContent } from "matrix-bot-sdk"; import { getBridgeApi } from "./util/bridge-api"; import { Server, createServer } from "http"; import { waitFor } from "./util/helpers"; -describe('GitHub', () => { - let testEnv: E2ETestEnv; - let githubServer: Server; - const webhooksPort = 9500 + E2ETestEnv.workerId; - const githubPort = 9700 + E2ETestEnv.workerId; +describe("GitHub", () => { + let testEnv: E2ETestEnv; + let githubServer: Server; + const webhooksPort = 9500 + E2ETestEnv.workerId; + const githubPort = 9700 + E2ETestEnv.workerId; - beforeAll(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); - - afterAll(() => { - githubServer?.close(); - return testEnv?.tearDown(); + beforeAll(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); + + afterAll(() => { + githubServer?.close(); + return testEnv?.tearDown(); + }); - test('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); + test("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 waitFor(async () => (await bridgeApi.getConnectionsForRoom(testRoomId)).length === 1); + // Wait for connection to be accepted. + await waitFor( + async () => + (await bridgeApi.getConnectionsForRoom(testRoomId)).length === 1, + ); - const webhookNotice = user.waitForRoomEvent({ - eventType: 'm.room.message', sender: testEnv.botMxid, roomId: testRoomId - }); + const webhookNotice = user.waitForRoomEvent({ + 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 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(); + 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'); + // 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"); + }); }); diff --git a/spec/jira.spec.ts b/spec/jira.spec.ts index 1d4353935..379630488 100644 --- a/spec/jira.spec.ts +++ b/spec/jira.spec.ts @@ -1,159 +1,184 @@ import { E2ESetupTestTimeout, E2ETestEnv } from "./util/e2e-test"; -import { describe, expect, beforeAll, afterAll, test } from "vitest" +import { describe, expect, beforeAll, afterAll, test } from "vitest"; import { createHmac, randomUUID } from "crypto"; -import { JiraProjectConnection, JiraProjectConnectionState } from "../src/Connections"; +import { + JiraProjectConnection, + JiraProjectConnectionState, +} from "../src/Connections"; import { MessageEventContent } from "matrix-bot-sdk"; import { JiraGrantChecker } from "../src/jira/GrantChecker"; import { getBridgeApi } from "./util/bridge-api"; import { waitFor } from "./util/helpers"; const JIRA_PAYLOAD = { - "timestamp": 1745506426948, - "webhookEvent": "jira:issue_created", - "issue_event_type_name": "issue_created", - "user": { - "accountId": "1234567890", - "displayName": "Test User", + timestamp: 1745506426948, + webhookEvent: "jira:issue_created", + issue_event_type_name: "issue_created", + user: { + accountId: "1234567890", + displayName: "Test User", + }, + issue: { + id: "10007", + self: "https://example.org/TP-8", + key: "TP-8", + fields: { + statuscategorychangedate: "2025-04-24T15:53:47.084+0100", + issuetype: { + id: "10001", + name: "Task", + }, + components: [], + timespent: null, + timeoriginalestimate: null, + project: { + self: "https://example.org/my/test/project/TP", + key: "TP", + id: "12345", + name: "Test Project", + projectTypeKey: "project-type-key", + simplified: false, + avatarUrls: {}, + }, + description: null, + summary: "Test issue", + lastViewed: null, + creator: { + accountId: "1234567890", + displayName: "Test User", + self: "https://example.org/user/1234567890", + avatarUrls: {}, + active: true, + timeZone: "UTC", + accountType: "atlassian", + }, + subtasks: [], + created: "2025-04-24T15:53:46.821+0100", + reporter: { + accountId: "1234567890", + displayName: "Test User", + }, + labels: [], + environment: null, + timeestimate: null, + aggregatetimeoriginalestimate: null, + versions: [], + duedate: null, + progress: { + progress: 0, + total: 0, + }, + issuelinks: [], + assignee: null, + updated: "2025-04-24T15:53:46.821+0100", + status: { + name: "To Do", + id: "10000", + }, + priority: {}, }, - "issue": { - "id": "10007", - "self": "https://example.org/TP-8", - "key": "TP-8", - "fields": { - "statuscategorychangedate": "2025-04-24T15:53:47.084+0100", - "issuetype": { - "id": "10001", - "name": "Task", - }, - "components": [], - "timespent": null, - "timeoriginalestimate": null, - "project": { - "self": "https://example.org/my/test/project/TP", - "key": "TP", - "id": "12345", - name: "Test Project", - projectTypeKey: "project-type-key", - simplified: false, - avatarUrls: {} - }, - "description": null, - "summary": "Test issue", - "lastViewed": null, - "creator": { - "accountId": "1234567890", - "displayName": "Test User", - "self": "https://example.org/user/1234567890", - avatarUrls: {}, - active: true, - timeZone: "UTC", - accountType: "atlassian", - }, - "subtasks": [], - "created": "2025-04-24T15:53:46.821+0100", - "reporter": { - "accountId": "1234567890", - "displayName": "Test User", - }, - "labels": [], - "environment": null, - "timeestimate": null, - "aggregatetimeoriginalestimate": null, - "versions": [], - "duedate": null, - "progress": { - "progress": 0, - "total": 0 - }, - "issuelinks": [], - "assignee": null, - "updated": "2025-04-24T15:53:46.821+0100", - "status": { - "name": "To Do", - "id": "10000", - }, - "priority": { } - } - }, - }; + }, +}; +describe("JIRA", () => { + let testEnv: E2ETestEnv; + const webhooksPort = 9500 + E2ETestEnv.workerId; -describe('JIRA', () => { - let testEnv: E2ETestEnv; - const webhooksPort = 9500 + E2ETestEnv.workerId; + beforeAll(async () => { + testEnv = await E2ETestEnv.createTestEnv({ + matrixLocalparts: ["user"], + config: { + jira: { + webhook: { + secret: randomUUID(), + }, + }, + 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); - beforeAll(async () => { - testEnv = await E2ETestEnv.createTestEnv({ - matrixLocalparts: ['user'], - config: { - jira: { - webhook: { - secret: randomUUID(), - }, - }, - 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); + afterAll(() => { + return testEnv?.tearDown(); + }); - afterAll(() => { - return testEnv?.tearDown(); + test("should be able to handle a JIRA 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); + const jiraURL = JIRA_PAYLOAD.issue.fields.project.self; + // Pre-grant connection to allow us to bypass the oauth dance. + await user.waitForRoomJoin({ sender: testEnv.botMxid, roomId: testRoomId }); + const granter = new JiraGrantChecker(testEnv.app.appservice, null as any); + await granter.grantConnection(testRoomId, { + url: jiraURL, }); - test('should be able to handle a JIRA 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); - const jiraURL = JIRA_PAYLOAD.issue.fields.project.self; - // Pre-grant connection to allow us to bypass the oauth dance. - await user.waitForRoomJoin({sender: testEnv.botMxid, roomId: testRoomId }); - const granter = new JiraGrantChecker(testEnv.app.appservice, null as any); - await granter.grantConnection(testRoomId, { - url: jiraURL, - }); - - // "Create" a JIRA connection. - await testEnv.app.appservice.botClient.sendStateEvent(testRoomId, JiraProjectConnection.CanonicalEventType, jiraURL, { - url: jiraURL, - } satisfies JiraProjectConnectionState); + // "Create" a JIRA connection. + await testEnv.app.appservice.botClient.sendStateEvent( + testRoomId, + JiraProjectConnection.CanonicalEventType, + jiraURL, + { + url: jiraURL, + } satisfies JiraProjectConnectionState, + ); - await waitFor(async () => (await bridgeApi.getConnectionsForRoom(testRoomId)).length === 1); + await waitFor( + async () => + (await bridgeApi.getConnectionsForRoom(testRoomId)).length === 1, + ); - const webhookNotice = user.waitForRoomEvent({ - eventType: 'm.room.message', sender: testEnv.botMxid, roomId: testRoomId - }); + const webhookNotice = user.waitForRoomEvent({ + eventType: "m.room.message", + sender: testEnv.botMxid, + roomId: testRoomId, + }); - const webhookPayload = JSON.stringify(JIRA_PAYLOAD); + const webhookPayload = JSON.stringify(JIRA_PAYLOAD); - const hmac = createHmac('sha256', testEnv.opts.config?.jira?.webhook.secret!); - hmac.write(webhookPayload); - hmac.end(); + const hmac = createHmac( + "sha256", + testEnv.opts.config?.jira?.webhook.secret!, + ); + hmac.write(webhookPayload); + hmac.end(); - // Send a webhook - const req = await fetch(`http://localhost:${webhooksPort}/`, { - method: 'POST', - headers: { - 'X-Hub-Signature': `sha256=${hmac.read().toString('hex')}`, - 'x-atlassian-webhook-identifier': 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('Test User created a new JIRA issue [TP-8](https://example.org/browse/TP-8): "Test issue"'); + // Send a webhook + const req = await fetch(`http://localhost:${webhooksPort}/`, { + method: "POST", + headers: { + "X-Hub-Signature": `sha256=${hmac.read().toString("hex")}`, + "x-atlassian-webhook-identifier": 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( + 'Test User created a new JIRA issue [TP-8](https://example.org/browse/TP-8): "Test issue"', + ); + }); }); diff --git a/spec/openproject.spec.ts b/spec/openproject.spec.ts index 95d6615c9..f8b12fe05 100644 --- a/spec/openproject.spec.ts +++ b/spec/openproject.spec.ts @@ -320,7 +320,7 @@ describe("OpenProject", () => { webhook: { secret: randomUUID(), }, - baseUrl: "http://mytestproject.com/" + baseUrl: "http://mytestproject.com/", }, widgets: { publicUrl: `http://localhost:${webhooksPort}`, @@ -396,7 +396,7 @@ describe("OpenProject", () => { method: "POST", headers: { "X-Op-Signature": `sha1=${hmac.read().toString("hex")}`, - 'Content-Type': 'application/json' + "Content-Type": "application/json", }, body: webhookPayload, }, diff --git a/spec/permissions.spec.ts b/spec/permissions.spec.ts index 3fb576192..791c4496a 100644 --- a/spec/permissions.spec.ts +++ b/spec/permissions.spec.ts @@ -2,85 +2,113 @@ import { E2ESetupTestTimeout, E2ETestEnv } from "./util/e2e-test"; import { describe, test, beforeAll, afterAll, expect } from "vitest"; import { MessageEventContent } from "matrix-bot-sdk"; -describe('Permissions test', () => { - let testEnv!: E2ETestEnv<'denied_user'|'allowed_user'>; +describe("Permissions test", () => { + let testEnv!: E2ETestEnv<"denied_user" | "allowed_user">; - beforeAll(async () => { - testEnv = await E2ETestEnv.createTestEnv({ - matrixLocalparts: ['denied_user', 'allowed_user'], - permissionsRoom: { - members: ['allowed_user'], - permissions: [{ - level: "manageConnections", - service: "webhooks" - }] + beforeAll(async () => { + testEnv = await E2ETestEnv.createTestEnv({ + matrixLocalparts: ["denied_user", "allowed_user"], + permissionsRoom: { + members: ["allowed_user"], + permissions: [ + { + level: "manageConnections", + service: "webhooks", + }, + ], + }, + e2eClientOpts: { + autoAcceptInvite: true, + }, + config: { + gitlab: { + instances: { + test: { + url: "https://example.org/foo/bar", }, - e2eClientOpts: { - autoAcceptInvite: true, - }, - config: { - gitlab: { - instances: {"test": { - url: "https://example.org/foo/bar" - }}, - webhook: { - secret: "foo!" - } - }, - generic: { - enabled: true, - urlPrefix: `http://localhost` - }, - } - }); - await testEnv.setUp(); - }, E2ESetupTestTimeout); - - afterAll(() => { - return testEnv?.tearDown(); + }, + webhook: { + secret: "foo!", + }, + }, + generic: { + enabled: true, + urlPrefix: `http://localhost`, + }, + }, }); + await testEnv.setUp(); + }, E2ESetupTestTimeout); - test('should only allow users in the permissions room', async () => { - const deniedUser = testEnv.getUser('denied_user'); - const allowedUser = testEnv.getUser('allowed_user'); + afterAll(() => { + return testEnv?.tearDown(); + }); - // Invite allowed user to permissions room - const roomId = await deniedUser.createRoom({ name: 'Test room', invite: [await allowedUser.getUserId()]}); + test("should only allow users in the permissions room", async () => { + const deniedUser = testEnv.getUser("denied_user"); + const allowedUser = testEnv.getUser("allowed_user"); - await deniedUser.inviteUser(testEnv.botMxid, roomId); - // User is not in the permissions room - const { data } = await deniedUser.waitForRoomLeave({sender: testEnv.botMxid, roomId }); - // XXX: Missing type - expect((data.content as any)["reason"]).to.equal("You do not have permission to invite this bot."); + // Invite allowed user to permissions room + const roomId = await deniedUser.createRoom({ + name: "Test room", + invite: [await allowedUser.getUserId()], + }); - await allowedUser.inviteUser(testEnv.botMxid, roomId); - await deniedUser.waitForRoomJoin({sender: testEnv.botMxid, roomId }); + await deniedUser.inviteUser(testEnv.botMxid, roomId); + // User is not in the permissions room + const { data } = await deniedUser.waitForRoomLeave({ + sender: testEnv.botMxid, + roomId, }); + // XXX: Missing type + expect((data.content as any)["reason"]).to.equal( + "You do not have permission to invite this bot.", + ); - test('should disallow users without permission to use a service', async () => { - const user = testEnv.getUser('allowed_user'); - const roomId = await user.createRoom({ name: 'Test room', invite: [testEnv.botMxid]}); - await user.waitForRoomJoin({sender: testEnv.botMxid, roomId }); + await allowedUser.inviteUser(testEnv.botMxid, roomId); + await deniedUser.waitForRoomJoin({ sender: testEnv.botMxid, roomId }); + }); - // Try to create a GitHub connection, should fail. - const msgGitLab = user.waitForRoomEvent({ - eventType: 'm.room.message', sender: testEnv.botMxid, roomId - }); - await user.sendText(roomId, "!hookshot gitlab project https://github.com/my/project"); - expect((await msgGitLab).data.content.body).to.include('Failed to handle command: You are not permitted to provision connections for gitlab.'); + test("should disallow users without permission to use a service", async () => { + const user = testEnv.getUser("allowed_user"); + const roomId = await user.createRoom({ + name: "Test room", + invite: [testEnv.botMxid], }); + await user.waitForRoomJoin({ sender: testEnv.botMxid, roomId }); + // Try to create a GitHub connection, should fail. + const msgGitLab = user.waitForRoomEvent({ + eventType: "m.room.message", + sender: testEnv.botMxid, + roomId, + }); + await user.sendText( + roomId, + "!hookshot gitlab project https://github.com/my/project", + ); + expect((await msgGitLab).data.content.body).to.include( + "Failed to handle command: You are not permitted to provision connections for gitlab.", + ); + }); + + test("should allow users with permission to use a service", async () => { + const user = testEnv.getUser("allowed_user"); + const roomId = await user.createRoom({ + name: "Test room", + invite: [testEnv.botMxid], + }); + await user.setUserPowerLevel(testEnv.botMxid, roomId, 50); + await user.waitForRoomJoin({ sender: testEnv.botMxid, roomId }); - test('should allow users with permission to use a service', async () => { - const user = testEnv.getUser('allowed_user'); - const roomId = await user.createRoom({ name: 'Test room', invite: [testEnv.botMxid]}); - await user.setUserPowerLevel(testEnv.botMxid, roomId, 50); - await user.waitForRoomJoin({sender: testEnv.botMxid, roomId }); - - const msgWebhooks = user.waitForRoomEvent({ - eventType: 'm.room.message', sender: testEnv.botMxid, roomId - }); - await user.sendText(roomId, "!hookshot webhook test"); - expect((await msgWebhooks).data.content.body).to.include('Room configured to bridge webhooks. See admin room for secret url.'); + const msgWebhooks = user.waitForRoomEvent({ + eventType: "m.room.message", + sender: testEnv.botMxid, + roomId, }); + await user.sendText(roomId, "!hookshot webhook test"); + expect((await msgWebhooks).data.content.body).to.include( + "Room configured to bridge webhooks. See admin room for secret url.", + ); + }); }); diff --git a/spec/util/bridge-api.ts b/spec/util/bridge-api.ts index a9e7a70fe..0230b3d66 100644 --- a/spec/util/bridge-api.ts +++ b/spec/util/bridge-api.ts @@ -3,12 +3,18 @@ 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); -} \ No newline at end of file + return BridgeAPI.getBridgeAPI( + publicUrl, + { + requestOpenIDConnectToken: () => { + return user.getOpenIDConnectToken(); + }, + } as unknown as WidgetApi, + { + getItem() { + return null; + }, + setItem() {}, + } as unknown as Storage, + ); +} diff --git a/spec/util/containers.ts b/spec/util/containers.ts index d1995f388..807b826a4 100644 --- a/spec/util/containers.ts +++ b/spec/util/containers.ts @@ -1,249 +1,251 @@ import { type IAppserviceRegistration } from "matrix-bot-sdk"; import { - GenericContainer, - Wait, - AbstractStartedContainer, - type StartedTestContainer, - TestContainers, - Network, - StartedNetwork, + GenericContainer, + Wait, + AbstractStartedContainer, + type StartedTestContainer, + TestContainers, + Network, + StartedNetwork, } from "testcontainers"; import { RedisContainer, StartedRedisContainer } from "@testcontainers/redis"; import YAML from "yaml"; -import { randomUUID } from "node:crypto"; +import { randomUUID } from "node:crypto"; -const DEFAULT_SYNAPSE_IMAGE = process.env.SYNAPSE_IMAGE || "ghcr.io/element-hq/synapse:latest"; +const DEFAULT_SYNAPSE_IMAGE = + process.env.SYNAPSE_IMAGE || "ghcr.io/element-hq/synapse:latest"; -const DEFAULT_SIGNING_KEY = "ed25519 a_DTli HDSh+iM94MpMlvoebjuY3hqmHi/CU7j8kANUsq1gjws"; +const DEFAULT_SIGNING_KEY = + "ed25519 a_DTli HDSh+iM94MpMlvoebjuY3hqmHi/CU7j8kANUsq1gjws"; export interface TestContainerNetwork { - network: StartedNetwork; - synapse: StartedSynapseContainer; - redis: StartedRedisContainer; - registration: IAppserviceRegistration; + network: StartedNetwork; + synapse: StartedSynapseContainer; + redis: StartedRedisContainer; + registration: IAppserviceRegistration; } // Sets up a Synapse homeserver in a test container export class SynapseContainer extends GenericContainer { - // List of appservice registration files copied to the container - private appserviceFiles: Set = new Set(); - - // List of federation CA files copied to the container - private federationCaFiles: Set = new Set(); - - // TLS certificate and key files - private tls: { certPath: string; keyPath: string } | null = null; - - // Signing key to use for the server - public readonly signingKey: string; - - // Registration secret to use for the server - public readonly registrationSecret: string; - - constructor( - public readonly serverName: string, - opts: { - signingKey?: string; - registrationSecret?: string; - image?: string; - crypto?: Crypto; - } = {}, - ) { - super(opts.image ?? DEFAULT_SYNAPSE_IMAGE); - this.withNetworkAliases(serverName) - .withExposedPorts(8008) - .withTmpFs({ "/media_store": "rw,noexec,nosuid,size=65536k" }) - .withWaitStrategy(Wait.forHttp("/_matrix/client/versions", 8008)) - .withEnvironment({ SERVER_NAME: serverName }); - - this.signingKey = opts.signingKey ?? DEFAULT_SIGNING_KEY; - this.registrationSecret = randomUUID(); + // List of appservice registration files copied to the container + private appserviceFiles: Set = new Set(); + + // List of federation CA files copied to the container + private federationCaFiles: Set = new Set(); + + // TLS certificate and key files + private tls: { certPath: string; keyPath: string } | null = null; + + // Signing key to use for the server + public readonly signingKey: string; + + // Registration secret to use for the server + public readonly registrationSecret: string; + + constructor( + public readonly serverName: string, + opts: { + signingKey?: string; + registrationSecret?: string; + image?: string; + crypto?: Crypto; + } = {}, + ) { + super(opts.image ?? DEFAULT_SYNAPSE_IMAGE); + this.withNetworkAliases(serverName) + .withExposedPorts(8008) + .withTmpFs({ "/media_store": "rw,noexec,nosuid,size=65536k" }) + .withWaitStrategy(Wait.forHttp("/_matrix/client/versions", 8008)) + .withEnvironment({ SERVER_NAME: serverName }); + + this.signingKey = opts.signingKey ?? DEFAULT_SIGNING_KEY; + this.registrationSecret = randomUUID(); + } + + // Add a custom appservice registration to the container + public withAppServiceRegistration( + registration: IAppserviceRegistration, + ): SynapseContainer { + const target = `/__conf/appservices/${randomUUID()}.yaml`; + const content = YAML.stringify(registration); + this.withCopyContentToContainer([{ content, target }]); + this.appserviceFiles.add(target); + + return this; + } + + private generateConfig(): any { + const listeners = [ + { + port: 8008, + bind_addresses: ["::"], + type: "http", + tls: false, + x_forwarded: false, + resources: [{ names: ["client", "federation"] }], + }, + ]; + + if (this.tls) { + listeners.push({ + port: 8448, + bind_addresses: ["::"], + type: "http", + tls: true, + x_forwarded: false, + resources: [{ names: ["client", "federation"] }], + }); } - // Add a custom appservice registration to the container - public withAppServiceRegistration( - registration: IAppserviceRegistration, - ): SynapseContainer { - const target = `/__conf/appservices/${randomUUID()}.yaml`; - const content = YAML.stringify(registration); - this.withCopyContentToContainer([{ content, target }]); - this.appserviceFiles.add(target); - - return this; - } + const rc = { per_second: 9999, burst_count: 9999 }; + + const config: any = { + server_name: this.serverName, + signing_key: this.signingKey, + listeners, + report_stats: false, + trusted_key_servers: [], + enable_registration: false, + bcrypt_rounds: 4, + registration_shared_secret: this.registrationSecret, + app_service_config_files: Array.from(this.appserviceFiles), + + // Disable the media repo, as it requires mounting a volume + enable_media_repo: true, + + // unblacklist RFC1918 addresses + federation_ip_range_blacklist: [], + + // Use an in-memory SQLite database + database: { + name: "sqlite3", + args: { database: ":memory:" }, + }, + + // Set generous rate limits + rc_federation: { + window_size: 1000, + sleep_limit: 10, + sleep_delay: 500, + reject_limit: 99999, + concurrent: 3, + }, + rc_message: rc, + rc_registration: rc, + rc_login: { + address: rc, + account: rc, + failed_attempts: rc, + }, + rc_admin_redaction: rc, + rc_joins: { + local: rc, + remote: rc, + }, + rc_joins_per_room: rc, + rc_3pid_validation: rc, + rc_invites: { + per_room: rc, + per_user: rc, + }, + federation_rr_transactions_per_room_per_second: 9999, + experimental_features: { + msc2409_to_device_messages_enabled: true, + msc3202_device_masquerading: true, + msc3202_transaction_extensions: true, + msc3983_appservice_otk_claims: true, + msc3984_appservice_key_query: true, + }, + }; - private generateConfig(): any { - const listeners = [ - { - port: 8008, - bind_addresses: ["::"], - type: "http", - tls: false, - x_forwarded: false, - resources: [{ names: ["client", "federation"] }], - }, - ]; - - if (this.tls) { - listeners.push({ - port: 8448, - bind_addresses: ["::"], - type: "http", - tls: true, - x_forwarded: false, - resources: [{ names: ["client", "federation"] }], - }); - } - - const rc = { per_second: 9999, burst_count: 9999 }; - - const config: any = { - server_name: this.serverName, - signing_key: this.signingKey, - listeners, - report_stats: false, - trusted_key_servers: [], - enable_registration: false, - bcrypt_rounds: 4, - registration_shared_secret: this.registrationSecret, - app_service_config_files: Array.from(this.appserviceFiles), - - // Disable the media repo, as it requires mounting a volume - enable_media_repo: true, - - // unblacklist RFC1918 addresses - federation_ip_range_blacklist: [], - - // Use an in-memory SQLite database - database: { - name: "sqlite3", - args: { database: ":memory:" }, - }, - - // Set generous rate limits - rc_federation: { - window_size: 1000, - sleep_limit: 10, - sleep_delay: 500, - reject_limit: 99999, - concurrent: 3, - }, - rc_message: rc, - rc_registration: rc, - rc_login: { - address: rc, - account: rc, - failed_attempts: rc, - }, - rc_admin_redaction: rc, - rc_joins: { - local: rc, - remote: rc, - }, - rc_joins_per_room: rc, - rc_3pid_validation: rc, - rc_invites: { - per_room: rc, - per_user: rc, - }, - federation_rr_transactions_per_room_per_second: 9999, - experimental_features: { - msc2409_to_device_messages_enabled: true, - msc3202_device_masquerading: true, - msc3202_transaction_extensions: true, - msc3983_appservice_otk_claims: true, - msc3984_appservice_key_query: true, - } - }; - - if (this.federationCaFiles.size > 0) { - config["federation_custom_ca_list"] = Array.from( - this.federationCaFiles, - ); - } - - if (this.tls) { - config["tls_certificate_path"] = this.tls.certPath; - config["tls_private_key_path"] = this.tls.keyPath; - } - - return config; + if (this.federationCaFiles.size > 0) { + config["federation_custom_ca_list"] = Array.from(this.federationCaFiles); } - public override async beforeContainerCreated(): Promise { - // Just before the container is created, generate the config file - // and set the environment variable to point to it - const config = this.generateConfig(); - const target = "/__conf/config.yaml"; - const content = YAML.stringify(config); - this.withCopyContentToContainer([{ content, target }]); - this.withEnvironment({ SYNAPSE_CONFIG_PATH: target }); + if (this.tls) { + config["tls_certificate_path"] = this.tls.certPath; + config["tls_private_key_path"] = this.tls.keyPath; } - public override async start(): Promise { - return new StartedSynapseContainer( - this.serverName, - this.registrationSecret, - this.signingKey, - await super.start(), - ); - } + return config; + } + + public override async beforeContainerCreated(): Promise { + // Just before the container is created, generate the config file + // and set the environment variable to point to it + const config = this.generateConfig(); + const target = "/__conf/config.yaml"; + const content = YAML.stringify(config); + this.withCopyContentToContainer([{ content, target }]); + this.withEnvironment({ SYNAPSE_CONFIG_PATH: target }); + } + + public override async start(): Promise { + return new StartedSynapseContainer( + this.serverName, + this.registrationSecret, + this.signingKey, + await super.start(), + ); + } } export class StartedSynapseContainer extends AbstractStartedContainer { - constructor( - public readonly serverName: string, - public readonly registrationSecret: string, - public readonly sigingKey: string, - startedTestContainer: StartedTestContainer, - ) { - super(startedTestContainer); - } - - public get baseUrl(): string { - const port = this.getMappedPort(8008); - const host = this.getHost(); - return `http://${host}:${port}/`; - } + constructor( + public readonly serverName: string, + public readonly registrationSecret: string, + public readonly sigingKey: string, + startedTestContainer: StartedTestContainer, + ) { + super(startedTestContainer); + } + + public get baseUrl(): string { + const port = this.getMappedPort(8008); + const host = this.getHost(); + return `http://${host}:${port}/`; + } } -export async function createContainers(name: string, hookshotPort: number): Promise { - // Before doing anything, make sure we have the port forwarder running - // Even though the port list to expose is empty, this has the side effect of starting the port forwarder - await TestContainers.exposeHostPorts(); - - // Start a docker network which will hold all the containers - const network = await new Network().start(); - - const registration = { - id: "hookshot", - hs_token: "hs_hs_token", - as_token: "hs_as_token", - url: `http://host.testcontainers.internal:${hookshotPort}`, - sender_localpart: "hookshot", - rate_limited: false, - namespaces: { - rooms: [], - users: [{ regex: "@hookshot_.*:hookshot", exclusive: false }], - aliases: [], - }, - 'de.sorunome.msc2409.push_ephemeral': true, - 'push_ephemeral': true, - 'org.matrix.msc3202': true, - }; - - const container = await new SynapseContainer(name, { crypto }) - .withNetwork(network) - .withAppServiceRegistration(registration) - .start(); - - const redis = await new RedisContainer().withNetwork(network).start(); - - - return { - registration, - network, - synapse: container, - redis - }; +export async function createContainers( + name: string, + hookshotPort: number, +): Promise { + // Before doing anything, make sure we have the port forwarder running + // Even though the port list to expose is empty, this has the side effect of starting the port forwarder + await TestContainers.exposeHostPorts(); + + // Start a docker network which will hold all the containers + const network = await new Network().start(); + + const registration = { + id: "hookshot", + hs_token: "hs_hs_token", + as_token: "hs_as_token", + url: `http://host.testcontainers.internal:${hookshotPort}`, + sender_localpart: "hookshot", + rate_limited: false, + namespaces: { + rooms: [], + users: [{ regex: "@hookshot_.*:hookshot", exclusive: false }], + aliases: [], + }, + "de.sorunome.msc2409.push_ephemeral": true, + push_ephemeral: true, + "org.matrix.msc3202": true, + }; + + const container = await new SynapseContainer(name, { crypto }) + .withNetwork(network) + .withAppServiceRegistration(registration) + .start(); + + const redis = await new RedisContainer().withNetwork(network).start(); + + return { + registration, + network, + synapse: container, + redis, + }; } diff --git a/spec/util/e2e-test.ts b/spec/util/e2e-test.ts index b8beb8368..941c46b71 100644 --- a/spec/util/e2e-test.ts +++ b/spec/util/e2e-test.ts @@ -1,392 +1,520 @@ import { TestHomeServer, createHS, destroyHS } from "./homerunner"; -import { Appservice, IAppserviceRegistration, MatrixClient, Membership, MembershipEventContent, PowerLevelsEventContent } from "matrix-bot-sdk"; +import { + Appservice, + IAppserviceRegistration, + MatrixClient, + Membership, + 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"; import Redis from "ioredis"; -import { BridgeConfigActorPermission, BridgeConfigServicePermission } from "../../src/libRs"; +import { + BridgeConfigActorPermission, + BridgeConfigServicePermission, +} from "../../src/libRs"; import { TestContainers } from "testcontainers"; const WAIT_EVENT_TIMEOUT = 20000; export const E2ESetupTestTimeout = 60000; -const REDIS_DATABASE_URI = process.env.HOOKSHOT_E2E_REDIS_DB_URI ?? "redis://localhost:6379"; +const REDIS_DATABASE_URI = + process.env.HOOKSHOT_E2E_REDIS_DB_URI ?? "redis://localhost:6379"; interface Opts { - matrixLocalparts?: ML[]; - permissionsRoom?: { - members: string[], - permissions: Array, - }; - config?: Partial, - enableE2EE?: boolean, - useRedis?: boolean, - e2eClientOpts?: E2ETestMatrixClientOpts, + matrixLocalparts?: ML[]; + permissionsRoom?: { + members: string[]; + permissions: Array; + }; + config?: Partial; + enableE2EE?: boolean; + useRedis?: boolean; + e2eClientOpts?: E2ETestMatrixClientOpts; } interface WaitForEventResponse> { - roomId: string, - data: { - sender: string, type: string, state_key?: string, content: T, event_id: string, - } + roomId: string; + data: { + sender: string; + type: string; + state_key?: string; + content: T; + event_id: string; + }; } export interface E2ETestMatrixClientOpts { - autoAcceptInvite: boolean; + autoAcceptInvite: boolean; } export class E2ETestMatrixClient extends MatrixClient { - - constructor(private e2eOpts: E2ETestMatrixClientOpts, ...args: ConstructorParameters) { - super(...args); - if (e2eOpts.autoAcceptInvite) { - this.on('room.invite', (eventRoomId: string) => { - this.joinRoom(eventRoomId); - }); - } - } - - - public async waitForPowerLevel( - roomId: string, expected: Partial, - ): 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, 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}`) - } - - private async innerWaitForRoomEvent>( - {eventType, sender, roomId, stateKey, eventId, body}: {eventType: string, sender: string, roomId?: string, stateKey?: string, body?: string, eventId?: string}, expectEncrypted: boolean, - ): Promise> { - return this.waitForEvent(expectEncrypted ? 'room.decrypted_event' : 'room.event', (eventRoomId: string, eventData: { - sender: string, type: string, state_key?: string, content: T, event_id: string, - }) => { - if (eventData.sender !== sender) { - return undefined; - } - if (eventData.type !== eventType) { - return undefined; - } - if (roomId && eventRoomId !== roomId) { - return undefined; - } - if (eventId && eventData.event_id !== eventId) { - return undefined; - } - if (stateKey !== undefined && eventData.state_key !== stateKey) { - return undefined; - } - const evtBody = 'body' in eventData.content && eventData.content.body; - if (body && body !== evtBody) { - return undefined; - } - console.info( - // eslint-disable-next-line max-len - `${eventRoomId} ${eventData.event_id} ${eventData.type} ${eventData.sender} ${eventData.state_key ?? evtBody ?? ''}` - ); - return {roomId: eventRoomId, data: eventData}; - }, `Timed out waiting for ${eventType} from ${sender} in ${roomId || "any room"}`) - } - - public async waitForRoomEvent>( - opts: Parameters[0] - ): Promise> { - return this.innerWaitForRoomEvent(opts, false); - } - - public async waitForEncryptedEvent>( - opts: Parameters[0] - ): Promise> { - return this.innerWaitForRoomEvent(opts, true); - } - - public async waitForRoomMembership( - {sender, roomId, membership}: {membership: Membership, sender: string, roomId?: string} - ): Promise<{roomId: string, data: { - sender: string, - state_key: string, - content: MembershipEventContent, - }}> { - 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 !== membership) { - return; - } - return {roomId: eventRoomId, data: eventData}; - }, `Timed out waiting for ${membership} to ${roomId || "any room"} from ${sender}`) + constructor( + private e2eOpts: E2ETestMatrixClientOpts, + ...args: ConstructorParameters + ) { + super(...args); + if (e2eOpts.autoAcceptInvite) { + this.on("room.invite", (eventRoomId: string) => { + this.joinRoom(eventRoomId); + }); } + } - public async waitForRoomJoin( - opts: {sender: string, roomId?: string} - ): ReturnType { - return this.waitForRoomMembership({...opts, membership: "join"}); - } - - public async waitForRoomLeave( - opts: {sender: string, roomId?: string} - ): ReturnType { - return this.waitForRoomMembership({...opts, membership: "leave"}); - } - - 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( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - emitterType: string, filterFn: (...args: any[]) => T|undefined, timeoutMsg: string) - : Promise { - 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 (process as any).__tinypool_state__.workerId; - } - - static async createTestEnv(opts: Opts): Promise> { - const workerID = this.workerId; - const { matrixLocalparts, config: providedConfig } = opts; - const keyPromise = new Promise((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) } - })); - - const dir = await mkdtemp('hookshot-int-test'); - const clientOpts = opts.e2eClientOpts ?? { - autoAcceptInvite: false, - }; - // Configure homeserver and bots - const [homeserver, privateKey] = await Promise.all([ - createHS([...matrixLocalparts || []], clientOpts, workerID, opts.enableE2EE ? path.join(dir, 'client-crypto') : undefined), - 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, - } + public async waitForPowerLevel( + roomId: string, + expected: Partial, + ): 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; + event_id: string; + state_key: string; + }, + ) => { + if (eventRoomId !== roomId) { + return undefined; } - if (providedConfig?.github) { - providedConfig.github.auth.privateKeyFile = keyPath; + if (eventData.type !== "m.room.power_levels") { + return undefined; } - opts.useRedis = opts.enableE2EE || opts.useRedis; - - let cacheConfig: BridgeConfigRoot["cache"]|undefined; - if (opts.useRedis) { - cacheConfig = { - redisUri: `${homeserver.containers.redis.getConnectionUrl()}/${workerID}`, - } + if (eventData.state_key !== "") { + return undefined; } - const registration: IAppserviceRegistration = { - as_token: homeserver.asToken, - hs_token: homeserver.hsToken, - sender_localpart: 'hookshot', - namespaces: { - users: [{ - regex: `@hookshot:${homeserver.domain}`, - exclusive: true, - }], - rooms: [], - aliases: [], - }, - "de.sorunome.msc2409.push_ephemeral": true - }; - - let permissions: BridgeConfigActorPermission[] = []; - if (opts.permissionsRoom) { - const botClient = new MatrixClient(homeserver.url, homeserver.asToken); - const permsRoom = await botClient.createRoom({name: "Permissions room", invite: opts.permissionsRoom.members.map(localpart => `@${localpart}:${homeserver.domain}`)}); - permissions.push({ actor: permsRoom, services: opts.permissionsRoom.permissions}); - } else { - permissions = [{ - actor: "*", - services: [{level: "manageConnections"}] - }]; + // 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; + } } - const config = new BridgeConfig({ - bridge: { - domain: homeserver.domain, - url: homeserver.url, - port: homeserver.appPort, - bindAddress: '0.0.0.0', - }, - logging: { - level: 'debug', - }, - // 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, - ...(opts.enableE2EE ? { - encryption: { - storagePath: path.join(dir, 'crypto-store'), - } - } : undefined), - cache: cacheConfig, - permissions, - ...providedConfig, - }); - const app = await start(config, registration); - app.listener.finaliseListeners(); - - return new E2ETestEnv(homeserver, app, opts, config, dir); - } - private constructor( - public readonly homeserver: TestHomeServer, - public app: Awaited>, - public readonly opts: Opts, - private readonly config: BridgeConfig, - private readonly dir: string, - ) { - const appService = app.appservice; - // Setup the appservice ping endpoint - appService.expressAppInstance.post( - "/_matrix/app/v1/ping", - (_req, res) => res.status(200).send({}), + 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}`, + ); + } + + private async innerWaitForRoomEvent< + T extends object = Record, + >( + { + eventType, + sender, + roomId, + stateKey, + eventId, + body, + }: { + eventType: string; + sender: string; + roomId?: string; + stateKey?: string; + body?: string; + eventId?: string; + }, + expectEncrypted: boolean, + ): Promise> { + return this.waitForEvent( + expectEncrypted ? "room.decrypted_event" : "room.event", + ( + eventRoomId: string, + eventData: { + sender: string; + type: string; + state_key?: string; + content: T; + event_id: string; + }, + ) => { + if (eventData.sender !== sender) { + return undefined; + } + if (eventData.type !== eventType) { + return undefined; + } + if (roomId && eventRoomId !== roomId) { + return undefined; + } + if (eventId && eventData.event_id !== eventId) { + return undefined; + } + if (stateKey !== undefined && eventData.state_key !== stateKey) { + return undefined; + } + const evtBody = "body" in eventData.content && eventData.content.body; + if (body && body !== evtBody) { + return undefined; + } + console.info( + // eslint-disable-next-line max-len + `${eventRoomId} ${eventData.event_id} ${eventData.type} ${eventData.sender} ${eventData.state_key ?? evtBody ?? ""}`, ); + return { roomId: eventRoomId, data: eventData }; + }, + `Timed out waiting for ${eventType} from ${sender} in ${roomId || "any room"}`, + ); + } + + public async waitForRoomEvent>( + opts: Parameters[0], + ): Promise> { + return this.innerWaitForRoomEvent(opts, false); + } + + public async waitForEncryptedEvent< + T extends object = Record, + >( + opts: Parameters[0], + ): Promise> { + return this.innerWaitForRoomEvent(opts, true); + } + + public async waitForRoomMembership({ + sender, + roomId, + membership, + }: { + membership: Membership; + sender: string; + roomId?: string; + }): Promise<{ + roomId: string; + data: { + sender: string; + state_key: string; + content: MembershipEventContent; + }; + }> { + 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 !== membership) { + return; + } + return { roomId: eventRoomId, data: eventData }; + }, + `Timed out waiting for ${membership} to ${roomId || "any room"} from ${sender}`, + ); + } + + public async waitForRoomJoin(opts: { + sender: string; + roomId?: string; + }): ReturnType { + return this.waitForRoomMembership({ ...opts, membership: "join" }); + } + + public async waitForRoomLeave(opts: { + sender: string; + roomId?: string; + }): ReturnType { + return this.waitForRoomMembership({ ...opts, membership: "leave" }); + } + + 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( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + emitterType: string, + filterFn: (...args: any[]) => T | undefined, + timeoutMsg: string, + ): Promise { + 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); + }); + } +} - // Patch the "begin" function to expose host ports, and ping the appservice - // The reason we don't do this unconditionally, is that if we never start the appservice, - // the HS will try to contact it, which will throw an exception on the local process if port was exposed, - // which mocha will catch and report as a test failure. - const originalBegin = appService.begin.bind(appService); - appService.begin = async () => { - await originalBegin(); - - // It looks like having the port forwarder setup before - // we actually start the appservice sometimes causes issues - await TestContainers.exposeHostPorts(config.bridge.port); - - // Ask the HS to ping the appservice. - // TODO: Because of crypto reasons, the appservice bot client might not be a "true" appservice session - // but instead a crypto session. For this reason we need to do a raw request. - new MatrixClient(homeserver.url, homeserver.asToken).doRequest( - "POST", - `/_matrix/client/v1/appservice/hookshot/ping`, - null, - {}, - ); - }; +export class E2ETestEnv { + static get workerId() { + return (process as any).__tinypool_state__.workerId; + } + + static async createTestEnv( + opts: Opts, + ): Promise> { + const workerID = this.workerId; + const { matrixLocalparts, config: providedConfig } = opts; + const keyPromise = new Promise((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); + } + }, + ), + ); + + const dir = await mkdtemp("hookshot-int-test"); + const clientOpts = opts.e2eClientOpts ?? { + autoAcceptInvite: false, + }; + // Configure homeserver and bots + const [homeserver, privateKey] = await Promise.all([ + createHS( + [...(matrixLocalparts || [])], + clientOpts, + workerID, + opts.enableE2EE ? path.join(dir, "client-crypto") : undefined, + ), + 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, + }; } - public get botMxid() { - return `@hookshot:${this.homeserver.domain}`; + if (providedConfig?.github) { + providedConfig.github.auth.privateKeyFile = keyPath; } - public async setUp(): Promise { - await this.app.bridgeApp.start(); - } + opts.useRedis = opts.enableE2EE || opts.useRedis; - public async tearDown(): Promise { - await destroyHS(this.homeserver); - await this.app.bridgeApp.stop(); - await this.app.listener.stop(); - await this.app.storage.disconnect?.(); - this.homeserver.users.forEach(u => u.client.stop()); - await rm(this.dir, { recursive: true }); + let cacheConfig: BridgeConfigRoot["cache"] | undefined; + if (opts.useRedis) { + cacheConfig = { + redisUri: `${homeserver.containers.redis.getConnectionUrl()}/${workerID}`, + }; } - public getUser(localpart: ML) { - const u = this.homeserver.users.find(u => u.userId === `@${localpart}:${this.homeserver.domain}`); - if (!u) { - throw Error("User missing from test"); - } - return u.client; + const registration: IAppserviceRegistration = { + as_token: homeserver.asToken, + hs_token: homeserver.hsToken, + sender_localpart: "hookshot", + namespaces: { + users: [ + { + regex: `@hookshot:${homeserver.domain}`, + exclusive: true, + }, + ], + rooms: [], + aliases: [], + }, + "de.sorunome.msc2409.push_ephemeral": true, + }; + + let permissions: BridgeConfigActorPermission[] = []; + if (opts.permissionsRoom) { + const botClient = new MatrixClient(homeserver.url, homeserver.asToken); + const permsRoom = await botClient.createRoom({ + name: "Permissions room", + invite: opts.permissionsRoom.members.map( + (localpart) => `@${localpart}:${homeserver.domain}`, + ), + }); + permissions.push({ + actor: permsRoom, + services: opts.permissionsRoom.permissions, + }); + } else { + permissions = [ + { + actor: "*", + services: [{ level: "manageConnections" }], + }, + ]; + } + const config = new BridgeConfig({ + bridge: { + domain: homeserver.domain, + url: homeserver.url, + port: homeserver.appPort, + bindAddress: "0.0.0.0", + }, + logging: { + level: "debug", + }, + // 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, + ...(opts.enableE2EE + ? { + encryption: { + storagePath: path.join(dir, "crypto-store"), + }, + } + : undefined), + cache: cacheConfig, + permissions, + ...providedConfig, + }); + const app = await start(config, registration); + app.listener.finaliseListeners(); + + return new E2ETestEnv(homeserver, app, opts, config, dir); + } + + private constructor( + public readonly homeserver: TestHomeServer, + public app: Awaited>, + public readonly opts: Opts, + private readonly config: BridgeConfig, + private readonly dir: string, + ) { + const appService = app.appservice; + // Setup the appservice ping endpoint + appService.expressAppInstance.post("/_matrix/app/v1/ping", (_req, res) => + res.status(200).send({}), + ); + + // Patch the "begin" function to expose host ports, and ping the appservice + // The reason we don't do this unconditionally, is that if we never start the appservice, + // the HS will try to contact it, which will throw an exception on the local process if port was exposed, + // which mocha will catch and report as a test failure. + const originalBegin = appService.begin.bind(appService); + appService.begin = async () => { + await originalBegin(); + + // It looks like having the port forwarder setup before + // we actually start the appservice sometimes causes issues + await TestContainers.exposeHostPorts(config.bridge.port); + + // Ask the HS to ping the appservice. + // TODO: Because of crypto reasons, the appservice bot client might not be a "true" appservice session + // but instead a crypto session. For this reason we need to do a raw request. + new MatrixClient(homeserver.url, homeserver.asToken).doRequest( + "POST", + `/_matrix/client/v1/appservice/hookshot/ping`, + null, + {}, + ); + }; + } + + public get botMxid() { + return `@hookshot:${this.homeserver.domain}`; + } + + public async setUp(): Promise { + await this.app.bridgeApp.start(); + } + + public async tearDown(): Promise { + await destroyHS(this.homeserver); + await this.app.bridgeApp.stop(); + await this.app.listener.stop(); + await this.app.storage.disconnect?.(); + this.homeserver.users.forEach((u) => u.client.stop()); + await rm(this.dir, { recursive: true }); + } + + public getUser(localpart: ML) { + const u = this.homeserver.users.find( + (u) => u.userId === `@${localpart}:${this.homeserver.domain}`, + ); + if (!u) { + throw Error("User missing from test"); } + return u.client; + } } diff --git a/spec/util/fixtures.ts b/spec/util/fixtures.ts index ae6de768c..fd7fd6001 100644 --- a/spec/util/fixtures.ts +++ b/spec/util/fixtures.ts @@ -1 +1,4 @@ -export const TEST_FILE = Buffer.from(`PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4NCjwhLS0gR2VuZXJhdG9yOiBBZG9iZSBJbGx1c3RyYXRvciAxMy4wLjAsIFNWRyBFeHBvcnQgUGx1Zy1JbiAuIFNWRyBWZXJzaW9uOiA2LjAwIEJ1aWxkIDE0NTc2KSAgLS0+DQo8IURPQ1RZUEUgc3ZnIFBVQkxJQyAiLS8vVzNDLy9EVEQgU1ZHIDEuMS8vRU4iICJodHRwOi8vd3d3LnczLm9yZy9HcmFwaGljcy9TVkcvMS4xL0RURC9zdmcxMS5kdGQiPg0KPHN2ZyB2ZXJzaW9uPSIxLjEiIGlkPSJMYXllcl8xIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB4PSIwcHgiIHk9IjBweCINCgkgd2lkdGg9Ijc5My4zMjJweCIgaGVpZ2h0PSIzNDAuODA5cHgiIHZpZXdCb3g9IjAgMCA3OTMuMzIyIDM0MC44MDkiIGVuYWJsZS1iYWNrZ3JvdW5kPSJuZXcgMCAwIDc5My4zMjIgMzQwLjgwOSINCgkgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+DQo8cGF0aCBvcGFjaXR5PSIwLjUiIGZpbGw9IiNGRkZGRkYiIGQ9Ik0zNC4wMDQsMzQwLjgwOUgyYy0xLjEwNCwwLTItMC44OTYtMi0yVjJjMC0xLjEwNCwwLjg5Ni0yLDItMmgzMi4wMDRjMS4xMDQsMCwyLDAuODk2LDIsMg0KCXY3LjcxYzAsMS4xMDQtMC44OTYsMi0yLDJoLTIxLjEzdjMxNy4zODZoMjEuMTNjMS4xMDQsMCwyLDAuODk2LDIsMi4wMDF2Ny43MTJDMzYuMDA0LDMzOS45MTMsMzUuMTA4LDM0MC44MDksMzQuMDA0LDM0MC44MDkNCglMMzQuMDA0LDM0MC44MDl6Ii8+DQo8cGF0aCBvcGFjaXR5PSIwLjUiIGZpbGw9IiNGRkZGRkYiIGQ9Ik0xMC44NzUsOS43MTF2MzIxLjM4NmgyMy4xM3Y3LjcxMUgxLjk5OVYyLjAwMWgzMi4wMDZ2Ny43MUgxMC44NzV6Ii8+DQo8cGF0aCBvcGFjaXR5PSIwLjUiIGZpbGw9IiNGRkZGRkYiIGQ9Ik0yNTIuNDAyLDIzMy43MTFoLTMyLjk5M2MtMS4xMDQsMC0yLTAuODk2LTItMnYtNjguMDczYzAtMy45NDktMC4xNTQtNy43MjItMC40NTctMTEuMjEzDQoJYy0wLjI4OS0zLjI4Mi0xLjA3NC02LjE1My0yLjMzMi04LjUzYy0xLjIwNC0yLjI3Ni0zLjAxNy00LjExOS01LjM4NC01LjQ3NmMtMi4zOTMtMS4zNjItNS43NzUtMi4wNTYtMTAuMDQyLTIuMDU2DQoJYy00LjIzOCwwLTcuNjc0LDAuNzk4LTEwLjIxMywyLjM3MWMtMi41NjUsMS41OTYtNC42MDQsMy43MDEtNi4wNTMsNi4yNThjLTEuNDk4LDIuNjQzLTIuNTEsNS42OTQtMy4wMTMsOS4wNjcNCgljLTAuNTI2LDMuNTEzLTAuNzkzLDcuMTI1LTAuNzkzLDEwLjc0MXY2Ni45MWMwLDEuMTA0LTAuODk2LDItMiwyaC0zMi45OTFjLTEuMTA0LDAtMi0wLjg5Ni0yLTJ2LTY3LjM3Mw0KCWMwLTMuNDM1LTAuMDc4LTYuOTY0LTAuMjI4LTEwLjQ4NWMtMC4xNDgtMy4yNTEtMC43NjctNi4yNzgtMS44NDEtOC45OTVjLTEuMDE4LTIuNTcxLTIuNjY3LTQuNTg0LTUuMDQ3LTYuMTUzDQoJYy0yLjM3Mi0xLjU1Mi02LjAyOS0yLjM0MS0xMC44NjUtMi4zNDFjLTEuMzcyLDAtMy4yNjUsMC4zMjgtNS42MjksMC45NzZjLTIuMjgsMC42MjQtNC41MzYsMS44MjYtNi43MDUsMy41NzcNCgljLTIuMTUyLDEuNzMyLTQuMDM2LDQuMzA2LTUuNjA1LDcuNjU1Yy0xLjU2OSwzLjM1Ni0yLjM2Nyw3Ljg3Ny0yLjM2NywxMy40Mzh2NjkuNzAxYzAsMS4xMDQtMC44OTUsMi0yLDJINjguODU3DQoJYy0xLjEwNCwwLTItMC44OTYtMi0yVjExMS41OTRjMC0xLjEwNCwwLjg5Ni0xLjk5OSwyLTEuOTk5aDMxLjEzYzEuMTA0LDAsMiwwLjg5NiwyLDEuOTk5djExLjAwNw0KCWMzLjgzNC00LjQ5OSw4LjI0OC04LjE1MiwxMy4xNzMtMTAuODk2YzYuMzk2LTMuNTU5LDEzLjc5OS01LjM2MiwyMi4wMDItNS4zNjJjNy44NDYsMCwxNS4xMjcsMS41NDgsMjEuNjQyLDQuNjA0DQoJYzUuNzk0LDIuNzIyLDEwLjQyNCw3LjI2LDEzLjc5MSwxMy41MmMzLjQ0OS00LjM2Miw3LjgzMy04LjMwNiwxMy4wNzEtMTEuNzUyYzYuNDIyLTQuMjI4LDE0LjEwMi02LjM3MSwyMi44MjQtNi4zNzENCgljNi40OTksMCwxMi42MjUsMC44MDcsMTguMjA5LDIuMzk5YzUuNjg2LDEuNjI4LDEwLjYzNSw0LjI3MSwxNC43MTIsNy44NTdjNC4wODgsMy42MDUsNy4zMTgsOC4zNTcsOS42MDEsMTQuMTIzDQoJYzIuMjUsNS43MTksMy4zOTEsMTIuNjQ5LDMuMzkxLDIwLjYwNHY4MC4zODRDMjU0LjQwMiwyMzIuODE1LDI1My41MDcsMjMzLjcxMSwyNTIuNDAyLDIzMy43MTFMMjUyLjQwMiwyMzMuNzExeiIvPg0KPHBhdGggb3BhY2l0eT0iMC41IiBmaWxsPSIjRkZGRkZGIiBkPSJNOTkuOTg4LDExMS41OTV2MTYuMjY0aDAuNDYzYzQuMzM4LTYuMTkxLDkuNTYzLTEwLjk5OCwxNS42ODQtMTQuNDA2DQoJYzYuMTE3LTMuNDAyLDEzLjEyOS01LjExLDIxLjAyNy01LjExYzcuNTg4LDAsMTQuNTIxLDEuNDc1LDIwLjc5Myw0LjQxNWM2LjI3NCwyLjk0NSwxMS4wMzgsOC4xMzEsMTQuMjkxLDE1LjU2Nw0KCWMzLjU2LTUuMjY1LDguNC05LjkxMywxNC41MjEtMTMuOTRjNi4xMTctNC4wMjUsMTMuMzU4LTYuMDQyLDIxLjcyNC02LjA0MmM2LjM1MSwwLDEyLjIzNCwwLjc3NiwxNy42NiwyLjMyNQ0KCWM1LjQxOCwxLjU0OSwxMC4wNjUsNC4wMjcsMTMuOTM4LDcuNDM0YzMuODY5LDMuNDEsNi44ODksNy44NjMsOS4wNjIsMTMuMzU3YzIuMTY3LDUuNTA0LDMuMjUzLDEyLjEyMiwzLjI1MywxOS44Njl2ODAuMzg1SDIxOS40MQ0KCXYtNjguMDc0YzAtNC4wMjUtMC4xNTQtNy44Mi0wLjQ2NS0xMS4zODVjLTAuMzEzLTMuNTYtMS4xNjEtNi42NTYtMi41NTUtOS4yOTNjLTEuMzk1LTIuNjMxLTMuNDUtNC43MjQtNi4xNTctNi4yNzQNCgljLTIuNzExLTEuNTQzLTYuMzkxLTIuMzIyLTExLjAzNy0yLjMyMnMtOC40MDMsMC44OTYtMTEuMjY5LDIuNjcxYy0yLjg2OCwxLjc4NC01LjExMiw0LjEwOS02LjczNyw2Ljk3MQ0KCWMtMS42MjYsMi44NjktMi43MTEsNi4xMi0zLjI1Miw5Ljc2MmMtMC41NDUsMy42MzgtMC44MTQsNy4zMTgtMC44MTQsMTEuMDM1djY2LjkxaC0zMi45OTF2LTY3LjM3NWMwLTMuNTYyLTAuMDgxLTcuMDg3LTAuMjMtMTAuNTcNCgljLTAuMTU4LTMuNDg3LTAuODE0LTYuNy0xLjk3OC05LjY0NWMtMS4xNjItMi45NC0zLjA5OS01LjMwNC01LjgwOS03LjA4OGMtMi43MTEtMS43NzUtNi42OTktMi42NzEtMTEuOTY1LTIuNjcxDQoJYy0xLjU1MSwwLTMuNjAzLDAuMzQ5LTYuMTU2LDEuMDQ4Yy0yLjU1NiwwLjY5Ny01LjAzNiwyLjAxNi03LjQzNSwzLjk0OWMtMi40MDQsMS45MzgtNC40NTQsNC43MjYtNi4xNTgsOC4zNjMNCgljLTEuNzA1LDMuNjQyLTIuNTU2LDguNDAyLTIuNTU2LDE0LjI4N3Y2OS43MDFoLTMyLjk5VjExMS41OTVIOTkuOTg4eiIvPg0KPHBhdGggb3BhY2l0eT0iMC41IiBmaWxsPSIjRkZGRkZGIiBkPSJNMzA0LjkwOSwyMzYuNzMzYy01Ljg4MywwLTExLjQ2LTAuNzI5LTE2LjU3NC0yLjE2M2MtNS4xOTItMS40NjQtOS44MDYtMy43NzQtMTMuNzEzLTYuODcxDQoJYy0zLjk0NC0zLjExNy03LjA2OC03LjExMS05LjI4Mi0xMS44NzFjLTIuMjA1LTQuNzMzLTMuMzI0LTEwLjQxMi0zLjMyNC0xNi44NzZjMC03LjEzLDEuMjkzLTEzLjExNywzLjg0Ni0xNy43OTcNCgljMi41NDItNC42NzQsNS44NzctOC40NjQsOS45MTItMTEuMjYzYzMuOTctMi43NTIsOC41NTYtNC44NDIsMTMuNjMtNi4yMDljNC45MDEtMS4zMjIsOS45MzctMi4zOTQsMTQuOTYxLTMuMTg0DQoJYzQuOTg2LTAuNzc1LDkuOTQ5LTEuNDA0LDE0Ljc1NC0xLjg3MmM0LjY3OS0wLjQ1Miw4Ljg4LTEuMTM5LDEyLjQ4OS0yLjAzOWMzLjQxMi0wLjg1NCw2LjExOC0yLjA5LDguMDQyLTMuNjcyDQoJYzEuNjY2LTEuMzcsMi40MTYtMy4zODQsMi4yOTItNi4xNTFjLTAuMDAyLTMuMjg5LTAuNTAyLTUuODE2LTEuNDkyLTcuNTk1Yy0wLjk5OC0xLjc5OC0yLjI4My0zLjE1LTMuOTI3LTQuMTM4DQoJYy0xLjcwMy0xLjAyLTMuNzI1LTEuNzEzLTYuMDEyLTIuMDYyYy0yLjQ3LTAuMzctNS4xNDYtMC41NTctNy45NDctMC41NTdjLTYuMDM0LDAtMTAuNzg5LDEuMjcxLTE0LjEzNSwzLjc4Mw0KCWMtMy4yMzMsMi40MjQtNS4xNTUsNi42NC01LjcxNCwxMi41MjdjLTAuMDk4LDEuMDI2LTAuOTYxLDEuODEyLTEuOTkyLDEuODEyaC0zMi45OTJjLTAuNTUyLDAtMS4wNzktMC4yMjktMS40NTctMC42MjkNCgljLTAuMzc2LTAuNDAyLTAuNTcyLTAuOTQxLTAuNTQtMS40OTFjMC40ODUtOC4wNzMsMi41NS0xNC44OTQsNi4xNDItMjAuMjcyYzMuNTQ4LTUuMzMxLDguMTQ3LTkuNjgyLDEzLjY2MS0xMi45MzENCgljNS40MjQtMy4xOTEsMTEuNjEyLTUuNDk4LDE4LjM5Mi02Ljg1N2M2LjY4NC0xLjMzNSwxMy41LTIuMDEzLDIwLjI2LTIuMDEzYzYuMDk2LDAsMTIuMzY1LDAuNDM3LDE4LjYyNiwxLjI5Ng0KCWM2LjM3NywwLjg4LDEyLjI4NSwyLjYyMiwxNy41NjIsNS4xNzdjNS4zNzYsMi42MDQsOS44NDUsNi4yOSwxMy4yODIsMTAuOTUxYzMuNDk4LDQuNzQ0LDUuMjcxLDExLjA0OCw1LjI3MSwxOC43MzF2NjIuNDk0DQoJYzAsNS4zMDcsMC4zMDYsMTAuNDYyLDAuOTE1LDE1LjMxOWMwLjU3Niw0LjY0LDEuNTcyLDguMTE2LDIuOTYzLDEwLjMzOGMwLjM4NSwwLjYxNiwwLjQwNywxLjM5NSwwLjA1NSwyLjAzMQ0KCWMtMC4zNTMsMC42MzUtMS4wMjIsMS4wMy0xLjc1LDEuMDNoLTMzLjQ1N2MtMC44NjEsMC0xLjYyNC0wLjU1LTEuODk4LTEuMzY3Yy0wLjY0Ni0xLjk0MS0xLjE3Ni0zLjkzOS0xLjU3Mi01LjkzNg0KCWMtMC4xNDEtMC42OTYtMC4yNjctMS40MDItMC4zOC0yLjEyYy00LjgyNSw0LjE4NC0xMC4zNDksNy4yNC0xNi40NzQsOS4xMDVDMzIwLjAzMywyMzUuNjA5LDMxMi40ODksMjM2LjczMywzMDQuOTA5LDIzNi43MzMNCglMMzA0LjkwOSwyMzYuNzMzeiBNMzQxLjk0MSwxNzYuNjYxYy0wLjgwOSwwLjQwOS0xLjY3NiwwLjc2OC0yLjU5NiwxLjA3NGMtMi4xNjEsMC43Mi00LjUxMSwxLjMyNi02Ljk4OCwxLjgwNw0KCWMtMi40NDIsMC40NzUtNS4wMzMsMC44NzItNy42OTksMS4xODZjLTIuNjMxLDAuMzExLTUuMjUxLDAuNjk3LTcuNzg0LDEuMTQ2Yy0yLjMyOSwwLjQzMy00LjcwNSwxLjAzNS03LjA1MSwxLjc5Mg0KCWMtMi4xOTQsMC43MTEtNC4xMTQsMS42NjctNS42OTksMi44NDJjLTEuNTMxLDEuMTI4LTIuNzg1LDIuNTg3LTMuNzMxLDQuMzM1Yy0wLjkxNywxLjcwOS0xLjM4NSwzLjk3LTEuMzg1LDYuNzE5DQoJYzAsMi41OTgsMC40NjUsNC43NzgsMS4zODUsNi40ODFjMC45MjgsMS43MjIsMi4xNDIsMy4wMzUsMy43MTYsNC4wMThjMS42NDQsMS4wMjYsMy42MDEsMS43NTcsNS44MTYsMi4xNw0KCWMyLjM0NCwwLjQzOSw0Ljc5OSwwLjY2Myw3LjI5NywwLjY2M2M2LjEwNSwwLDEwLjgzNi0wLjk5NiwxNC4wNjMtMi45NjFjMy4yNDQtMS45NzMsNS42NjYtNC4zNDksNy4xOTktNy4wNjINCgljMS41NjgtMi43OCwyLjU0Mi01LjYyLDIuODkyLTguNDM2YzAuMzc2LTMuMDE5LDAuNTY1LTUuNDM2LDAuNTY1LTcuMTg3VjE3Ni42NjFMMzQxLjk0MSwxNzYuNjYxeiIvPg0KPHBhdGggb3BhY2l0eT0iMC41IiBmaWxsPSIjRkZGRkZGIiBkPSJNMjczLjU0NCwxMjkuMjU1YzMuNDA1LTUuMTEzLDcuNzQ0LTkuMjE1LDEzLjAxMi0xMi4zMTYNCgljNS4yNjQtMy4wOTcsMTEuMTg2LTUuMzAzLDE3Ljc3MS02LjYyMWM2LjU4Mi0xLjMxNSwxMy4yMDUtMS45NzYsMTkuODY1LTEuOTc2YzYuMDQyLDAsMTIuMTU4LDAuNDI4LDE4LjM1NCwxLjI3Nw0KCWM2LjE5NSwwLjg1NSwxMS44NSwyLjUyMiwxNi45NjIsNC45OTdjNS4xMTEsMi40NzcsOS4yOTIsNS45MjYsMTIuNTQ2LDEwLjMzOGMzLjI1Myw0LjQxNCw0Ljg3OSwxMC4yNjIsNC44NzksMTcuNTQzdjYyLjQ5NA0KCWMwLDUuNDI4LDAuMzEsMTAuNjExLDAuOTMxLDE1LjU2N2MwLjYxNSw0Ljk1OSwxLjcwMSw4LjY3NiwzLjI1MSwxMS4xNTNIMzQ3LjY2Yy0wLjYyMS0xLjg2LTEuMTI2LTMuNzU1LTEuNTExLTUuNjkzDQoJYy0wLjM5LTEuOTMzLTAuNjYxLTMuOTA4LTAuODEzLTUuOTIzYy01LjI2Nyw1LjQyMi0xMS40NjUsOS4yMTctMTguNTg1LDExLjM4NmMtNy4xMjcsMi4xNjMtMTQuNDA3LDMuMjUxLTIxLjg0MiwzLjI1MQ0KCWMtNS43MzMsMC0xMS4wNzctMC42OTgtMTYuMDMzLTIuMDljLTQuOTU4LTEuMzk1LTkuMjkzLTMuNTYyLTEzLjAxLTYuNTFjLTMuNzE4LTIuOTM4LTYuNjIyLTYuNjU2LTguNzEzLTExLjE0Nw0KCXMtMy4xMzgtOS44NC0zLjEzOC0xNi4wMzNjMC02LjgxMywxLjE5OS0xMi40MywzLjYwNC0xNi44NGMyLjM5OS00LjQxNyw1LjQ5NS03LjkzOSw5LjI5NS0xMC41NzUNCgljMy43OTMtMi42MzIsOC4xMjktNC42MDcsMTMuMDEtNS45MjNjNC44NzgtMS4zMTUsOS43OTUtMi4zNTgsMTQuNzUyLTMuMTM3YzQuOTU3LTAuNzcyLDkuODM1LTEuMzkzLDE0LjYzOC0xLjg1Nw0KCWM0LjgwMS0wLjQ2Niw5LjA2Mi0xLjE2NCwxMi43NzktMi4wOTNjMy43MTgtMC45MjksNi42NTgtMi4yODIsOC44MjktNC4wNjVjMi4xNjUtMS43ODEsMy4xNzItNC4zNzUsMy4wMi03Ljc4NQ0KCWMwLTMuNTYtMC41OC02LjM4OS0xLjc0Mi04LjQ3OWMtMS4xNjEtMi4wOS0yLjcxMS0zLjcxOS00LjY0Ni00Ljg4Yy0xLjkzNy0xLjE2MS00LjE4My0xLjkzNi02LjczNy0yLjMyNQ0KCWMtMi41NTctMC4zODItNS4zMDktMC41OC04LjI0OC0wLjU4Yy02LjUwNiwwLTExLjYxNywxLjM5NS0xNS4zMzUsNC4xODNjLTMuNzE2LDIuNzg4LTUuODg5LDcuNDM3LTYuNTA2LDEzLjk0aC0zMi45OTENCglDMjY4LjE5OSwxNDAuNzk0LDI3MC4xMzIsMTM0LjM2MywyNzMuNTQ0LDEyOS4yNTV6IE0zMzguNzEzLDE3NS44MzhjLTIuMDksMC42OTYtNC4zMzcsMS4yNzUtNi43MzYsMS43NDENCgljLTIuNDAyLDAuNDY1LTQuOTE4LDAuODUzLTcuNTUxLDEuMTYxYy0yLjYzNSwwLjMxMy01LjI2OCwwLjY5OC03Ljg5OSwxLjE2M2MtMi40OCwwLjQ2MS00LjkxOSwxLjA4Ni03LjMxNywxLjg1Nw0KCWMtMi40MDQsMC43NzktNC40OTUsMS44MjItNi4yNzQsMy4xMzhjLTEuNzg0LDEuMzE3LTMuMjE2LDIuOTg1LTQuMyw0Ljk5NGMtMS4wODUsMi4wMTQtMS42MjYsNC41NzEtMS42MjYsNy42NjgNCgljMCwyLjk0LDAuNTQxLDUuNDIyLDEuNjI2LDcuNDMxYzEuMDg0LDIuMDE3LDIuNTU4LDMuNjA0LDQuNDE2LDQuNzY1czQuMDI1LDEuOTc2LDYuNTA3LDIuNDM4YzIuNDc1LDAuNDY2LDUuMDMxLDAuNjk4LDcuNjY1LDAuNjk4DQoJYzYuNTA1LDAsMTEuNTM3LTEuMDgyLDE1LjEwMy0zLjI1M2MzLjU2MS0yLjE2Niw2LjE5Mi00Ljc2Miw3Ljg5OS03Ljc4NWMxLjcwMi0zLjAxOSwyLjc0OS02LjA3MiwzLjEzNy05LjE3NA0KCWMwLjM4NC0zLjA5NywwLjU4LTUuNTc2LDAuNTgtNy40MzR2LTEyLjMxNkMzNDIuNTQ3LDE3NC4xNzMsMzQwLjgwNSwxNzUuMTQsMzM4LjcxMywxNzUuODM4eiIvPg0KPHBhdGggb3BhY2l0eT0iMC41IiBmaWxsPSIjRkZGRkZGIiBkPSJNNDQ0LjU0MiwyMzQuODc0Yy01LjE4NywwLTEwLjE3My0wLjM2MS0xNC44MjMtMS4wNjljLTQuODAyLTAuNzMyLTkuMTA0LTIuMTgzLTEyLjc3OS00LjMxMw0KCWMtMy43ODktMi4xODUtNi44MjEtNS4zNDEtOS4wMDYtOS4zNzVjLTIuMTYzLTMuOTg2LTMuMjYtOS4yMzItMy4yNi0xNS41OXYtNjguODU5aC0xNy45ODFjLTEuMTA0LDAtMi0wLjg5Ni0yLTEuOTk5di0yMi4wNzMNCgljMC0xLjEwNCwwLjg5Ni0xLjk5OSwyLTEuOTk5aDE3Ljk4MVY3NS41ODJjMC0xLjEwNCwwLjg5Ni0yLDItMmgzMi45OTJjMS4xMDQsMCwyLDAuODk2LDIsMnYzNC4wMTRoMjIuMTYyYzEuMTA0LDAsMiwwLjg5NiwyLDEuOTk5DQoJdjIyLjA3M2MwLDEuMTA0LTAuODk2LDEuOTk5LTIsMS45OTloLTIyLjE2MnY1Ny40NzljMCw2LjIyOSwxLjE5OCw4LjczMSwyLjIwMiw5LjczM2MxLjAwNCwxLjAwNywzLjUwNiwyLjIwNSw5LjczOCwyLjIwNQ0KCWMxLjgwNCwwLDMuNTQyLTAuMDc2LDUuMTYxLTAuMjI1YzEuNjA0LTAuMTQ0LDMuMTc0LTAuMzY3LDQuNjY5LTAuNjY1YzAuMTMtMC4wMjYsMC4yNjEtMC4wMzksMC4zOTEtMC4wMzkNCgljMC40NTgsMCwwLjkwNywwLjE1OSwxLjI3LDAuNDU0YzAuNDYzLDAuMzc5LDAuNzMsMC45NDYsMC43MywxLjU0NnYyNS41NTVjMCwwLjk3OS0wLjcwNywxLjgxMy0xLjY3MiwxLjk3NA0KCWMtMi44MzQsMC40NzItNi4wNDEsMC43OTQtOS41MjcsMC45NTdDNDUxLjAxNSwyMzQuNzk4LDQ0Ny43MTgsMjM0Ljg3NCw0NDQuNTQyLDIzNC44NzRMNDQ0LjU0MiwyMzQuODc0eiIvPg0KPHBhdGggb3BhY2l0eT0iMC41IiBmaWxsPSIjRkZGRkZGIiBkPSJNNDYzLjgyNSwxMTEuNTk1djIyLjA3MmgtMjQuMTYxdjU5LjQ3OWMwLDUuNTczLDAuOTI4LDkuMjkyLDIuNzg4LDExLjE0OQ0KCWMxLjg1NiwxLjg1OSw1LjU3NiwyLjc4OCwxMS4xNTIsMi43ODhjMS44NTksMCwzLjYzOC0wLjA3Niw1LjM0My0wLjIzMmMxLjcwMy0wLjE1MiwzLjMzLTAuMzg4LDQuODc4LTAuNjk2djI1LjU1Nw0KCWMtMi43ODgsMC40NjUtNS44ODcsMC43NzMtOS4yOTMsMC45MzFjLTMuNDA3LDAuMTQ5LTYuNzM3LDAuMjMtOS45OSwwLjIzYy01LjExMSwwLTkuOTUzLTAuMzUtMTQuNTIxLTEuMDQ4DQoJYy00LjU3MS0wLjY5NS04LjU5Ny0yLjA0Ny0xMi4wODEtNC4wNjNjLTMuNDg2LTIuMDExLTYuMjM2LTQuODgtOC4yNDgtOC41OTdjLTIuMDE2LTMuNzE0LTMuMDIxLTguNTk1LTMuMDIxLTE0LjYzOXYtNzAuODU5aC0xOS45OA0KCXYtMjIuMDcyaDE5Ljk4Vjc1LjU4M2gzMi45OTJ2MzYuMDEySDQ2My44MjV6Ii8+DQo8cGF0aCBvcGFjaXR5PSIwLjUiIGZpbGw9IiNGRkZGRkYiIGQ9Ik01MTIuNjEzLDIzMy43MTFoLTMyLjk5MWMtMS4xMDQsMC0yLTAuODk2LTItMlYxMTEuNTk0YzAtMS4xMDQsMC44OTYtMS45OTksMi0xLjk5OWgzMS4zNjYNCgljMS4xMDQsMCwyLDAuODk2LDIsMS45OTl2MTUuMDY5YzAuOTY3LTEuNTE2LDIuMDM0LTIuOTc4LDMuMTk5LTQuMzgyYzIuNzU0LTMuMzEyLDUuOTQ5LTYuMTgyLDkuNDk2LTguNTIyDQoJYzMuNTQ1LTIuMzMyLDcuMzg1LTQuMTY5LDExLjQxNS01LjQ2MmM0LjA1Ni0xLjI5OCw4LjMyNy0xLjk1NCwxMi42OTEtMS45NTRjMi4zNDEsMCw0Ljk1MywwLjQxOCw3Ljc2NiwxLjI0Mw0KCWMwLjg1MiwwLjI1LDEuNDM3LDEuMDMyLDEuNDM3LDEuOTJ2MzAuNjdjMCwwLjYtMC4yNjksMS4xNjctMC43MzIsMS41NDdjLTAuMzYxLDAuMjk2LTAuODA4LDAuNDUyLTEuMjY1LDAuNDUyDQoJYy0wLjEzMywwLTAuMjY1LTAuMDEzLTAuMzk4LTAuMDM5Yy0xLjQ4NC0wLjMtMy4yOTktMC41NjUtNS4zOTItMC43ODdjLTIuMDk4LTAuMjI0LTQuMTM2LTAuMzM5LTYuMDYyLTAuMzM5DQoJYy01LjcwNiwwLTEwLjU3MiwwLjk1LTE0LjQ2NywyLjgyM2MtMy44NjIsMS44Ni03LjAxMiw0LjQyOC05LjM2MSw3LjYyOWMtMi4zODksMy4yNjMtNC4xMTUsNy4xMi01LjEyNywxMS40Nw0KCWMtMS4wNDMsNC40NzktMS41NzQsOS40MDktMS41NzQsMTQuNjQ3djU0LjEzMkM1MTQuNjEzLDIzMi44MTUsNTEzLjcxNywyMzMuNzExLDUxMi42MTMsMjMzLjcxMUw1MTIuNjEzLDIzMy43MTF6Ii8+DQo8cGF0aCBvcGFjaXR5PSIwLjUiIGZpbGw9IiNGRkZGRkYiIGQ9Ik01MTAuOTg4LDExMS41OTVWMTMzLjloMC40NjVjMS41NDYtMy43MiwzLjYzNi03LjE2Myw2LjI3Mi0xMC4zNDENCgljMi42MzQtMy4xNzIsNS42NTItNS44ODUsOS4wNi04LjEzMWMzLjQwNS0yLjI0Miw3LjA0Ny0zLjk4NSwxMC45MjMtNS4yMjhjMy44NjgtMS4yMzcsNy44OTgtMS44NTksMTIuMDgxLTEuODU5DQoJYzIuMTY4LDAsNC41NjYsMC4zOSw3LjIwMiwxLjE2M3YzMC42N2MtMS41NTEtMC4zMTItMy40MS0wLjU4NC01LjU3Ni0wLjgxNGMtMi4xNy0wLjIzMy00LjI2LTAuMzUtNi4yNzQtMC4zNQ0KCWMtNi4wNDEsMC0xMS4xNTIsMS4wMS0xNS4zMzIsMy4wMjFjLTQuMTgyLDIuMDE0LTcuNTUsNC43NjEtMTAuMTA3LDguMjQ3Yy0yLjU1NSwzLjQ4Ny00LjM3OSw3LjU1LTUuNDYyLDEyLjE5OA0KCWMtMS4wODMsNC42NDUtMS42MjUsOS42ODItMS42MjUsMTUuMTAydjU0LjEzM2gtMzIuOTkxVjExMS41OTVINTEwLjk4OHoiLz4NCjxwYXRoIG9wYWNpdHk9IjAuNSIgZmlsbD0iI0ZGRkZGRiIgZD0iTTYwMy45MjMsMjMzLjcxMUg1NzAuOTNjLTEuMTA0LDAtMi0wLjg5Ni0yLTJWMTExLjU5NGMwLTEuMTA0LDAuODk2LTEuOTk5LDItMS45OTloMzIuOTk0DQoJYzEuMTA0LDAsMiwwLjg5NiwyLDEuOTk5djEyMC4xMTdDNjA1LjkyMywyMzIuODE1LDYwNS4wMjcsMjMzLjcxMSw2MDMuOTIzLDIzMy43MTFMNjAzLjkyMywyMzMuNzExeiBNNjAzLjkyMyw5NS4wMDZINTcwLjkzDQoJYy0xLjEwNCwwLTItMC44OTYtMi0xLjk5OVY2NS44MjVjMC0xLjEwNCwwLjg5Ni0yLDItMmgzMi45OTRjMS4xMDQsMCwyLDAuODk2LDIsMnYyNy4xODINCglDNjA1LjkyMyw5NC4xMSw2MDUuMDI3LDk1LjAwNiw2MDMuOTIzLDk1LjAwNkw2MDMuOTIzLDk1LjAwNnoiLz4NCjxwYXRoIG9wYWNpdHk9IjAuNSIgZmlsbD0iI0ZGRkZGRiIgZD0iTTU3MC45Myw5My4wMDdWNjUuODI0aDMyLjk5NHYyNy4xODNINTcwLjkzeiBNNjAzLjkyNCwxMTEuNTk1djEyMC4xMTdINTcwLjkzVjExMS41OTUNCglINjAzLjkyNHoiLz4NCjxwYXRoIG9wYWNpdHk9IjAuNSIgZmlsbD0iI0ZGRkZGRiIgZD0iTTc0Mi4xNjMsMjMzLjcxMWgtMzcuNjRjLTAuNjcxLDAtMS4yOTctMC4zMzUtMS42NjctMC44OTZsLTIzLjQyNi0zNS4zNTJsLTIzLjQyNiwzNS4zNTINCgljLTAuMzY5LDAuNTYxLTAuOTk1LDAuODk2LTEuNjY3LDAuODk2aC0zNi45MzhjLTAuNzQxLDAtMS40MjQtMC40MTEtMS43Ny0xLjA2N2MtMC4zNDUtMC42NTQtMC4zLTEuNDQ5LDAuMTE4LTIuMDYxbDQyLjQzNS02Mi4wNTUNCglsLTM4LjcxLTU1Ljc5M2MtMC40MjQtMC42MTMtMC40NzQtMS40MDgtMC4xMjgtMi4wNjljMC4zNDMtMC42NTgsMS4wMjgtMS4wNzEsMS43NzEtMS4wNzFoMzcuNjM2YzAuNjY1LDAsMS4yODcsMC4zMywxLjY1OCwwLjg4Mg0KCWwxOS40NzcsMjguODkzbDE5LjI1NS0yOC44ODRjMC4zNzItMC41NTYsMC45OTYtMC44OTEsMS42NjUtMC44OTFoMzYuNDc1YzAuNzQ2LDAsMS40MywwLjQxNSwxLjc3NiwxLjA3OA0KCWMwLjM0MywwLjY2LDAuMjg5LDEuNDYtMC4xMzksMi4wNzFsLTM4LjY5LDU1LjA4Mmw0My41NzgsNjIuNzQ0YzAuNDI0LDAuNjEsMC40NzQsMS40MDgsMC4xMjgsMi4wNjYNCglDNzQzLjU5MSwyMzMuMjk4LDc0Mi45MDgsMjMzLjcxMSw3NDIuMTYzLDIzMy43MTFMNzQyLjE2MywyMzMuNzExeiIvPg0KPHBhdGggb3BhY2l0eT0iMC41IiBmaWxsPSIjRkZGRkZGIiBkPSJNNjIxLjExNSwxMTEuNTk1aDM3LjYzN2wyMS4xNDQsMzEuMzY1bDIwLjkxMS0zMS4zNjVoMzYuNDc2bC0zOS40OTYsNTYuMjI2bDQ0LjM3Nyw2My44OTINCgloLTM3LjY0bC0yNS4wOTMtMzcuODdsLTI1LjA5NCwzNy44N2gtMzYuOTM4bDQzLjIxMy02My4xOTNMNjIxLjExNSwxMTEuNTk1eiIvPg0KPHBhdGggb3BhY2l0eT0iMC41IiBmaWxsPSIjRkZGRkZGIiBkPSJNNzkxLjMyMiwzNDAuODA5aC0zMi4wMDhjLTEuMTA1LDAtMi0wLjg5Ni0yLTJ2LTcuNzEyYzAtMS4xMDUsMC44OTYtMi4wMDEsMi0yLjAwMWgyMS4xMw0KCVYxMS43MWgtMjEuMTNjLTEuMTA1LDAtMi0wLjg5Ni0yLTJWMmMwLTEuMTA0LDAuODk2LTIsMi0yaDMyLjAwOGMxLjEwNCwwLDIsMC44OTYsMiwydjMzNi44MDkNCglDNzkzLjMyMiwzMzkuOTEzLDc5Mi40MjYsMzQwLjgwOSw3OTEuMzIyLDM0MC44MDlMNzkxLjMyMiwzNDAuODA5eiIvPg0KPHBhdGggb3BhY2l0eT0iMC41IiBmaWxsPSIjRkZGRkZGIiBkPSJNNzgyLjQ0MywzMzEuMDk3VjkuNzExaC0yMy4xM3YtNy43MWgzMi4wMDh2MzM2LjgwN2gtMzIuMDA4di03LjcxMUg3ODIuNDQzeiIvPg0KPHBhdGggZD0iTTEwLjg3NSw5LjcxMXYzMjEuMzg2aDIzLjEzdjcuNzExSDEuOTk5VjIuMDAxaDMyLjAwNnY3LjcxSDEwLjg3NXoiLz4NCjxwYXRoIGQ9Ik05OS45ODgsMTExLjU5NXYxNi4yNjRoMC40NjNjNC4zMzgtNi4xOTEsOS41NjMtMTAuOTk4LDE1LjY4NC0xNC40MDZjNi4xMTctMy40MDIsMTMuMTI5LTUuMTEsMjEuMDI3LTUuMTENCgljNy41ODgsMCwxNC41MjEsMS40NzUsMjAuNzkzLDQuNDE1YzYuMjc0LDIuOTQ1LDExLjAzOCw4LjEzMSwxNC4yOTEsMTUuNTY3YzMuNTYtNS4yNjUsOC40LTkuOTEzLDE0LjUyMS0xMy45NA0KCWM2LjExNy00LjAyNSwxMy4zNTgtNi4wNDIsMjEuNzI0LTYuMDQyYzYuMzUxLDAsMTIuMjM0LDAuNzc2LDE3LjY2LDIuMzI1YzUuNDE4LDEuNTQ5LDEwLjA2NSw0LjAyNywxMy45MzgsNy40MzQNCgljMy44NjksMy40MSw2Ljg4OSw3Ljg2Myw5LjA2MiwxMy4zNTdjMi4xNjcsNS41MDQsMy4yNTMsMTIuMTIyLDMuMjUzLDE5Ljg2OXY4MC4zODVIMjE5LjQxdi02OC4wNzQNCgljMC00LjAyNS0wLjE1NC03LjgyLTAuNDY1LTExLjM4NWMtMC4zMTMtMy41Ni0xLjE2MS02LjY1Ni0yLjU1NS05LjI5M2MtMS4zOTUtMi42MzEtMy40NS00LjcyNC02LjE1Ny02LjI3NA0KCWMtMi43MTEtMS41NDMtNi4zOTEtMi4zMjItMTEuMDM3LTIuMzIycy04LjQwMywwLjg5Ni0xMS4yNjksMi42NzFjLTIuODY4LDEuNzg0LTUuMTEyLDQuMTA5LTYuNzM3LDYuOTcxDQoJYy0xLjYyNiwyLjg2OS0yLjcxMSw2LjEyLTMuMjUyLDkuNzYyYy0wLjU0NSwzLjYzOC0wLjgxNCw3LjMxOC0wLjgxNCwxMS4wMzV2NjYuOTFoLTMyLjk5MXYtNjcuMzc1YzAtMy41NjItMC4wODEtNy4wODctMC4yMy0xMC41Nw0KCWMtMC4xNTgtMy40ODctMC44MTQtNi43LTEuOTc4LTkuNjQ1Yy0xLjE2Mi0yLjk0LTMuMDk5LTUuMzA0LTUuODA5LTcuMDg4Yy0yLjcxMS0xLjc3NS02LjY5OS0yLjY3MS0xMS45NjUtMi42NzENCgljLTEuNTUxLDAtMy42MDMsMC4zNDktNi4xNTYsMS4wNDhjLTIuNTU2LDAuNjk3LTUuMDM2LDIuMDE2LTcuNDM1LDMuOTQ5Yy0yLjQwNCwxLjkzOC00LjQ1NCw0LjcyNi02LjE1OCw4LjM2Mw0KCWMtMS43MDUsMy42NDItMi41NTYsOC40MDItMi41NTYsMTQuMjg3djY5LjcwMWgtMzIuOTlWMTExLjU5NUg5OS45ODh6Ii8+DQo8cGF0aCBkPSJNMjczLjU0NCwxMjkuMjU1YzMuNDA1LTUuMTEzLDcuNzQ0LTkuMjE1LDEzLjAxMi0xMi4zMTZjNS4yNjQtMy4wOTcsMTEuMTg2LTUuMzAzLDE3Ljc3MS02LjYyMQ0KCWM2LjU4Mi0xLjMxNSwxMy4yMDUtMS45NzYsMTkuODY1LTEuOTc2YzYuMDQyLDAsMTIuMTU4LDAuNDI4LDE4LjM1NCwxLjI3N2M2LjE5NSwwLjg1NSwxMS44NSwyLjUyMiwxNi45NjIsNC45OTcNCgljNS4xMTEsMi40NzcsOS4yOTIsNS45MjYsMTIuNTQ2LDEwLjMzOGMzLjI1Myw0LjQxNCw0Ljg3OSwxMC4yNjIsNC44NzksMTcuNTQzdjYyLjQ5NGMwLDUuNDI4LDAuMzEsMTAuNjExLDAuOTMxLDE1LjU2Nw0KCWMwLjYxNSw0Ljk1OSwxLjcwMSw4LjY3NiwzLjI1MSwxMS4xNTNIMzQ3LjY2Yy0wLjYyMS0xLjg2LTEuMTI2LTMuNzU1LTEuNTExLTUuNjkzYy0wLjM5LTEuOTMzLTAuNjYxLTMuOTA4LTAuODEzLTUuOTIzDQoJYy01LjI2Nyw1LjQyMi0xMS40NjUsOS4yMTctMTguNTg1LDExLjM4NmMtNy4xMjcsMi4xNjMtMTQuNDA3LDMuMjUxLTIxLjg0MiwzLjI1MWMtNS43MzMsMC0xMS4wNzctMC42OTgtMTYuMDMzLTIuMDkNCgljLTQuOTU4LTEuMzk1LTkuMjkzLTMuNTYyLTEzLjAxLTYuNTFjLTMuNzE4LTIuOTM4LTYuNjIyLTYuNjU2LTguNzEzLTExLjE0N3MtMy4xMzgtOS44NC0zLjEzOC0xNi4wMzMNCgljMC02LjgxMywxLjE5OS0xMi40MywzLjYwNC0xNi44NGMyLjM5OS00LjQxNyw1LjQ5NS03LjkzOSw5LjI5NS0xMC41NzVjMy43OTMtMi42MzIsOC4xMjktNC42MDcsMTMuMDEtNS45MjMNCgljNC44NzgtMS4zMTUsOS43OTUtMi4zNTgsMTQuNzUyLTMuMTM3YzQuOTU3LTAuNzcyLDkuODM1LTEuMzkzLDE0LjYzOC0xLjg1N2M0LjgwMS0wLjQ2Niw5LjA2Mi0xLjE2NCwxMi43NzktMi4wOTMNCgljMy43MTgtMC45MjksNi42NTgtMi4yODIsOC44MjktNC4wNjVjMi4xNjUtMS43ODEsMy4xNzItNC4zNzUsMy4wMi03Ljc4NWMwLTMuNTYtMC41OC02LjM4OS0xLjc0Mi04LjQ3OQ0KCWMtMS4xNjEtMi4wOS0yLjcxMS0zLjcxOS00LjY0Ni00Ljg4Yy0xLjkzNy0xLjE2MS00LjE4My0xLjkzNi02LjczNy0yLjMyNWMtMi41NTctMC4zODItNS4zMDktMC41OC04LjI0OC0wLjU4DQoJYy02LjUwNiwwLTExLjYxNywxLjM5NS0xNS4zMzUsNC4xODNjLTMuNzE2LDIuNzg4LTUuODg5LDcuNDM3LTYuNTA2LDEzLjk0aC0zMi45OTENCglDMjY4LjE5OSwxNDAuNzk0LDI3MC4xMzIsMTM0LjM2MywyNzMuNTQ0LDEyOS4yNTV6IE0zMzguNzEzLDE3NS44MzhjLTIuMDksMC42OTYtNC4zMzcsMS4yNzUtNi43MzYsMS43NDENCgljLTIuNDAyLDAuNDY1LTQuOTE4LDAuODUzLTcuNTUxLDEuMTYxYy0yLjYzNSwwLjMxMy01LjI2OCwwLjY5OC03Ljg5OSwxLjE2M2MtMi40OCwwLjQ2MS00LjkxOSwxLjA4Ni03LjMxNywxLjg1Nw0KCWMtMi40MDQsMC43NzktNC40OTUsMS44MjItNi4yNzQsMy4xMzhjLTEuNzg0LDEuMzE3LTMuMjE2LDIuOTg1LTQuMyw0Ljk5NGMtMS4wODUsMi4wMTQtMS42MjYsNC41NzEtMS42MjYsNy42NjgNCgljMCwyLjk0LDAuNTQxLDUuNDIyLDEuNjI2LDcuNDMxYzEuMDg0LDIuMDE3LDIuNTU4LDMuNjA0LDQuNDE2LDQuNzY1czQuMDI1LDEuOTc2LDYuNTA3LDIuNDM4YzIuNDc1LDAuNDY2LDUuMDMxLDAuNjk4LDcuNjY1LDAuNjk4DQoJYzYuNTA1LDAsMTEuNTM3LTEuMDgyLDE1LjEwMy0zLjI1M2MzLjU2MS0yLjE2Niw2LjE5Mi00Ljc2Miw3Ljg5OS03Ljc4NWMxLjcwMi0zLjAxOSwyLjc0OS02LjA3MiwzLjEzNy05LjE3NA0KCWMwLjM4NC0zLjA5NywwLjU4LTUuNTc2LDAuNTgtNy40MzR2LTEyLjMxNkMzNDIuNTQ3LDE3NC4xNzMsMzQwLjgwNSwxNzUuMTQsMzM4LjcxMywxNzUuODM4eiIvPg0KPHBhdGggZD0iTTQ2My44MjUsMTExLjU5NXYyMi4wNzJoLTI0LjE2MXY1OS40NzljMCw1LjU3MywwLjkyOCw5LjI5MiwyLjc4OCwxMS4xNDljMS44NTYsMS44NTksNS41NzYsMi43ODgsMTEuMTUyLDIuNzg4DQoJYzEuODU5LDAsMy42MzgtMC4wNzYsNS4zNDMtMC4yMzJjMS43MDMtMC4xNTIsMy4zMy0wLjM4OCw0Ljg3OC0wLjY5NnYyNS41NTdjLTIuNzg4LDAuNDY1LTUuODg3LDAuNzczLTkuMjkzLDAuOTMxDQoJYy0zLjQwNywwLjE0OS02LjczNywwLjIzLTkuOTksMC4yM2MtNS4xMTEsMC05Ljk1My0wLjM1LTE0LjUyMS0xLjA0OGMtNC41NzEtMC42OTUtOC41OTctMi4wNDctMTIuMDgxLTQuMDYzDQoJYy0zLjQ4Ni0yLjAxMS02LjIzNi00Ljg4LTguMjQ4LTguNTk3Yy0yLjAxNi0zLjcxNC0zLjAyMS04LjU5NS0zLjAyMS0xNC42Mzl2LTcwLjg1OWgtMTkuOTh2LTIyLjA3MmgxOS45OFY3NS41ODNoMzIuOTkydjM2LjAxMg0KCUg0NjMuODI1eiIvPg0KPHBhdGggZD0iTTUxMC45ODgsMTExLjU5NVYxMzMuOWgwLjQ2NWMxLjU0Ni0zLjcyLDMuNjM2LTcuMTYzLDYuMjcyLTEwLjM0MWMyLjYzNC0zLjE3Miw1LjY1Mi01Ljg4NSw5LjA2LTguMTMxDQoJYzMuNDA1LTIuMjQyLDcuMDQ3LTMuOTg1LDEwLjkyMy01LjIyOGMzLjg2OC0xLjIzNyw3Ljg5OC0xLjg1OSwxMi4wODEtMS44NTljMi4xNjgsMCw0LjU2NiwwLjM5LDcuMjAyLDEuMTYzdjMwLjY3DQoJYy0xLjU1MS0wLjMxMi0zLjQxLTAuNTg0LTUuNTc2LTAuODE0Yy0yLjE3LTAuMjMzLTQuMjYtMC4zNS02LjI3NC0wLjM1Yy02LjA0MSwwLTExLjE1MiwxLjAxLTE1LjMzMiwzLjAyMQ0KCWMtNC4xODIsMi4wMTQtNy41NSw0Ljc2MS0xMC4xMDcsOC4yNDdjLTIuNTU1LDMuNDg3LTQuMzc5LDcuNTUtNS40NjIsMTIuMTk4Yy0xLjA4Myw0LjY0NS0xLjYyNSw5LjY4Mi0xLjYyNSwxNS4xMDJ2NTQuMTMzaC0zMi45OTENCglWMTExLjU5NUg1MTAuOTg4eiIvPg0KPHBhdGggZD0iTTU3MC45Myw5My4wMDdWNjUuODI0aDMyLjk5NHYyNy4xODNINTcwLjkzeiBNNjAzLjkyNCwxMTEuNTk1djEyMC4xMTdINTcwLjkzVjExMS41OTVINjAzLjkyNHoiLz4NCjxwYXRoIGQ9Ik02MjEuMTE1LDExMS41OTVoMzcuNjM3bDIxLjE0NCwzMS4zNjVsMjAuOTExLTMxLjM2NWgzNi40NzZsLTM5LjQ5Niw1Ni4yMjZsNDQuMzc3LDYzLjg5MmgtMzcuNjRsLTI1LjA5My0zNy44Nw0KCWwtMjUuMDk0LDM3Ljg3aC0zNi45MzhsNDMuMjEzLTYzLjE5M0w2MjEuMTE1LDExMS41OTV6Ii8+DQo8cGF0aCBkPSJNNzgyLjQ0MywzMzEuMDk3VjkuNzExaC0yMy4xM3YtNy43MWgzMi4wMDh2MzM2LjgwN2gtMzIuMDA4di03LjcxMUg3ODIuNDQzeiIvPg0KPC9zdmc+DQo=`, "base64"); \ No newline at end of file +export const TEST_FILE = Buffer.from( + `PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4NCjwhLS0gR2VuZXJhdG9yOiBBZG9iZSBJbGx1c3RyYXRvciAxMy4wLjAsIFNWRyBFeHBvcnQgUGx1Zy1JbiAuIFNWRyBWZXJzaW9uOiA2LjAwIEJ1aWxkIDE0NTc2KSAgLS0+DQo8IURPQ1RZUEUgc3ZnIFBVQkxJQyAiLS8vVzNDLy9EVEQgU1ZHIDEuMS8vRU4iICJodHRwOi8vd3d3LnczLm9yZy9HcmFwaGljcy9TVkcvMS4xL0RURC9zdmcxMS5kdGQiPg0KPHN2ZyB2ZXJzaW9uPSIxLjEiIGlkPSJMYXllcl8xIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB4PSIwcHgiIHk9IjBweCINCgkgd2lkdGg9Ijc5My4zMjJweCIgaGVpZ2h0PSIzNDAuODA5cHgiIHZpZXdCb3g9IjAgMCA3OTMuMzIyIDM0MC44MDkiIGVuYWJsZS1iYWNrZ3JvdW5kPSJuZXcgMCAwIDc5My4zMjIgMzQwLjgwOSINCgkgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+DQo8cGF0aCBvcGFjaXR5PSIwLjUiIGZpbGw9IiNGRkZGRkYiIGQ9Ik0zNC4wMDQsMzQwLjgwOUgyYy0xLjEwNCwwLTItMC44OTYtMi0yVjJjMC0xLjEwNCwwLjg5Ni0yLDItMmgzMi4wMDRjMS4xMDQsMCwyLDAuODk2LDIsMg0KCXY3LjcxYzAsMS4xMDQtMC44OTYsMi0yLDJoLTIxLjEzdjMxNy4zODZoMjEuMTNjMS4xMDQsMCwyLDAuODk2LDIsMi4wMDF2Ny43MTJDMzYuMDA0LDMzOS45MTMsMzUuMTA4LDM0MC44MDksMzQuMDA0LDM0MC44MDkNCglMMzQuMDA0LDM0MC44MDl6Ii8+DQo8cGF0aCBvcGFjaXR5PSIwLjUiIGZpbGw9IiNGRkZGRkYiIGQ9Ik0xMC44NzUsOS43MTF2MzIxLjM4NmgyMy4xM3Y3LjcxMUgxLjk5OVYyLjAwMWgzMi4wMDZ2Ny43MUgxMC44NzV6Ii8+DQo8cGF0aCBvcGFjaXR5PSIwLjUiIGZpbGw9IiNGRkZGRkYiIGQ9Ik0yNTIuNDAyLDIzMy43MTFoLTMyLjk5M2MtMS4xMDQsMC0yLTAuODk2LTItMnYtNjguMDczYzAtMy45NDktMC4xNTQtNy43MjItMC40NTctMTEuMjEzDQoJYy0wLjI4OS0zLjI4Mi0xLjA3NC02LjE1My0yLjMzMi04LjUzYy0xLjIwNC0yLjI3Ni0zLjAxNy00LjExOS01LjM4NC01LjQ3NmMtMi4zOTMtMS4zNjItNS43NzUtMi4wNTYtMTAuMDQyLTIuMDU2DQoJYy00LjIzOCwwLTcuNjc0LDAuNzk4LTEwLjIxMywyLjM3MWMtMi41NjUsMS41OTYtNC42MDQsMy43MDEtNi4wNTMsNi4yNThjLTEuNDk4LDIuNjQzLTIuNTEsNS42OTQtMy4wMTMsOS4wNjcNCgljLTAuNTI2LDMuNTEzLTAuNzkzLDcuMTI1LTAuNzkzLDEwLjc0MXY2Ni45MWMwLDEuMTA0LTAuODk2LDItMiwyaC0zMi45OTFjLTEuMTA0LDAtMi0wLjg5Ni0yLTJ2LTY3LjM3Mw0KCWMwLTMuNDM1LTAuMDc4LTYuOTY0LTAuMjI4LTEwLjQ4NWMtMC4xNDgtMy4yNTEtMC43NjctNi4yNzgtMS44NDEtOC45OTVjLTEuMDE4LTIuNTcxLTIuNjY3LTQuNTg0LTUuMDQ3LTYuMTUzDQoJYy0yLjM3Mi0xLjU1Mi02LjAyOS0yLjM0MS0xMC44NjUtMi4zNDFjLTEuMzcyLDAtMy4yNjUsMC4zMjgtNS42MjksMC45NzZjLTIuMjgsMC42MjQtNC41MzYsMS44MjYtNi43MDUsMy41NzcNCgljLTIuMTUyLDEuNzMyLTQuMDM2LDQuMzA2LTUuNjA1LDcuNjU1Yy0xLjU2OSwzLjM1Ni0yLjM2Nyw3Ljg3Ny0yLjM2NywxMy40Mzh2NjkuNzAxYzAsMS4xMDQtMC44OTUsMi0yLDJINjguODU3DQoJYy0xLjEwNCwwLTItMC44OTYtMi0yVjExMS41OTRjMC0xLjEwNCwwLjg5Ni0xLjk5OSwyLTEuOTk5aDMxLjEzYzEuMTA0LDAsMiwwLjg5NiwyLDEuOTk5djExLjAwNw0KCWMzLjgzNC00LjQ5OSw4LjI0OC04LjE1MiwxMy4xNzMtMTAuODk2YzYuMzk2LTMuNTU5LDEzLjc5OS01LjM2MiwyMi4wMDItNS4zNjJjNy44NDYsMCwxNS4xMjcsMS41NDgsMjEuNjQyLDQuNjA0DQoJYzUuNzk0LDIuNzIyLDEwLjQyNCw3LjI2LDEzLjc5MSwxMy41MmMzLjQ0OS00LjM2Miw3LjgzMy04LjMwNiwxMy4wNzEtMTEuNzUyYzYuNDIyLTQuMjI4LDE0LjEwMi02LjM3MSwyMi44MjQtNi4zNzENCgljNi40OTksMCwxMi42MjUsMC44MDcsMTguMjA5LDIuMzk5YzUuNjg2LDEuNjI4LDEwLjYzNSw0LjI3MSwxNC43MTIsNy44NTdjNC4wODgsMy42MDUsNy4zMTgsOC4zNTcsOS42MDEsMTQuMTIzDQoJYzIuMjUsNS43MTksMy4zOTEsMTIuNjQ5LDMuMzkxLDIwLjYwNHY4MC4zODRDMjU0LjQwMiwyMzIuODE1LDI1My41MDcsMjMzLjcxMSwyNTIuNDAyLDIzMy43MTFMMjUyLjQwMiwyMzMuNzExeiIvPg0KPHBhdGggb3BhY2l0eT0iMC41IiBmaWxsPSIjRkZGRkZGIiBkPSJNOTkuOTg4LDExMS41OTV2MTYuMjY0aDAuNDYzYzQuMzM4LTYuMTkxLDkuNTYzLTEwLjk5OCwxNS42ODQtMTQuNDA2DQoJYzYuMTE3LTMuNDAyLDEzLjEyOS01LjExLDIxLjAyNy01LjExYzcuNTg4LDAsMTQuNTIxLDEuNDc1LDIwLjc5Myw0LjQxNWM2LjI3NCwyLjk0NSwxMS4wMzgsOC4xMzEsMTQuMjkxLDE1LjU2Nw0KCWMzLjU2LTUuMjY1LDguNC05LjkxMywxNC41MjEtMTMuOTRjNi4xMTctNC4wMjUsMTMuMzU4LTYuMDQyLDIxLjcyNC02LjA0MmM2LjM1MSwwLDEyLjIzNCwwLjc3NiwxNy42NiwyLjMyNQ0KCWM1LjQxOCwxLjU0OSwxMC4wNjUsNC4wMjcsMTMuOTM4LDcuNDM0YzMuODY5LDMuNDEsNi44ODksNy44NjMsOS4wNjIsMTMuMzU3YzIuMTY3LDUuNTA0LDMuMjUzLDEyLjEyMiwzLjI1MywxOS44Njl2ODAuMzg1SDIxOS40MQ0KCXYtNjguMDc0YzAtNC4wMjUtMC4xNTQtNy44Mi0wLjQ2NS0xMS4zODVjLTAuMzEzLTMuNTYtMS4xNjEtNi42NTYtMi41NTUtOS4yOTNjLTEuMzk1LTIuNjMxLTMuNDUtNC43MjQtNi4xNTctNi4yNzQNCgljLTIuNzExLTEuNTQzLTYuMzkxLTIuMzIyLTExLjAzNy0yLjMyMnMtOC40MDMsMC44OTYtMTEuMjY5LDIuNjcxYy0yLjg2OCwxLjc4NC01LjExMiw0LjEwOS02LjczNyw2Ljk3MQ0KCWMtMS42MjYsMi44NjktMi43MTEsNi4xMi0zLjI1Miw5Ljc2MmMtMC41NDUsMy42MzgtMC44MTQsNy4zMTgtMC44MTQsMTEuMDM1djY2LjkxaC0zMi45OTF2LTY3LjM3NWMwLTMuNTYyLTAuMDgxLTcuMDg3LTAuMjMtMTAuNTcNCgljLTAuMTU4LTMuNDg3LTAuODE0LTYuNy0xLjk3OC05LjY0NWMtMS4xNjItMi45NC0zLjA5OS01LjMwNC01LjgwOS03LjA4OGMtMi43MTEtMS43NzUtNi42OTktMi42NzEtMTEuOTY1LTIuNjcxDQoJYy0xLjU1MSwwLTMuNjAzLDAuMzQ5LTYuMTU2LDEuMDQ4Yy0yLjU1NiwwLjY5Ny01LjAzNiwyLjAxNi03LjQzNSwzLjk0OWMtMi40MDQsMS45MzgtNC40NTQsNC43MjYtNi4xNTgsOC4zNjMNCgljLTEuNzA1LDMuNjQyLTIuNTU2LDguNDAyLTIuNTU2LDE0LjI4N3Y2OS43MDFoLTMyLjk5VjExMS41OTVIOTkuOTg4eiIvPg0KPHBhdGggb3BhY2l0eT0iMC41IiBmaWxsPSIjRkZGRkZGIiBkPSJNMzA0LjkwOSwyMzYuNzMzYy01Ljg4MywwLTExLjQ2LTAuNzI5LTE2LjU3NC0yLjE2M2MtNS4xOTItMS40NjQtOS44MDYtMy43NzQtMTMuNzEzLTYuODcxDQoJYy0zLjk0NC0zLjExNy03LjA2OC03LjExMS05LjI4Mi0xMS44NzFjLTIuMjA1LTQuNzMzLTMuMzI0LTEwLjQxMi0zLjMyNC0xNi44NzZjMC03LjEzLDEuMjkzLTEzLjExNywzLjg0Ni0xNy43OTcNCgljMi41NDItNC42NzQsNS44NzctOC40NjQsOS45MTItMTEuMjYzYzMuOTctMi43NTIsOC41NTYtNC44NDIsMTMuNjMtNi4yMDljNC45MDEtMS4zMjIsOS45MzctMi4zOTQsMTQuOTYxLTMuMTg0DQoJYzQuOTg2LTAuNzc1LDkuOTQ5LTEuNDA0LDE0Ljc1NC0xLjg3MmM0LjY3OS0wLjQ1Miw4Ljg4LTEuMTM5LDEyLjQ4OS0yLjAzOWMzLjQxMi0wLjg1NCw2LjExOC0yLjA5LDguMDQyLTMuNjcyDQoJYzEuNjY2LTEuMzcsMi40MTYtMy4zODQsMi4yOTItNi4xNTFjLTAuMDAyLTMuMjg5LTAuNTAyLTUuODE2LTEuNDkyLTcuNTk1Yy0wLjk5OC0xLjc5OC0yLjI4My0zLjE1LTMuOTI3LTQuMTM4DQoJYy0xLjcwMy0xLjAyLTMuNzI1LTEuNzEzLTYuMDEyLTIuMDYyYy0yLjQ3LTAuMzctNS4xNDYtMC41NTctNy45NDctMC41NTdjLTYuMDM0LDAtMTAuNzg5LDEuMjcxLTE0LjEzNSwzLjc4Mw0KCWMtMy4yMzMsMi40MjQtNS4xNTUsNi42NC01LjcxNCwxMi41MjdjLTAuMDk4LDEuMDI2LTAuOTYxLDEuODEyLTEuOTkyLDEuODEyaC0zMi45OTJjLTAuNTUyLDAtMS4wNzktMC4yMjktMS40NTctMC42MjkNCgljLTAuMzc2LTAuNDAyLTAuNTcyLTAuOTQxLTAuNTQtMS40OTFjMC40ODUtOC4wNzMsMi41NS0xNC44OTQsNi4xNDItMjAuMjcyYzMuNTQ4LTUuMzMxLDguMTQ3LTkuNjgyLDEzLjY2MS0xMi45MzENCgljNS40MjQtMy4xOTEsMTEuNjEyLTUuNDk4LDE4LjM5Mi02Ljg1N2M2LjY4NC0xLjMzNSwxMy41LTIuMDEzLDIwLjI2LTIuMDEzYzYuMDk2LDAsMTIuMzY1LDAuNDM3LDE4LjYyNiwxLjI5Ng0KCWM2LjM3NywwLjg4LDEyLjI4NSwyLjYyMiwxNy41NjIsNS4xNzdjNS4zNzYsMi42MDQsOS44NDUsNi4yOSwxMy4yODIsMTAuOTUxYzMuNDk4LDQuNzQ0LDUuMjcxLDExLjA0OCw1LjI3MSwxOC43MzF2NjIuNDk0DQoJYzAsNS4zMDcsMC4zMDYsMTAuNDYyLDAuOTE1LDE1LjMxOWMwLjU3Niw0LjY0LDEuNTcyLDguMTE2LDIuOTYzLDEwLjMzOGMwLjM4NSwwLjYxNiwwLjQwNywxLjM5NSwwLjA1NSwyLjAzMQ0KCWMtMC4zNTMsMC42MzUtMS4wMjIsMS4wMy0xLjc1LDEuMDNoLTMzLjQ1N2MtMC44NjEsMC0xLjYyNC0wLjU1LTEuODk4LTEuMzY3Yy0wLjY0Ni0xLjk0MS0xLjE3Ni0zLjkzOS0xLjU3Mi01LjkzNg0KCWMtMC4xNDEtMC42OTYtMC4yNjctMS40MDItMC4zOC0yLjEyYy00LjgyNSw0LjE4NC0xMC4zNDksNy4yNC0xNi40NzQsOS4xMDVDMzIwLjAzMywyMzUuNjA5LDMxMi40ODksMjM2LjczMywzMDQuOTA5LDIzNi43MzMNCglMMzA0LjkwOSwyMzYuNzMzeiBNMzQxLjk0MSwxNzYuNjYxYy0wLjgwOSwwLjQwOS0xLjY3NiwwLjc2OC0yLjU5NiwxLjA3NGMtMi4xNjEsMC43Mi00LjUxMSwxLjMyNi02Ljk4OCwxLjgwNw0KCWMtMi40NDIsMC40NzUtNS4wMzMsMC44NzItNy42OTksMS4xODZjLTIuNjMxLDAuMzExLTUuMjUxLDAuNjk3LTcuNzg0LDEuMTQ2Yy0yLjMyOSwwLjQzMy00LjcwNSwxLjAzNS03LjA1MSwxLjc5Mg0KCWMtMi4xOTQsMC43MTEtNC4xMTQsMS42NjctNS42OTksMi44NDJjLTEuNTMxLDEuMTI4LTIuNzg1LDIuNTg3LTMuNzMxLDQuMzM1Yy0wLjkxNywxLjcwOS0xLjM4NSwzLjk3LTEuMzg1LDYuNzE5DQoJYzAsMi41OTgsMC40NjUsNC43NzgsMS4zODUsNi40ODFjMC45MjgsMS43MjIsMi4xNDIsMy4wMzUsMy43MTYsNC4wMThjMS42NDQsMS4wMjYsMy42MDEsMS43NTcsNS44MTYsMi4xNw0KCWMyLjM0NCwwLjQzOSw0Ljc5OSwwLjY2Myw3LjI5NywwLjY2M2M2LjEwNSwwLDEwLjgzNi0wLjk5NiwxNC4wNjMtMi45NjFjMy4yNDQtMS45NzMsNS42NjYtNC4zNDksNy4xOTktNy4wNjINCgljMS41NjgtMi43OCwyLjU0Mi01LjYyLDIuODkyLTguNDM2YzAuMzc2LTMuMDE5LDAuNTY1LTUuNDM2LDAuNTY1LTcuMTg3VjE3Ni42NjFMMzQxLjk0MSwxNzYuNjYxeiIvPg0KPHBhdGggb3BhY2l0eT0iMC41IiBmaWxsPSIjRkZGRkZGIiBkPSJNMjczLjU0NCwxMjkuMjU1YzMuNDA1LTUuMTEzLDcuNzQ0LTkuMjE1LDEzLjAxMi0xMi4zMTYNCgljNS4yNjQtMy4wOTcsMTEuMTg2LTUuMzAzLDE3Ljc3MS02LjYyMWM2LjU4Mi0xLjMxNSwxMy4yMDUtMS45NzYsMTkuODY1LTEuOTc2YzYuMDQyLDAsMTIuMTU4LDAuNDI4LDE4LjM1NCwxLjI3Nw0KCWM2LjE5NSwwLjg1NSwxMS44NSwyLjUyMiwxNi45NjIsNC45OTdjNS4xMTEsMi40NzcsOS4yOTIsNS45MjYsMTIuNTQ2LDEwLjMzOGMzLjI1Myw0LjQxNCw0Ljg3OSwxMC4yNjIsNC44NzksMTcuNTQzdjYyLjQ5NA0KCWMwLDUuNDI4LDAuMzEsMTAuNjExLDAuOTMxLDE1LjU2N2MwLjYxNSw0Ljk1OSwxLjcwMSw4LjY3NiwzLjI1MSwxMS4xNTNIMzQ3LjY2Yy0wLjYyMS0xLjg2LTEuMTI2LTMuNzU1LTEuNTExLTUuNjkzDQoJYy0wLjM5LTEuOTMzLTAuNjYxLTMuOTA4LTAuODEzLTUuOTIzYy01LjI2Nyw1LjQyMi0xMS40NjUsOS4yMTctMTguNTg1LDExLjM4NmMtNy4xMjcsMi4xNjMtMTQuNDA3LDMuMjUxLTIxLjg0MiwzLjI1MQ0KCWMtNS43MzMsMC0xMS4wNzctMC42OTgtMTYuMDMzLTIuMDljLTQuOTU4LTEuMzk1LTkuMjkzLTMuNTYyLTEzLjAxLTYuNTFjLTMuNzE4LTIuOTM4LTYuNjIyLTYuNjU2LTguNzEzLTExLjE0Nw0KCXMtMy4xMzgtOS44NC0zLjEzOC0xNi4wMzNjMC02LjgxMywxLjE5OS0xMi40MywzLjYwNC0xNi44NGMyLjM5OS00LjQxNyw1LjQ5NS03LjkzOSw5LjI5NS0xMC41NzUNCgljMy43OTMtMi42MzIsOC4xMjktNC42MDcsMTMuMDEtNS45MjNjNC44NzgtMS4zMTUsOS43OTUtMi4zNTgsMTQuNzUyLTMuMTM3YzQuOTU3LTAuNzcyLDkuODM1LTEuMzkzLDE0LjYzOC0xLjg1Nw0KCWM0LjgwMS0wLjQ2Niw5LjA2Mi0xLjE2NCwxMi43NzktMi4wOTNjMy43MTgtMC45MjksNi42NTgtMi4yODIsOC44MjktNC4wNjVjMi4xNjUtMS43ODEsMy4xNzItNC4zNzUsMy4wMi03Ljc4NQ0KCWMwLTMuNTYtMC41OC02LjM4OS0xLjc0Mi04LjQ3OWMtMS4xNjEtMi4wOS0yLjcxMS0zLjcxOS00LjY0Ni00Ljg4Yy0xLjkzNy0xLjE2MS00LjE4My0xLjkzNi02LjczNy0yLjMyNQ0KCWMtMi41NTctMC4zODItNS4zMDktMC41OC04LjI0OC0wLjU4Yy02LjUwNiwwLTExLjYxNywxLjM5NS0xNS4zMzUsNC4xODNjLTMuNzE2LDIuNzg4LTUuODg5LDcuNDM3LTYuNTA2LDEzLjk0aC0zMi45OTENCglDMjY4LjE5OSwxNDAuNzk0LDI3MC4xMzIsMTM0LjM2MywyNzMuNTQ0LDEyOS4yNTV6IE0zMzguNzEzLDE3NS44MzhjLTIuMDksMC42OTYtNC4zMzcsMS4yNzUtNi43MzYsMS43NDENCgljLTIuNDAyLDAuNDY1LTQuOTE4LDAuODUzLTcuNTUxLDEuMTYxYy0yLjYzNSwwLjMxMy01LjI2OCwwLjY5OC03Ljg5OSwxLjE2M2MtMi40OCwwLjQ2MS00LjkxOSwxLjA4Ni03LjMxNywxLjg1Nw0KCWMtMi40MDQsMC43NzktNC40OTUsMS44MjItNi4yNzQsMy4xMzhjLTEuNzg0LDEuMzE3LTMuMjE2LDIuOTg1LTQuMyw0Ljk5NGMtMS4wODUsMi4wMTQtMS42MjYsNC41NzEtMS42MjYsNy42NjgNCgljMCwyLjk0LDAuNTQxLDUuNDIyLDEuNjI2LDcuNDMxYzEuMDg0LDIuMDE3LDIuNTU4LDMuNjA0LDQuNDE2LDQuNzY1czQuMDI1LDEuOTc2LDYuNTA3LDIuNDM4YzIuNDc1LDAuNDY2LDUuMDMxLDAuNjk4LDcuNjY1LDAuNjk4DQoJYzYuNTA1LDAsMTEuNTM3LTEuMDgyLDE1LjEwMy0zLjI1M2MzLjU2MS0yLjE2Niw2LjE5Mi00Ljc2Miw3Ljg5OS03Ljc4NWMxLjcwMi0zLjAxOSwyLjc0OS02LjA3MiwzLjEzNy05LjE3NA0KCWMwLjM4NC0zLjA5NywwLjU4LTUuNTc2LDAuNTgtNy40MzR2LTEyLjMxNkMzNDIuNTQ3LDE3NC4xNzMsMzQwLjgwNSwxNzUuMTQsMzM4LjcxMywxNzUuODM4eiIvPg0KPHBhdGggb3BhY2l0eT0iMC41IiBmaWxsPSIjRkZGRkZGIiBkPSJNNDQ0LjU0MiwyMzQuODc0Yy01LjE4NywwLTEwLjE3My0wLjM2MS0xNC44MjMtMS4wNjljLTQuODAyLTAuNzMyLTkuMTA0LTIuMTgzLTEyLjc3OS00LjMxMw0KCWMtMy43ODktMi4xODUtNi44MjEtNS4zNDEtOS4wMDYtOS4zNzVjLTIuMTYzLTMuOTg2LTMuMjYtOS4yMzItMy4yNi0xNS41OXYtNjguODU5aC0xNy45ODFjLTEuMTA0LDAtMi0wLjg5Ni0yLTEuOTk5di0yMi4wNzMNCgljMC0xLjEwNCwwLjg5Ni0xLjk5OSwyLTEuOTk5aDE3Ljk4MVY3NS41ODJjMC0xLjEwNCwwLjg5Ni0yLDItMmgzMi45OTJjMS4xMDQsMCwyLDAuODk2LDIsMnYzNC4wMTRoMjIuMTYyYzEuMTA0LDAsMiwwLjg5NiwyLDEuOTk5DQoJdjIyLjA3M2MwLDEuMTA0LTAuODk2LDEuOTk5LTIsMS45OTloLTIyLjE2MnY1Ny40NzljMCw2LjIyOSwxLjE5OCw4LjczMSwyLjIwMiw5LjczM2MxLjAwNCwxLjAwNywzLjUwNiwyLjIwNSw5LjczOCwyLjIwNQ0KCWMxLjgwNCwwLDMuNTQyLTAuMDc2LDUuMTYxLTAuMjI1YzEuNjA0LTAuMTQ0LDMuMTc0LTAuMzY3LDQuNjY5LTAuNjY1YzAuMTMtMC4wMjYsMC4yNjEtMC4wMzksMC4zOTEtMC4wMzkNCgljMC40NTgsMCwwLjkwNywwLjE1OSwxLjI3LDAuNDU0YzAuNDYzLDAuMzc5LDAuNzMsMC45NDYsMC43MywxLjU0NnYyNS41NTVjMCwwLjk3OS0wLjcwNywxLjgxMy0xLjY3MiwxLjk3NA0KCWMtMi44MzQsMC40NzItNi4wNDEsMC43OTQtOS41MjcsMC45NTdDNDUxLjAxNSwyMzQuNzk4LDQ0Ny43MTgsMjM0Ljg3NCw0NDQuNTQyLDIzNC44NzRMNDQ0LjU0MiwyMzQuODc0eiIvPg0KPHBhdGggb3BhY2l0eT0iMC41IiBmaWxsPSIjRkZGRkZGIiBkPSJNNDYzLjgyNSwxMTEuNTk1djIyLjA3MmgtMjQuMTYxdjU5LjQ3OWMwLDUuNTczLDAuOTI4LDkuMjkyLDIuNzg4LDExLjE0OQ0KCWMxLjg1NiwxLjg1OSw1LjU3NiwyLjc4OCwxMS4xNTIsMi43ODhjMS44NTksMCwzLjYzOC0wLjA3Niw1LjM0My0wLjIzMmMxLjcwMy0wLjE1MiwzLjMzLTAuMzg4LDQuODc4LTAuNjk2djI1LjU1Nw0KCWMtMi43ODgsMC40NjUtNS44ODcsMC43NzMtOS4yOTMsMC45MzFjLTMuNDA3LDAuMTQ5LTYuNzM3LDAuMjMtOS45OSwwLjIzYy01LjExMSwwLTkuOTUzLTAuMzUtMTQuNTIxLTEuMDQ4DQoJYy00LjU3MS0wLjY5NS04LjU5Ny0yLjA0Ny0xMi4wODEtNC4wNjNjLTMuNDg2LTIuMDExLTYuMjM2LTQuODgtOC4yNDgtOC41OTdjLTIuMDE2LTMuNzE0LTMuMDIxLTguNTk1LTMuMDIxLTE0LjYzOXYtNzAuODU5aC0xOS45OA0KCXYtMjIuMDcyaDE5Ljk4Vjc1LjU4M2gzMi45OTJ2MzYuMDEySDQ2My44MjV6Ii8+DQo8cGF0aCBvcGFjaXR5PSIwLjUiIGZpbGw9IiNGRkZGRkYiIGQ9Ik01MTIuNjEzLDIzMy43MTFoLTMyLjk5MWMtMS4xMDQsMC0yLTAuODk2LTItMlYxMTEuNTk0YzAtMS4xMDQsMC44OTYtMS45OTksMi0xLjk5OWgzMS4zNjYNCgljMS4xMDQsMCwyLDAuODk2LDIsMS45OTl2MTUuMDY5YzAuOTY3LTEuNTE2LDIuMDM0LTIuOTc4LDMuMTk5LTQuMzgyYzIuNzU0LTMuMzEyLDUuOTQ5LTYuMTgyLDkuNDk2LTguNTIyDQoJYzMuNTQ1LTIuMzMyLDcuMzg1LTQuMTY5LDExLjQxNS01LjQ2MmM0LjA1Ni0xLjI5OCw4LjMyNy0xLjk1NCwxMi42OTEtMS45NTRjMi4zNDEsMCw0Ljk1MywwLjQxOCw3Ljc2NiwxLjI0Mw0KCWMwLjg1MiwwLjI1LDEuNDM3LDEuMDMyLDEuNDM3LDEuOTJ2MzAuNjdjMCwwLjYtMC4yNjksMS4xNjctMC43MzIsMS41NDdjLTAuMzYxLDAuMjk2LTAuODA4LDAuNDUyLTEuMjY1LDAuNDUyDQoJYy0wLjEzMywwLTAuMjY1LTAuMDEzLTAuMzk4LTAuMDM5Yy0xLjQ4NC0wLjMtMy4yOTktMC41NjUtNS4zOTItMC43ODdjLTIuMDk4LTAuMjI0LTQuMTM2LTAuMzM5LTYuMDYyLTAuMzM5DQoJYy01LjcwNiwwLTEwLjU3MiwwLjk1LTE0LjQ2NywyLjgyM2MtMy44NjIsMS44Ni03LjAxMiw0LjQyOC05LjM2MSw3LjYyOWMtMi4zODksMy4yNjMtNC4xMTUsNy4xMi01LjEyNywxMS40Nw0KCWMtMS4wNDMsNC40NzktMS41NzQsOS40MDktMS41NzQsMTQuNjQ3djU0LjEzMkM1MTQuNjEzLDIzMi44MTUsNTEzLjcxNywyMzMuNzExLDUxMi42MTMsMjMzLjcxMUw1MTIuNjEzLDIzMy43MTF6Ii8+DQo8cGF0aCBvcGFjaXR5PSIwLjUiIGZpbGw9IiNGRkZGRkYiIGQ9Ik01MTAuOTg4LDExMS41OTVWMTMzLjloMC40NjVjMS41NDYtMy43MiwzLjYzNi03LjE2Myw2LjI3Mi0xMC4zNDENCgljMi42MzQtMy4xNzIsNS42NTItNS44ODUsOS4wNi04LjEzMWMzLjQwNS0yLjI0Miw3LjA0Ny0zLjk4NSwxMC45MjMtNS4yMjhjMy44NjgtMS4yMzcsNy44OTgtMS44NTksMTIuMDgxLTEuODU5DQoJYzIuMTY4LDAsNC41NjYsMC4zOSw3LjIwMiwxLjE2M3YzMC42N2MtMS41NTEtMC4zMTItMy40MS0wLjU4NC01LjU3Ni0wLjgxNGMtMi4xNy0wLjIzMy00LjI2LTAuMzUtNi4yNzQtMC4zNQ0KCWMtNi4wNDEsMC0xMS4xNTIsMS4wMS0xNS4zMzIsMy4wMjFjLTQuMTgyLDIuMDE0LTcuNTUsNC43NjEtMTAuMTA3LDguMjQ3Yy0yLjU1NSwzLjQ4Ny00LjM3OSw3LjU1LTUuNDYyLDEyLjE5OA0KCWMtMS4wODMsNC42NDUtMS42MjUsOS42ODItMS42MjUsMTUuMTAydjU0LjEzM2gtMzIuOTkxVjExMS41OTVINTEwLjk4OHoiLz4NCjxwYXRoIG9wYWNpdHk9IjAuNSIgZmlsbD0iI0ZGRkZGRiIgZD0iTTYwMy45MjMsMjMzLjcxMUg1NzAuOTNjLTEuMTA0LDAtMi0wLjg5Ni0yLTJWMTExLjU5NGMwLTEuMTA0LDAuODk2LTEuOTk5LDItMS45OTloMzIuOTk0DQoJYzEuMTA0LDAsMiwwLjg5NiwyLDEuOTk5djEyMC4xMTdDNjA1LjkyMywyMzIuODE1LDYwNS4wMjcsMjMzLjcxMSw2MDMuOTIzLDIzMy43MTFMNjAzLjkyMywyMzMuNzExeiBNNjAzLjkyMyw5NS4wMDZINTcwLjkzDQoJYy0xLjEwNCwwLTItMC44OTYtMi0xLjk5OVY2NS44MjVjMC0xLjEwNCwwLjg5Ni0yLDItMmgzMi45OTRjMS4xMDQsMCwyLDAuODk2LDIsMnYyNy4xODINCglDNjA1LjkyMyw5NC4xMSw2MDUuMDI3LDk1LjAwNiw2MDMuOTIzLDk1LjAwNkw2MDMuOTIzLDk1LjAwNnoiLz4NCjxwYXRoIG9wYWNpdHk9IjAuNSIgZmlsbD0iI0ZGRkZGRiIgZD0iTTU3MC45Myw5My4wMDdWNjUuODI0aDMyLjk5NHYyNy4xODNINTcwLjkzeiBNNjAzLjkyNCwxMTEuNTk1djEyMC4xMTdINTcwLjkzVjExMS41OTUNCglINjAzLjkyNHoiLz4NCjxwYXRoIG9wYWNpdHk9IjAuNSIgZmlsbD0iI0ZGRkZGRiIgZD0iTTc0Mi4xNjMsMjMzLjcxMWgtMzcuNjRjLTAuNjcxLDAtMS4yOTctMC4zMzUtMS42NjctMC44OTZsLTIzLjQyNi0zNS4zNTJsLTIzLjQyNiwzNS4zNTINCgljLTAuMzY5LDAuNTYxLTAuOTk1LDAuODk2LTEuNjY3LDAuODk2aC0zNi45MzhjLTAuNzQxLDAtMS40MjQtMC40MTEtMS43Ny0xLjA2N2MtMC4zNDUtMC42NTQtMC4zLTEuNDQ5LDAuMTE4LTIuMDYxbDQyLjQzNS02Mi4wNTUNCglsLTM4LjcxLTU1Ljc5M2MtMC40MjQtMC42MTMtMC40NzQtMS40MDgtMC4xMjgtMi4wNjljMC4zNDMtMC42NTgsMS4wMjgtMS4wNzEsMS43NzEtMS4wNzFoMzcuNjM2YzAuNjY1LDAsMS4yODcsMC4zMywxLjY1OCwwLjg4Mg0KCWwxOS40NzcsMjguODkzbDE5LjI1NS0yOC44ODRjMC4zNzItMC41NTYsMC45OTYtMC44OTEsMS42NjUtMC44OTFoMzYuNDc1YzAuNzQ2LDAsMS40MywwLjQxNSwxLjc3NiwxLjA3OA0KCWMwLjM0MywwLjY2LDAuMjg5LDEuNDYtMC4xMzksMi4wNzFsLTM4LjY5LDU1LjA4Mmw0My41NzgsNjIuNzQ0YzAuNDI0LDAuNjEsMC40NzQsMS40MDgsMC4xMjgsMi4wNjYNCglDNzQzLjU5MSwyMzMuMjk4LDc0Mi45MDgsMjMzLjcxMSw3NDIuMTYzLDIzMy43MTFMNzQyLjE2MywyMzMuNzExeiIvPg0KPHBhdGggb3BhY2l0eT0iMC41IiBmaWxsPSIjRkZGRkZGIiBkPSJNNjIxLjExNSwxMTEuNTk1aDM3LjYzN2wyMS4xNDQsMzEuMzY1bDIwLjkxMS0zMS4zNjVoMzYuNDc2bC0zOS40OTYsNTYuMjI2bDQ0LjM3Nyw2My44OTINCgloLTM3LjY0bC0yNS4wOTMtMzcuODdsLTI1LjA5NCwzNy44N2gtMzYuOTM4bDQzLjIxMy02My4xOTNMNjIxLjExNSwxMTEuNTk1eiIvPg0KPHBhdGggb3BhY2l0eT0iMC41IiBmaWxsPSIjRkZGRkZGIiBkPSJNNzkxLjMyMiwzNDAuODA5aC0zMi4wMDhjLTEuMTA1LDAtMi0wLjg5Ni0yLTJ2LTcuNzEyYzAtMS4xMDUsMC44OTYtMi4wMDEsMi0yLjAwMWgyMS4xMw0KCVYxMS43MWgtMjEuMTNjLTEuMTA1LDAtMi0wLjg5Ni0yLTJWMmMwLTEuMTA0LDAuODk2LTIsMi0yaDMyLjAwOGMxLjEwNCwwLDIsMC44OTYsMiwydjMzNi44MDkNCglDNzkzLjMyMiwzMzkuOTEzLDc5Mi40MjYsMzQwLjgwOSw3OTEuMzIyLDM0MC44MDlMNzkxLjMyMiwzNDAuODA5eiIvPg0KPHBhdGggb3BhY2l0eT0iMC41IiBmaWxsPSIjRkZGRkZGIiBkPSJNNzgyLjQ0MywzMzEuMDk3VjkuNzExaC0yMy4xM3YtNy43MWgzMi4wMDh2MzM2LjgwN2gtMzIuMDA4di03LjcxMUg3ODIuNDQzeiIvPg0KPHBhdGggZD0iTTEwLjg3NSw5LjcxMXYzMjEuMzg2aDIzLjEzdjcuNzExSDEuOTk5VjIuMDAxaDMyLjAwNnY3LjcxSDEwLjg3NXoiLz4NCjxwYXRoIGQ9Ik05OS45ODgsMTExLjU5NXYxNi4yNjRoMC40NjNjNC4zMzgtNi4xOTEsOS41NjMtMTAuOTk4LDE1LjY4NC0xNC40MDZjNi4xMTctMy40MDIsMTMuMTI5LTUuMTEsMjEuMDI3LTUuMTENCgljNy41ODgsMCwxNC41MjEsMS40NzUsMjAuNzkzLDQuNDE1YzYuMjc0LDIuOTQ1LDExLjAzOCw4LjEzMSwxNC4yOTEsMTUuNTY3YzMuNTYtNS4yNjUsOC40LTkuOTEzLDE0LjUyMS0xMy45NA0KCWM2LjExNy00LjAyNSwxMy4zNTgtNi4wNDIsMjEuNzI0LTYuMDQyYzYuMzUxLDAsMTIuMjM0LDAuNzc2LDE3LjY2LDIuMzI1YzUuNDE4LDEuNTQ5LDEwLjA2NSw0LjAyNywxMy45MzgsNy40MzQNCgljMy44NjksMy40MSw2Ljg4OSw3Ljg2Myw5LjA2MiwxMy4zNTdjMi4xNjcsNS41MDQsMy4yNTMsMTIuMTIyLDMuMjUzLDE5Ljg2OXY4MC4zODVIMjE5LjQxdi02OC4wNzQNCgljMC00LjAyNS0wLjE1NC03LjgyLTAuNDY1LTExLjM4NWMtMC4zMTMtMy41Ni0xLjE2MS02LjY1Ni0yLjU1NS05LjI5M2MtMS4zOTUtMi42MzEtMy40NS00LjcyNC02LjE1Ny02LjI3NA0KCWMtMi43MTEtMS41NDMtNi4zOTEtMi4zMjItMTEuMDM3LTIuMzIycy04LjQwMywwLjg5Ni0xMS4yNjksMi42NzFjLTIuODY4LDEuNzg0LTUuMTEyLDQuMTA5LTYuNzM3LDYuOTcxDQoJYy0xLjYyNiwyLjg2OS0yLjcxMSw2LjEyLTMuMjUyLDkuNzYyYy0wLjU0NSwzLjYzOC0wLjgxNCw3LjMxOC0wLjgxNCwxMS4wMzV2NjYuOTFoLTMyLjk5MXYtNjcuMzc1YzAtMy41NjItMC4wODEtNy4wODctMC4yMy0xMC41Nw0KCWMtMC4xNTgtMy40ODctMC44MTQtNi43LTEuOTc4LTkuNjQ1Yy0xLjE2Mi0yLjk0LTMuMDk5LTUuMzA0LTUuODA5LTcuMDg4Yy0yLjcxMS0xLjc3NS02LjY5OS0yLjY3MS0xMS45NjUtMi42NzENCgljLTEuNTUxLDAtMy42MDMsMC4zNDktNi4xNTYsMS4wNDhjLTIuNTU2LDAuNjk3LTUuMDM2LDIuMDE2LTcuNDM1LDMuOTQ5Yy0yLjQwNCwxLjkzOC00LjQ1NCw0LjcyNi02LjE1OCw4LjM2Mw0KCWMtMS43MDUsMy42NDItMi41NTYsOC40MDItMi41NTYsMTQuMjg3djY5LjcwMWgtMzIuOTlWMTExLjU5NUg5OS45ODh6Ii8+DQo8cGF0aCBkPSJNMjczLjU0NCwxMjkuMjU1YzMuNDA1LTUuMTEzLDcuNzQ0LTkuMjE1LDEzLjAxMi0xMi4zMTZjNS4yNjQtMy4wOTcsMTEuMTg2LTUuMzAzLDE3Ljc3MS02LjYyMQ0KCWM2LjU4Mi0xLjMxNSwxMy4yMDUtMS45NzYsMTkuODY1LTEuOTc2YzYuMDQyLDAsMTIuMTU4LDAuNDI4LDE4LjM1NCwxLjI3N2M2LjE5NSwwLjg1NSwxMS44NSwyLjUyMiwxNi45NjIsNC45OTcNCgljNS4xMTEsMi40NzcsOS4yOTIsNS45MjYsMTIuNTQ2LDEwLjMzOGMzLjI1Myw0LjQxNCw0Ljg3OSwxMC4yNjIsNC44NzksMTcuNTQzdjYyLjQ5NGMwLDUuNDI4LDAuMzEsMTAuNjExLDAuOTMxLDE1LjU2Nw0KCWMwLjYxNSw0Ljk1OSwxLjcwMSw4LjY3NiwzLjI1MSwxMS4xNTNIMzQ3LjY2Yy0wLjYyMS0xLjg2LTEuMTI2LTMuNzU1LTEuNTExLTUuNjkzYy0wLjM5LTEuOTMzLTAuNjYxLTMuOTA4LTAuODEzLTUuOTIzDQoJYy01LjI2Nyw1LjQyMi0xMS40NjUsOS4yMTctMTguNTg1LDExLjM4NmMtNy4xMjcsMi4xNjMtMTQuNDA3LDMuMjUxLTIxLjg0MiwzLjI1MWMtNS43MzMsMC0xMS4wNzctMC42OTgtMTYuMDMzLTIuMDkNCgljLTQuOTU4LTEuMzk1LTkuMjkzLTMuNTYyLTEzLjAxLTYuNTFjLTMuNzE4LTIuOTM4LTYuNjIyLTYuNjU2LTguNzEzLTExLjE0N3MtMy4xMzgtOS44NC0zLjEzOC0xNi4wMzMNCgljMC02LjgxMywxLjE5OS0xMi40MywzLjYwNC0xNi44NGMyLjM5OS00LjQxNyw1LjQ5NS03LjkzOSw5LjI5NS0xMC41NzVjMy43OTMtMi42MzIsOC4xMjktNC42MDcsMTMuMDEtNS45MjMNCgljNC44NzgtMS4zMTUsOS43OTUtMi4zNTgsMTQuNzUyLTMuMTM3YzQuOTU3LTAuNzcyLDkuODM1LTEuMzkzLDE0LjYzOC0xLjg1N2M0LjgwMS0wLjQ2Niw5LjA2Mi0xLjE2NCwxMi43NzktMi4wOTMNCgljMy43MTgtMC45MjksNi42NTgtMi4yODIsOC44MjktNC4wNjVjMi4xNjUtMS43ODEsMy4xNzItNC4zNzUsMy4wMi03Ljc4NWMwLTMuNTYtMC41OC02LjM4OS0xLjc0Mi04LjQ3OQ0KCWMtMS4xNjEtMi4wOS0yLjcxMS0zLjcxOS00LjY0Ni00Ljg4Yy0xLjkzNy0xLjE2MS00LjE4My0xLjkzNi02LjczNy0yLjMyNWMtMi41NTctMC4zODItNS4zMDktMC41OC04LjI0OC0wLjU4DQoJYy02LjUwNiwwLTExLjYxNywxLjM5NS0xNS4zMzUsNC4xODNjLTMuNzE2LDIuNzg4LTUuODg5LDcuNDM3LTYuNTA2LDEzLjk0aC0zMi45OTENCglDMjY4LjE5OSwxNDAuNzk0LDI3MC4xMzIsMTM0LjM2MywyNzMuNTQ0LDEyOS4yNTV6IE0zMzguNzEzLDE3NS44MzhjLTIuMDksMC42OTYtNC4zMzcsMS4yNzUtNi43MzYsMS43NDENCgljLTIuNDAyLDAuNDY1LTQuOTE4LDAuODUzLTcuNTUxLDEuMTYxYy0yLjYzNSwwLjMxMy01LjI2OCwwLjY5OC03Ljg5OSwxLjE2M2MtMi40OCwwLjQ2MS00LjkxOSwxLjA4Ni03LjMxNywxLjg1Nw0KCWMtMi40MDQsMC43NzktNC40OTUsMS44MjItNi4yNzQsMy4xMzhjLTEuNzg0LDEuMzE3LTMuMjE2LDIuOTg1LTQuMyw0Ljk5NGMtMS4wODUsMi4wMTQtMS42MjYsNC41NzEtMS42MjYsNy42NjgNCgljMCwyLjk0LDAuNTQxLDUuNDIyLDEuNjI2LDcuNDMxYzEuMDg0LDIuMDE3LDIuNTU4LDMuNjA0LDQuNDE2LDQuNzY1czQuMDI1LDEuOTc2LDYuNTA3LDIuNDM4YzIuNDc1LDAuNDY2LDUuMDMxLDAuNjk4LDcuNjY1LDAuNjk4DQoJYzYuNTA1LDAsMTEuNTM3LTEuMDgyLDE1LjEwMy0zLjI1M2MzLjU2MS0yLjE2Niw2LjE5Mi00Ljc2Miw3Ljg5OS03Ljc4NWMxLjcwMi0zLjAxOSwyLjc0OS02LjA3MiwzLjEzNy05LjE3NA0KCWMwLjM4NC0zLjA5NywwLjU4LTUuNTc2LDAuNTgtNy40MzR2LTEyLjMxNkMzNDIuNTQ3LDE3NC4xNzMsMzQwLjgwNSwxNzUuMTQsMzM4LjcxMywxNzUuODM4eiIvPg0KPHBhdGggZD0iTTQ2My44MjUsMTExLjU5NXYyMi4wNzJoLTI0LjE2MXY1OS40NzljMCw1LjU3MywwLjkyOCw5LjI5MiwyLjc4OCwxMS4xNDljMS44NTYsMS44NTksNS41NzYsMi43ODgsMTEuMTUyLDIuNzg4DQoJYzEuODU5LDAsMy42MzgtMC4wNzYsNS4zNDMtMC4yMzJjMS43MDMtMC4xNTIsMy4zMy0wLjM4OCw0Ljg3OC0wLjY5NnYyNS41NTdjLTIuNzg4LDAuNDY1LTUuODg3LDAuNzczLTkuMjkzLDAuOTMxDQoJYy0zLjQwNywwLjE0OS02LjczNywwLjIzLTkuOTksMC4yM2MtNS4xMTEsMC05Ljk1My0wLjM1LTE0LjUyMS0xLjA0OGMtNC41NzEtMC42OTUtOC41OTctMi4wNDctMTIuMDgxLTQuMDYzDQoJYy0zLjQ4Ni0yLjAxMS02LjIzNi00Ljg4LTguMjQ4LTguNTk3Yy0yLjAxNi0zLjcxNC0zLjAyMS04LjU5NS0zLjAyMS0xNC42Mzl2LTcwLjg1OWgtMTkuOTh2LTIyLjA3MmgxOS45OFY3NS41ODNoMzIuOTkydjM2LjAxMg0KCUg0NjMuODI1eiIvPg0KPHBhdGggZD0iTTUxMC45ODgsMTExLjU5NVYxMzMuOWgwLjQ2NWMxLjU0Ni0zLjcyLDMuNjM2LTcuMTYzLDYuMjcyLTEwLjM0MWMyLjYzNC0zLjE3Miw1LjY1Mi01Ljg4NSw5LjA2LTguMTMxDQoJYzMuNDA1LTIuMjQyLDcuMDQ3LTMuOTg1LDEwLjkyMy01LjIyOGMzLjg2OC0xLjIzNyw3Ljg5OC0xLjg1OSwxMi4wODEtMS44NTljMi4xNjgsMCw0LjU2NiwwLjM5LDcuMjAyLDEuMTYzdjMwLjY3DQoJYy0xLjU1MS0wLjMxMi0zLjQxLTAuNTg0LTUuNTc2LTAuODE0Yy0yLjE3LTAuMjMzLTQuMjYtMC4zNS02LjI3NC0wLjM1Yy02LjA0MSwwLTExLjE1MiwxLjAxLTE1LjMzMiwzLjAyMQ0KCWMtNC4xODIsMi4wMTQtNy41NSw0Ljc2MS0xMC4xMDcsOC4yNDdjLTIuNTU1LDMuNDg3LTQuMzc5LDcuNTUtNS40NjIsMTIuMTk4Yy0xLjA4Myw0LjY0NS0xLjYyNSw5LjY4Mi0xLjYyNSwxNS4xMDJ2NTQuMTMzaC0zMi45OTENCglWMTExLjU5NUg1MTAuOTg4eiIvPg0KPHBhdGggZD0iTTU3MC45Myw5My4wMDdWNjUuODI0aDMyLjk5NHYyNy4xODNINTcwLjkzeiBNNjAzLjkyNCwxMTEuNTk1djEyMC4xMTdINTcwLjkzVjExMS41OTVINjAzLjkyNHoiLz4NCjxwYXRoIGQ9Ik02MjEuMTE1LDExMS41OTVoMzcuNjM3bDIxLjE0NCwzMS4zNjVsMjAuOTExLTMxLjM2NWgzNi40NzZsLTM5LjQ5Niw1Ni4yMjZsNDQuMzc3LDYzLjg5MmgtMzcuNjRsLTI1LjA5My0zNy44Nw0KCWwtMjUuMDk0LDM3Ljg3aC0zNi45MzhsNDMuMjEzLTYzLjE5M0w2MjEuMTE1LDExMS41OTV6Ii8+DQo8cGF0aCBkPSJNNzgyLjQ0MywzMzEuMDk3VjkuNzExaC0yMy4xM3YtNy43MWgzMi4wMDh2MzM2LjgwN2gtMzIuMDA4di03LjcxMUg3ODIuNDQzeiIvPg0KPC9zdmc+DQo=`, + "base64", +); diff --git a/spec/util/helpers.ts b/spec/util/helpers.ts index f3729e86b..4608fc4d1 100644 --- a/spec/util/helpers.ts +++ b/spec/util/helpers.ts @@ -1,9 +1,13 @@ -export async function waitFor(condition: () => Promise, delay = 100, maxRetries = 10) { - let retries = 0; - while (!await condition() && retries++ < maxRetries) { - await new Promise((r) => setTimeout(r, delay)); - } - if (retries === maxRetries) { - throw Error('Hit retry limit'); - } -} \ No newline at end of file +export async function waitFor( + condition: () => Promise, + delay = 100, + maxRetries = 10, +) { + let retries = 0; + while (!(await condition()) && retries++ < maxRetries) { + await new Promise((r) => setTimeout(r, delay)); + } + if (retries === maxRetries) { + throw Error("Hit retry limit"); + } +} diff --git a/spec/util/homerunner.ts b/spec/util/homerunner.ts index bba889176..e4ffe8cf4 100644 --- a/spec/util/homerunner.ts +++ b/spec/util/homerunner.ts @@ -1,4 +1,9 @@ -import { MatrixClient, MemoryStorageProvider, RustSdkCryptoStorageProvider, RustSdkCryptoStoreType } from "matrix-bot-sdk"; +import { + MatrixClient, + MemoryStorageProvider, + RustSdkCryptoStorageProvider, + RustSdkCryptoStoreType, +} from "matrix-bot-sdk"; import { createHash, createHmac } from "crypto"; import { E2ETestMatrixClient, E2ETestMatrixClientOpts } from "./e2e-test"; import path from "node:path"; @@ -6,100 +11,129 @@ import { createContainers, TestContainerNetwork } from "./containers"; import { TestContainers } from "testcontainers"; export interface TestHomeServer { - url: string, - domain: string, - users: {userId: string, accessToken: string, deviceId: string, client: E2ETestMatrixClient}[] - asToken: string, - hsToken: string, - appPort: number, - containers: TestContainerNetwork, + url: string; + domain: string; + users: { + userId: string; + accessToken: string; + deviceId: string; + client: E2ETestMatrixClient; + }[]; + asToken: string; + hsToken: string; + appPort: number; + containers: TestContainerNetwork; } // Due to a bug with testcontainers, we can't reuse a port twice without *issues*. // This is slightly hacky as it should ensure we never use the same port twice. let incrementalPort = 1; -export async function createHS(localparts: string[] = [], clientOpts: E2ETestMatrixClientOpts, workerId: number, cryptoRootPath?: string): Promise { - // The worker ID is provided to ensure that across different worker processes we still have a unique port. - const appPort = 49600 + (workerId*100) + incrementalPort++; - const containers = await createContainers("hookshot", appPort); +export async function createHS( + localparts: string[] = [], + clientOpts: E2ETestMatrixClientOpts, + workerId: number, + cryptoRootPath?: string, +): Promise { + // The worker ID is provided to ensure that across different worker processes we still have a unique port. + const appPort = 49600 + workerId * 100 + incrementalPort++; + const containers = await createContainers("hookshot", appPort); - // Create users - const rawUsers = await Promise.all(localparts.map(username => registerUser(containers.synapse.baseUrl, { username, admin: false }, containers.synapse.registrationSecret))) + // Create users + const rawUsers = await Promise.all( + localparts.map((username) => + registerUser( + containers.synapse.baseUrl, + { username, admin: false }, + containers.synapse.registrationSecret, + ), + ), + ); - // Skip AS user. - const users = await Promise.all( - rawUsers.map(async ({mxid, client}) => { - const cryptoStore = cryptoRootPath ? new RustSdkCryptoStorageProvider(path.join(cryptoRootPath, mxid), 0) : undefined; - const e2eClient = new E2ETestMatrixClient(clientOpts, containers.synapse.baseUrl, client.accessToken, new MemoryStorageProvider(), cryptoStore); - if (cryptoStore) { - await e2eClient.crypto.prepare(); - } - // Start syncing proactively. - await e2eClient.start(); - return { - userId: mxid, - accessToken: client.accessToken, - deviceId: (await client.getWhoAmI()).device_id!, - client: e2eClient, - } - } - )); + // Skip AS user. + const users = await Promise.all( + rawUsers.map(async ({ mxid, client }) => { + const cryptoStore = cryptoRootPath + ? new RustSdkCryptoStorageProvider(path.join(cryptoRootPath, mxid), 0) + : undefined; + const e2eClient = new E2ETestMatrixClient( + clientOpts, + containers.synapse.baseUrl, + client.accessToken, + new MemoryStorageProvider(), + cryptoStore, + ); + if (cryptoStore) { + await e2eClient.crypto.prepare(); + } + // Start syncing proactively. + await e2eClient.start(); + return { + userId: mxid, + accessToken: client.accessToken, + deviceId: (await client.getWhoAmI()).device_id!, + client: e2eClient, + }; + }), + ); - - return { - users, - url: containers.synapse.baseUrl, - domain: containers.synapse.serverName, - asToken: containers.registration.as_token, - appPort, - hsToken: containers.registration.hs_token, - containers, - }; + return { + users, + url: containers.synapse.baseUrl, + domain: containers.synapse.serverName, + asToken: containers.registration.as_token, + appPort, + hsToken: containers.registration.hs_token, + containers, + }; } -export async function destroyHS( - hs: TestHomeServer -): Promise { - await hs.containers.synapse.stop(); - await hs.containers.redis.stop(); - await hs.containers.network.stop(); +export async function destroyHS(hs: TestHomeServer): Promise { + await hs.containers.synapse.stop(); + await hs.containers.redis.stop(); + await hs.containers.network.stop(); } export async function registerUser( - homeserverUrl: string, - user: { username: string, admin: boolean }, - sharedSecret: string, -): Promise<{mxid: string, client: MatrixClient}> { - const registerUrl: string = (() => { - const url = new URL(homeserverUrl); - url.pathname = '/_synapse/admin/v1/register'; - return url.toString(); - })(); + homeserverUrl: string, + user: { username: string; admin: boolean }, + sharedSecret: string, +): 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'); - const req = await fetch(registerUrl, { method: "POST", body: JSON.stringify( - { - nonce, - username: user.username, - password, - admin: user.admin, - mac: hmac, - } - )}); - const res = await req.json() as {user_id: string, access_token: string}; - return { - mxid: res.user_id, - client: new MatrixClient(homeserverUrl, res.access_token), - }; + 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"); + const req = await fetch(registerUrl, { + method: "POST", + body: JSON.stringify({ + nonce, + username: user.username, + password, + admin: user.admin, + mac: hmac, + }), + }); + const res = (await req.json()) as { user_id: string; access_token: string }; + return { + mxid: res.user_id, + client: new MatrixClient(homeserverUrl, res.access_token), + }; } diff --git a/spec/webhooks.spec.ts b/spec/webhooks.spec.ts index a77ab1d0e..191287579 100644 --- a/spec/webhooks.spec.ts +++ b/spec/webhooks.spec.ts @@ -1,5 +1,17 @@ -import { E2ESetupTestTimeout, E2ETestEnv, E2ETestMatrixClient } from "./util/e2e-test"; -import { describe, test, beforeAll, afterAll, afterEach, beforeEach, expect } from "vitest"; +import { + E2ESetupTestTimeout, + E2ETestEnv, + E2ETestMatrixClient, +} from "./util/e2e-test"; +import { + describe, + test, + beforeAll, + afterAll, + afterEach, + beforeEach, + expect, +} from "vitest"; import { OutboundHookConnection } from "../src/Connections"; import { TextualMessageEventContent } from "matrix-bot-sdk"; import { IncomingHttpHeaders, Server, createServer } from "http"; @@ -8,206 +20,242 @@ import { TEST_FILE } from "./util/fixtures"; import { AddressInfo } from "net"; async function createHttpServer(): Promise { - const server = createServer(); - await new Promise((req) => server.listen(0, () => req())); - return server; + const server = createServer(); + await new Promise((req) => server.listen(0, () => req())); + return server; } -async function createOutboundConnection(user: E2ETestMatrixClient, botMxid: string, roomId: string, server: Server) { - const join = user.waitForRoomJoin({ sender: botMxid, roomId }); - const connectionEvent = user.waitForRoomEvent({ - eventType: OutboundHookConnection.CanonicalEventType, - stateKey: 'test', - sender: botMxid - }); - await user.inviteUser(botMxid, roomId); - await user.setUserPowerLevel(botMxid, roomId, 50); - await join; +async function createOutboundConnection( + user: E2ETestMatrixClient, + botMxid: string, + roomId: string, + server: Server, +) { + const join = user.waitForRoomJoin({ sender: botMxid, roomId }); + const connectionEvent = user.waitForRoomEvent({ + eventType: OutboundHookConnection.CanonicalEventType, + stateKey: "test", + sender: botMxid, + }); + await user.inviteUser(botMxid, roomId); + await user.setUserPowerLevel(botMxid, roomId, 50); + await join; - // Note: Here we create the DM proactively so this works across multiple - // tests. - // Get the DM room so we can get the token. - const dmRoomId = await user.dms.getOrCreateDm(botMxid); + // Note: Here we create the DM proactively so this works across multiple + // tests. + // Get the DM room so we can get the token. + const dmRoomId = await user.dms.getOrCreateDm(botMxid); - const port = (server.address() as AddressInfo).port; + const port = (server.address() as AddressInfo).port; - await user.sendText(roomId, `!hookshot outbound-hook test http://localhost:${port}/test-path`); - // Test the contents of this. - await connectionEvent; + await user.sendText( + roomId, + `!hookshot outbound-hook test http://localhost:${port}/test-path`, + ); + // Test the contents of this. + await connectionEvent; - const msgPromise = user.waitForRoomEvent({ sender: botMxid, eventType: "m.room.message", roomId: dmRoomId }); - const { data: msgData } = await msgPromise; + const msgPromise = user.waitForRoomEvent({ + sender: botMxid, + eventType: "m.room.message", + roomId: dmRoomId, + }); + const { data: msgData } = await msgPromise; - const [_match, token ] = /(.+)<\/code>/.exec((msgData.content as unknown as TextualMessageEventContent).formatted_body ?? "") ?? []; - return token; + const [_match, token] = + /(.+)<\/code>/.exec( + (msgData.content as unknown as TextualMessageEventContent) + .formatted_body ?? "", + ) ?? []; + return token; } -function awaitOutboundWebhook(server: Server): Promise<{headers: IncomingHttpHeaders, files: {name: string, file: Buffer, info: FileInfo}[]}> { - return new Promise<{headers: IncomingHttpHeaders, files: {name: string, file: Buffer, info: FileInfo}[]}>((resolve, reject) => { - server.on("request", (req, res) => { - const bb = busboy({headers: req.headers}); - const files: {name: string, file: Buffer, info: FileInfo}[] = []; - bb.on('file', (name, stream, info) => { - const buffers: Buffer[] = []; - stream.on('data', d => { - buffers.push(d) - }); - stream.once('close', () => { - files.push({name, info, file: Buffer.concat(buffers)}) - }); - }); - - bb.once('close', () => { - res.writeHead(200, { 'Content-Type': 'text/plain' }); - res.end('OK'); - resolve({ - headers: req.headers, - files, - }); - clearTimeout(timer); - }); - - req.pipe(bb); +function awaitOutboundWebhook(server: Server): Promise<{ + headers: IncomingHttpHeaders; + files: { name: string; file: Buffer; info: FileInfo }[]; +}> { + return new Promise<{ + headers: IncomingHttpHeaders; + files: { name: string; file: Buffer; info: FileInfo }[]; + }>((resolve, reject) => { + server.on("request", (req, res) => { + const bb = busboy({ headers: req.headers }); + const files: { name: string; file: Buffer; info: FileInfo }[] = []; + bb.on("file", (name, stream, info) => { + const buffers: Buffer[] = []; + stream.on("data", (d) => { + buffers.push(d); }); - let timer: NodeJS.Timeout; - timer = setTimeout(() => { - reject(new Error("Request did not arrive")); - }, 10000); - }); -} + stream.once("close", () => { + files.push({ name, info, file: Buffer.concat(buffers) }); + }); + }); -describe('OutboundHooks', () => { - let testEnv: E2ETestEnv; - let server: Server; - - beforeAll(async () => { - const webhooksPort = 9500 + E2ETestEnv.workerId; - testEnv = await E2ETestEnv.createTestEnv({ - matrixLocalparts: ['user'], - config: { - generic: { - enabled: true, - outbound: true, - urlPrefix: `http://localhost:${webhooksPort}` - }, - listeners: [{ - port: webhooksPort, - bindAddress: '0.0.0.0', - // Bind to the SAME listener to ensure we don't have conflicts. - resources: ['webhooks'], - }], - } + bb.once("close", () => { + res.writeHead(200, { "Content-Type": "text/plain" }); + res.end("OK"); + resolve({ + headers: req.headers, + files, }); - await testEnv.setUp(); - }, E2ESetupTestTimeout); - - afterEach(() => { - try { - server.close(); - } catch { - // Ignore, we tried. - } - }) - - afterAll(async () => { - await testEnv?.tearDown(); + clearTimeout(timer); + }); + + req.pipe(bb); }); + let timer: NodeJS.Timeout; + timer = setTimeout(() => { + reject(new Error("Request did not arrive")); + }, 10000); + }); +} + +describe("OutboundHooks", () => { + let testEnv: E2ETestEnv; + let server: Server; - test('should be able to create a new webhook and push an event.', async () => { - const user = testEnv.getUser('user'); - const roomId = await user.createRoom({ name: 'My Test Webhooks room'}); - server = await createHttpServer(); - const gotWebhookRequest = awaitOutboundWebhook(server); - const token = await createOutboundConnection(user, testEnv.botMxid, roomId, server); - - const eventId = await user.sendText(roomId, 'hello!'); - const { headers, files } = await gotWebhookRequest; - expect(headers['x-matrix-hookshot-roomid']).toEqual(roomId); - expect(headers['x-matrix-hookshot-eventid']).toEqual(eventId); - expect(headers['x-matrix-hookshot-token']).toEqual(token); - - // And check the JSON payload - const [event, media] = files; - expect(event.name).toEqual('event'); - expect(event.info.mimeType).toEqual('application/json'); - expect(event.info.filename).toEqual('event_data.json'); - const eventJson = JSON.parse(event.file.toString('utf-8')); - - // Check that the content looks sane. - expect(eventJson.room_id).toEqual(roomId); - expect(eventJson.event_id).toEqual(eventId); - expect(eventJson.sender).toEqual(await user.getUserId()); - expect(eventJson.content.body).toEqual('hello!'); - - // No media should be present. - expect(media).toBeUndefined(); + beforeAll(async () => { + const webhooksPort = 9500 + E2ETestEnv.workerId; + testEnv = await E2ETestEnv.createTestEnv({ + matrixLocalparts: ["user"], + config: { + generic: { + enabled: true, + outbound: true, + urlPrefix: `http://localhost:${webhooksPort}`, + }, + listeners: [ + { + port: webhooksPort, + bindAddress: "0.0.0.0", + // Bind to the SAME listener to ensure we don't have conflicts. + resources: ["webhooks"], + }, + ], + }, }); + await testEnv.setUp(); + }, E2ESetupTestTimeout); + + afterEach(() => { + try { + server.close(); + } catch { + // Ignore, we tried. + } + }); + + afterAll(async () => { + await testEnv?.tearDown(); + }); + + test("should be able to create a new webhook and push an event.", async () => { + const user = testEnv.getUser("user"); + const roomId = await user.createRoom({ name: "My Test Webhooks room" }); + server = await createHttpServer(); + const gotWebhookRequest = awaitOutboundWebhook(server); + const token = await createOutboundConnection( + user, + testEnv.botMxid, + roomId, + server, + ); - test('should be able to create a new webhook and push a media attachment.', async () => { - const user = testEnv.getUser('user'); - const roomId = await user.createRoom({ name: 'My Test Webhooks room'}); - server = await createHttpServer(); - const gotWebhookRequest = awaitOutboundWebhook(server); - const token = await createOutboundConnection(user, testEnv.botMxid, roomId, server); - - const mxcUrl = await user.uploadContent(TEST_FILE, 'image/svg+xml', "matrix.svg"); - await user.sendMessage(roomId, { - url: mxcUrl, - msgtype: "m.file", - body: "matrix.svg", - }) - const { files } = await gotWebhookRequest; - const [event, media] = files; - expect(event.info.mimeType).toEqual('application/json'); - expect(event.info.filename).toEqual('event_data.json'); - const eventJson = JSON.parse(event.file.toString('utf-8')); - expect(eventJson.content.body).toEqual('matrix.svg'); - - - expect(media.info.mimeType).toEqual('image/svg+xml'); - expect(media.info.filename).toEqual('matrix.svg'); - expect(media.file).toEqual(TEST_FILE); + const eventId = await user.sendText(roomId, "hello!"); + const { headers, files } = await gotWebhookRequest; + expect(headers["x-matrix-hookshot-roomid"]).toEqual(roomId); + expect(headers["x-matrix-hookshot-eventid"]).toEqual(eventId); + expect(headers["x-matrix-hookshot-token"]).toEqual(token); + + // And check the JSON payload + const [event, media] = files; + expect(event.name).toEqual("event"); + expect(event.info.mimeType).toEqual("application/json"); + expect(event.info.filename).toEqual("event_data.json"); + const eventJson = JSON.parse(event.file.toString("utf-8")); + + // Check that the content looks sane. + expect(eventJson.room_id).toEqual(roomId); + expect(eventJson.event_id).toEqual(eventId); + expect(eventJson.sender).toEqual(await user.getUserId()); + expect(eventJson.content.body).toEqual("hello!"); + + // No media should be present. + expect(media).toBeUndefined(); + }); + + test("should be able to create a new webhook and push a media attachment.", async () => { + const user = testEnv.getUser("user"); + const roomId = await user.createRoom({ name: "My Test Webhooks room" }); + server = await createHttpServer(); + const gotWebhookRequest = awaitOutboundWebhook(server); + const token = await createOutboundConnection( + user, + testEnv.botMxid, + roomId, + server, + ); + + const mxcUrl = await user.uploadContent( + TEST_FILE, + "image/svg+xml", + "matrix.svg", + ); + await user.sendMessage(roomId, { + url: mxcUrl, + msgtype: "m.file", + body: "matrix.svg", }); + const { files } = await gotWebhookRequest; + const [event, media] = files; + expect(event.info.mimeType).toEqual("application/json"); + expect(event.info.filename).toEqual("event_data.json"); + const eventJson = JSON.parse(event.file.toString("utf-8")); + expect(eventJson.content.body).toEqual("matrix.svg"); + + expect(media.info.mimeType).toEqual("image/svg+xml"); + expect(media.info.filename).toEqual("matrix.svg"); + expect(media.file).toEqual(TEST_FILE); + }); + + // TODO: This requires us to support Redis in test conditions, as encryption is not possible + // in hookshot without it at the moment. + + // it.only('should be able to create a new webhook and push an encrypted media attachment.', async () => { + // const user = testEnv.getUser('user'); + // const roomId = await user.createRoom({ name: 'My Test Webhooks room', initial_state: [{ + // content: { + // "algorithm": "m.megolm.v1.aes-sha2" + // }, + // state_key: "", + // type: "m.room.encryption" + // }]}); + // await createOutboundConnection(user, testEnv.botMxid, roomId); + // const gotWebhookRequest = awaitOutboundWebhook(); + + // const encrypted = await user.crypto.encryptMedia(Buffer.from(TEST_FILE)); + // const mxc = await user.uploadContent(TEST_FILE); + // await user.sendMessage(roomId, { + // msgtype: "m.image", + // body: "matrix.svg", + // info: { + // mimetype: "image/svg+xml", + // }, + // file: { + // url: mxc, + // ...encrypted.file, + // }, + // }); + + // const { headers, files } = await gotWebhookRequest; + // const [event, media] = files; + // expect(event.info.mimeType).toEqual('application/json'); + // expect(event.info.filename).toEqual('event_data.json'); + // const eventJson = JSON.parse(event.file.toString('utf-8')); + // expect(eventJson.content.body).toEqual('matrix.svg'); - // TODO: This requires us to support Redis in test conditions, as encryption is not possible - // in hookshot without it at the moment. - - // it.only('should be able to create a new webhook and push an encrypted media attachment.', async () => { - // const user = testEnv.getUser('user'); - // const roomId = await user.createRoom({ name: 'My Test Webhooks room', initial_state: [{ - // content: { - // "algorithm": "m.megolm.v1.aes-sha2" - // }, - // state_key: "", - // type: "m.room.encryption" - // }]}); - // await createOutboundConnection(user, testEnv.botMxid, roomId); - // const gotWebhookRequest = awaitOutboundWebhook(); - - // const encrypted = await user.crypto.encryptMedia(Buffer.from(TEST_FILE)); - // const mxc = await user.uploadContent(TEST_FILE); - // await user.sendMessage(roomId, { - // msgtype: "m.image", - // body: "matrix.svg", - // info: { - // mimetype: "image/svg+xml", - // }, - // file: { - // url: mxc, - // ...encrypted.file, - // }, - // }); - - // const { headers, files } = await gotWebhookRequest; - // const [event, media] = files; - // expect(event.info.mimeType).toEqual('application/json'); - // expect(event.info.filename).toEqual('event_data.json'); - // const eventJson = JSON.parse(event.file.toString('utf-8')); - // expect(eventJson.content.body).toEqual('matrix.svg'); - - - // expect(media.info.mimeType).toEqual('image/svg+xml'); - // expect(media.info.filename).toEqual('matrix.svg'); - // expect(media.file).toEqual(TEST_FILE); - // }); + // expect(media.info.mimeType).toEqual('image/svg+xml'); + // expect(media.info.filename).toEqual('matrix.svg'); + // expect(media.file).toEqual(TEST_FILE); + // }); }); diff --git a/spec/widgets.spec.ts b/spec/widgets.spec.ts index cdb606504..76a6b9795 100644 --- a/spec/widgets.spec.ts +++ b/spec/widgets.spec.ts @@ -2,36 +2,43 @@ import { E2ESetupTestTimeout, E2ETestEnv } from "./util/e2e-test"; import { describe, test, beforeAll, afterAll, expect } from "vitest"; import { getBridgeApi } from "./util/bridge-api"; -describe('Widgets', () => { - let testEnv: E2ETestEnv; +describe("Widgets", () => { + let testEnv: E2ETestEnv; - beforeAll(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); - - afterAll(() => { - return testEnv?.tearDown(); + beforeAll(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); + + afterAll(() => { + return testEnv?.tearDown(); + }); - test('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", - }); + test("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", }); + }); }); diff --git a/src/AdminRoom.ts b/src/AdminRoom.ts index 009c3958e..b43594625 100644 --- a/src/AdminRoom.ts +++ b/src/AdminRoom.ts @@ -1,10 +1,27 @@ import "reflect-metadata"; -import { AdminAccountData, AdminRoomCommandHandler, Category } from "./AdminRoomCommandHandler"; -import { botCommand, compileBotCommands, handleCommand, BotCommands, HelpFunction } from "./BotCommands"; +import { + AdminAccountData, + AdminRoomCommandHandler, + Category, +} from "./AdminRoomCommandHandler"; +import { + botCommand, + compileBotCommands, + handleCommand, + BotCommands, + HelpFunction, +} from "./BotCommands"; import { BridgeConfig, BridgePermissionLevel } from "./config/Config"; -import { BridgeRoomState, BridgeRoomStateGitHub } from "./Widgets/BridgeWidgetInterface"; +import { + BridgeRoomState, + BridgeRoomStateGitHub, +} from "./Widgets/BridgeWidgetInterface"; import { Endpoints } from "@octokit/types"; -import { GitHubDiscussionSpace, GitHubIssueConnection, GitHubRepoConnection } from "./Connections"; +import { + GitHubDiscussionSpace, + GitHubIssueConnection, + GitHubRepoConnection, +} from "./Connections"; import { ConnectionManager } from "./ConnectionManager"; import { FormatUtil } from "./FormatUtil"; import { GetUserResponse } from "./Gitlab/Types"; @@ -13,545 +30,825 @@ import { GithubGraphQLClient } from "./github/GithubInstance"; import { GitLabClient } from "./Gitlab/Client"; import { Intent } from "matrix-bot-sdk"; import { JiraBotCommands } from "./jira/AdminCommands"; -import { NotifFilter, NotificationFilterStateContent } from "./NotificationFilters"; +import { + NotifFilter, + NotificationFilterStateContent, +} from "./NotificationFilters"; import { ProjectsListResponseData } from "./github/Types"; import { UserTokenStore } from "./tokens/UserTokenStore"; import { Logger } from "matrix-appservice-bridge"; import markdown from "markdown-it"; import { OpenProjectBotCommands } from "./openproject/adminCommands"; -type ProjectsListForRepoResponseData = Endpoints["GET /repos/{owner}/{repo}/projects"]["response"]; -type ProjectsListForUserResponseData = Endpoints["GET /users/{username}/projects"]["response"]; +type ProjectsListForRepoResponseData = + Endpoints["GET /repos/{owner}/{repo}/projects"]["response"]; +type ProjectsListForUserResponseData = + Endpoints["GET /users/{username}/projects"]["response"]; const md = new markdown(); -const log = new Logger('AdminRoom'); +const log = new Logger("AdminRoom"); export const LEGACY_BRIDGE_ROOM_TYPE = "uk.half-shot.matrix-github.room"; -export const LEGACY_BRIDGE_NOTIF_TYPE = "uk.half-shot.matrix-github.notif_state"; -export const LEGACY_BRIDGE_GITLAB_NOTIF_TYPE = "uk.half-shot.matrix-github.gitlab.notif_state"; +export const LEGACY_BRIDGE_NOTIF_TYPE = + "uk.half-shot.matrix-github.notif_state"; +export const LEGACY_BRIDGE_GITLAB_NOTIF_TYPE = + "uk.half-shot.matrix-github.gitlab.notif_state"; export const BRIDGE_ROOM_TYPE = "uk.half-shot.matrix-hookshot.github.room"; -export const BRIDGE_NOTIF_TYPE = "uk.half-shot.matrix-hookshot.github.notif_state"; -export const BRIDGE_GITLAB_NOTIF_TYPE = "uk.half-shot.matrix-hookshot.gitlab.notif_state"; +export const BRIDGE_NOTIF_TYPE = + "uk.half-shot.matrix-hookshot.github.notif_state"; +export const BRIDGE_GITLAB_NOTIF_TYPE = + "uk.half-shot.matrix-hookshot.gitlab.notif_state"; export class AdminRoom extends AdminRoomCommandHandler { - public static helpMessage: HelpFunction; - protected widgetAccessToken = `abcdef`; - static botCommands: BotCommands; - - protected pendingOAuthState: string|null = null; - public readonly notifFilter: NotifFilter; - - constructor(roomId: string, - data: AdminAccountData, - notifContent: NotificationFilterStateContent, - botIntent: Intent, - tokenStore: UserTokenStore, - config: BridgeConfig, - private connectionManager: ConnectionManager, - ) { - super(botIntent, roomId, tokenStore, config, data); - this.notifFilter = new NotifFilter(notifContent); + public static helpMessage: HelpFunction; + protected widgetAccessToken = `abcdef`; + static botCommands: BotCommands; + + protected pendingOAuthState: string | null = null; + public readonly notifFilter: NotifFilter; + + constructor( + roomId: string, + data: AdminAccountData, + notifContent: NotificationFilterStateContent, + botIntent: Intent, + tokenStore: UserTokenStore, + config: BridgeConfig, + private connectionManager: ConnectionManager, + ) { + super(botIntent, roomId, tokenStore, config, data); + this.notifFilter = new NotifFilter(notifContent); + } + + public get oauthState() { + return this.pendingOAuthState; + } + + public verifyWidgetAccessToken(token: string) { + return this.widgetAccessToken === token; + } + + public notificationsEnabled(type: string, instanceName?: string) { + if (type === "github") { + return this.data.github?.notifications?.enabled; } - - public get oauthState() { - return this.pendingOAuthState; + return ( + type === "gitlab" && + !!instanceName && + this.data.gitlab && + this.data.gitlab[instanceName].notifications.enabled + ); + } + + public notificationsParticipating(type: "github" | "gitlab") { + if (type !== "github") { + return false; } - - public verifyWidgetAccessToken(token: string) { - return this.widgetAccessToken === token; + return this.data.github?.notifications?.participating || false; + } + + public clearOauthState() { + this.pendingOAuthState = null; + } + + public async getNotifSince(type: "github" | "gitlab", instanceName?: string) { + if (type === "gitlab") { + try { + let accountData: null | { since: number } = + await this.botIntent.underlyingClient.getSafeRoomAccountData( + `${BRIDGE_GITLAB_NOTIF_TYPE}:${instanceName}`, + this.roomId, + null, + ); + if (!accountData) { + accountData = + await this.botIntent.underlyingClient.getSafeRoomAccountData( + `${LEGACY_BRIDGE_GITLAB_NOTIF_TYPE}:${instanceName}`, + this.roomId, + { since: 0 }, + ); + } + return accountData.since; + } catch { + // TODO: We should look at this error. + return 0; + } } - - public notificationsEnabled(type: string, instanceName?: string) { - if (type === "github") { - return this.data.github?.notifications?.enabled; - } - return (type === "gitlab" && - !!instanceName && - this.data.gitlab && - this.data.gitlab[instanceName].notifications.enabled + try { + let accountData: null | { since: number } = + await this.botIntent.underlyingClient.getSafeRoomAccountData( + BRIDGE_NOTIF_TYPE, + this.roomId, + { since: 0 }, ); + if (!accountData) { + accountData = + await this.botIntent.underlyingClient.getSafeRoomAccountData( + `${LEGACY_BRIDGE_NOTIF_TYPE}:${instanceName}`, + this.roomId, + { since: 0 }, + ); + } + log.debug(`Got ${type} notif-since to ${accountData.since}`); + return accountData.since; + } catch (ex) { + log.warn(`Filed to get ${type} notif-since`, ex); + // TODO: We should look at this error. + return 0; } - - public notificationsParticipating(type: "github"|"gitlab") { - if (type !== "github") { - return false; - } - return this.data.github?.notifications?.participating || false; - } - - public clearOauthState() { - this.pendingOAuthState = null; - } - - public async getNotifSince(type: "github"|"gitlab", instanceName?: string) { - if (type === "gitlab") { - try { - let accountData: null|{since: number} = await this.botIntent.underlyingClient.getSafeRoomAccountData( - `${BRIDGE_GITLAB_NOTIF_TYPE}:${instanceName}`, this.roomId, null - ); - if (!accountData) { - accountData = await this.botIntent.underlyingClient.getSafeRoomAccountData( - `${LEGACY_BRIDGE_GITLAB_NOTIF_TYPE}:${instanceName}`, this.roomId, { since: 0 } - ); - } - return accountData.since; - } catch { - // TODO: We should look at this error. - return 0; - } - } - try { - let accountData: null|{since: number} = await this.botIntent.underlyingClient.getSafeRoomAccountData(BRIDGE_NOTIF_TYPE, this.roomId, { since: 0 }); - if (!accountData) { - accountData = await this.botIntent.underlyingClient.getSafeRoomAccountData( - `${LEGACY_BRIDGE_NOTIF_TYPE}:${instanceName}`, this.roomId, { since: 0 } - ); - } - log.debug(`Got ${type} notif-since to ${accountData.since}`); - return accountData.since; - } catch (ex) { - log.warn(`Filed to get ${type} notif-since`, ex); - // TODO: We should look at this error. - return 0; - } + } + + public async setNotifSince( + type: "github" | "gitlab", + since: number, + instanceName?: string, + ) { + log.debug(`Updated ${type} notif-since to ${since}`); + if (type === "gitlab") { + return this.botIntent.underlyingClient.setRoomAccountData( + `${BRIDGE_GITLAB_NOTIF_TYPE}:${instanceName}`, + this.roomId, + { + since, + }, + ); } - - public async setNotifSince(type: "github"|"gitlab", since: number, instanceName?: string) { - log.debug(`Updated ${type} notif-since to ${since}`); - if (type === "gitlab") { - return this.botIntent.underlyingClient.setRoomAccountData( - `${BRIDGE_GITLAB_NOTIF_TYPE}:${instanceName}`, - this.roomId, { - since, - }); - } - return this.botIntent.underlyingClient.setRoomAccountData(BRIDGE_NOTIF_TYPE, this.roomId, { - since, - }); + return this.botIntent.underlyingClient.setRoomAccountData( + BRIDGE_NOTIF_TYPE, + this.roomId, + { + since, + }, + ); + } + + @botCommand("help", { help: "This help text" }) + public async helpCommand() { + const enabledCategories = [ + this.config.github ? Category.Github : "", + this.config.gitlab ? Category.Gitlab : "", + this.config.jira ? Category.Jira : "", + this.config.openProject ? Category.OpenProject : "", + this.config.github && this.canAdminConnections("github") + ? Category.ConnectionManagement + : "", + ]; + return this.botIntent.sendEvent( + this.roomId, + AdminRoom.helpMessage(undefined, enabledCategories), + ); + } + + @botCommand("disconnect", { + help: "Remove a connection", + requiredArgs: ["roomId", "id"], + category: Category.ConnectionManagement, + }) + public async disconnect(roomId: string, id: string) { + if (!this.canAdminConnections("github")) { + await this.sendNotice("Insufficient permissions."); + return; } - @botCommand("help", { help: "This help text" }) - public async helpCommand() { - const enabledCategories = [ - this.config.github ? Category.Github : "", - this.config.gitlab ? Category.Gitlab : "", - this.config.jira ? Category.Jira : "", - this.config.openProject ? Category.OpenProject : "", - (this.config.github && this.canAdminConnections('github')) ? Category.ConnectionManagement : '', - ]; - return this.botIntent.sendEvent(this.roomId, AdminRoom.helpMessage(undefined, enabledCategories)); + // it's stupid that we need roomId -- shouldn't `id` identify the connection? + const conn = this.connectionManager.getConnectionById(roomId, id); + if (!conn) { + await this.sendNotice("Connection not found"); + return; } - - @botCommand("disconnect", { help: "Remove a connection", requiredArgs: ['roomId', 'id'], category: Category.ConnectionManagement }) - public async disconnect(roomId: string, id: string) { - if (!this.canAdminConnections('github')) { - await this.sendNotice("Insufficient permissions."); - return; - } - - // it's stupid that we need roomId -- shouldn't `id` identify the connection? - const conn = this.connectionManager.getConnectionById(roomId, id); - if (!conn) { - await this.sendNotice("Connection not found"); - return; - } - try { - await this.connectionManager.purgeConnection(conn.roomId, conn.connectionId); - await this.sendNotice('Connection removed successfully'); - } catch (err: unknown) { - log.debug(`Failed to purge connection: ${err}`); - await this.sendNotice('Connection could not be removed: see debug logs for details'); - } + try { + await this.connectionManager.purgeConnection( + conn.roomId, + conn.connectionId, + ); + await this.sendNotice("Connection removed successfully"); + } catch (err: unknown) { + log.debug(`Failed to purge connection: ${err}`); + await this.sendNotice( + "Connection could not be removed: see debug logs for details", + ); } - - @botCommand("github notifications toggle", { help: "Toggle enabling/disabling GitHub notifications in this room", category: Category.Github}) - public async setGitHubNotificationsStateToggle() { - const newData = await this.saveAccountData((data) => { - return { - ...data, - github: { - notifications: { - enabled: !(data.github?.notifications?.enabled ?? false), - participating: data.github?.notifications?.participating, - }, - }, - }; - }); - await this.sendNotice(`${newData.github?.notifications?.enabled ? "En" : "Dis"}abled GitHub notifcations`); - } - - @botCommand("github notifications filter participating", {help: "Toggle enabling/disabling GitHub notifications in this room", category: Category.Github}) - private async setGitHubNotificationsStateParticipating() { - const newData = await this.saveAccountData((data) => { - if (!data.github?.notifications?.enabled) { - throw Error('Notifications are not enabled.') - } - const oldState = data.github?.notifications?.participating ?? false; - return { - ...data, - github: { - notifications: { - participating: !oldState, - enabled: true, - }, - }, - }; - }); - if (newData.github?.notifications?.participating) { - return this.sendNotice(`Filtering for events you are participating in.`); - } - return this.sendNotice(`Showing all events.`); + } + + @botCommand("github notifications toggle", { + help: "Toggle enabling/disabling GitHub notifications in this room", + category: Category.Github, + }) + public async setGitHubNotificationsStateToggle() { + const newData = await this.saveAccountData((data) => { + return { + ...data, + github: { + notifications: { + enabled: !(data.github?.notifications?.enabled ?? false), + participating: data.github?.notifications?.participating, + }, + }, + }; + }); + await this.sendNotice( + `${newData.github?.notifications?.enabled ? "En" : "Dis"}abled GitHub notifcations`, + ); + } + + @botCommand("github notifications filter participating", { + help: "Toggle enabling/disabling GitHub notifications in this room", + category: Category.Github, + }) + private async setGitHubNotificationsStateParticipating() { + const newData = await this.saveAccountData((data) => { + if (!data.github?.notifications?.enabled) { + throw Error("Notifications are not enabled."); + } + const oldState = data.github?.notifications?.participating ?? false; + return { + ...data, + github: { + notifications: { + participating: !oldState, + enabled: true, + }, + }, + }; + }); + if (newData.github?.notifications?.participating) { + return this.sendNotice(`Filtering for events you are participating in.`); } - - @botCommand("github notifications", {help: "Show the current notification settings", category: Category.Github}) - public async getGitHubNotificationsState() { - if (!this.notificationsEnabled("github")) { - return this.sendNotice(`Notifications are disabled.`); - } - return this.sendNotice(`Notifications are enabled, ${this.notificationsParticipating("github") ? "Showing only events you are particiapting in." : "Showing all events."}`); + return this.sendNotice(`Showing all events.`); + } + + @botCommand("github notifications", { + help: "Show the current notification settings", + category: Category.Github, + }) + public async getGitHubNotificationsState() { + if (!this.notificationsEnabled("github")) { + return this.sendNotice(`Notifications are disabled.`); } - - @botCommand("github list-connections", {help: "List currently bridged Github rooms", category: Category.ConnectionManagement}) - public async listGithubConnections() { - if (!this.config.github) { - return this.sendNotice("The bridge is not configured with GitHub support."); - } - - if (!this.canAdminConnections('github')) { - await this.sendNotice("Insufficient permissions."); - return; - } - - const connections = { - repos: this.connectionManager.getAllConnectionsOfType(GitHubRepoConnection), - issues: this.connectionManager.getAllConnectionsOfType(GitHubIssueConnection), - discussions: this.connectionManager.getAllConnectionsOfType(GitHubDiscussionSpace), - }; - - const reposFormatted = connections.repos.map(c => ` - ${c.org}/${c.repo} (ID: \`${c.connectionId}\`, Room: \`${c.roomId}\`)`).join('\n'); - const issuesFormatted = connections.issues.map(c => ` - ${c.org}/${c.repo}/${c.issueNumber} (Room: \`${c.roomId}\`, ID: \`${c.connectionId}\`)`).join('\n'); - const discussionsFormatted = connections.discussions.map(c => ` - ${c.owner}/${c.repo} (Room: \`${c.roomId}\`, ID: \`${c.connectionId}\`)`).join('\n'); - - const content = [ - connections.repos.length > 0 ? `Repositories:\n${reposFormatted}` : '', - connections.issues.length > 0 ? `Issues:\n${issuesFormatted}` : '', - connections.discussions.length > 0 ? `Discussions:\n${discussionsFormatted}` : '', - ].filter(v => !!v).join('\n\n') || 'No GitHub bridges'; - - return this.botIntent.sendEvent(this.roomId,{ - msgtype: "m.notice", - body: content, - formatted_body: md.render(content), - format: "org.matrix.custom.html" - }); + return this.sendNotice( + `Notifications are enabled, ${this.notificationsParticipating("github") ? "Showing only events you are particiapting in." : "Showing all events."}`, + ); + } + + @botCommand("github list-connections", { + help: "List currently bridged Github rooms", + category: Category.ConnectionManagement, + }) + public async listGithubConnections() { + if (!this.config.github) { + return this.sendNotice( + "The bridge is not configured with GitHub support.", + ); } - @botCommand("github project list-for-user", {help: "List GitHub projects for a user", optionalArgs:['user', 'repo'], category: Category.Github}) - private async listGitHubProjectsForUser(username?: string, repo?: string) { - if (!this.config.github) { - return this.sendNotice("The bridge is not configured with GitHub support."); - } - const octokit = await this.tokenStore.getOctokitForUser(this.userId); - if (!octokit) { - return this.sendNotice("You can not list projects without an account."); - } - - if (!username) { - const me = await octokit.users.getAuthenticated(); - // TODO: Fix - username = me.data.login; - } - - let res: ProjectsListResponseData; - try { - if (repo) { - res = (await octokit.projects.listForRepo({ - repo, - owner: username, - })).data; - } - res = (await octokit.projects.listForUser({ - username, - })).data; - } catch (ex) { - log.warn(`Failed to fetch projects:`, ex); - return this.sendNotice(`Failed to fetch projects due to an error. See logs for details.`); - } - - const content = `Projects for ${username}:\n${FormatUtil.projectListing(res)}\n`; - return this.botIntent.sendEvent(this.roomId,{ - msgtype: "m.notice", - body: content, - formatted_body: md.render(content), - format: "org.matrix.custom.html" - }); + if (!this.canAdminConnections("github")) { + await this.sendNotice("Insufficient permissions."); + return; } - @botCommand("github project list-for-org", {help: "List GitHub projects for an org", requiredArgs: ['org'], optionalArgs: ['repo'], category: Category.Github}) - private async listGitHubProjectsForOrg(org: string, repo?: string) { - if (!this.config.github) { - return this.sendNotice("The bridge is not configured with GitHub support."); - } - const octokit = await this.tokenStore.getOctokitForUser(this.userId); - if (!octokit) { - return this.sendNotice("You can not list projects without an account."); - } - - let res: ProjectsListForUserResponseData|ProjectsListForRepoResponseData; - try { - if (repo) { - res = (await octokit.projects.listForRepo({ - repo, - owner: org, - })); - } - res = (await octokit.projects.listForOrg({ - org, - })); - } catch (ex) { - if (ex.status === 404) { - return this.sendNotice(`${repo ? "Repository" : "Org"} does not exist.`); - } - log.warn(`Failed to fetch projects:`, ex); - return this.sendNotice(`Failed to fetch projects due to an error. See logs for details.`); - } - - const content = `Projects for ${org}:\n` + res.data.map(r => ` - ${FormatUtil.projectListing([r])}\n`).join("\n"); - return this.botIntent.sendEvent(this.roomId,{ - msgtype: "m.notice", - body: content, - formatted_body: md.render(content), - format: "org.matrix.custom.html" - }); + const connections = { + repos: + this.connectionManager.getAllConnectionsOfType(GitHubRepoConnection), + issues: this.connectionManager.getAllConnectionsOfType( + GitHubIssueConnection, + ), + discussions: this.connectionManager.getAllConnectionsOfType( + GitHubDiscussionSpace, + ), + }; + + const reposFormatted = connections.repos + .map( + (c) => + ` - ${c.org}/${c.repo} (ID: \`${c.connectionId}\`, Room: \`${c.roomId}\`)`, + ) + .join("\n"); + const issuesFormatted = connections.issues + .map( + (c) => + ` - ${c.org}/${c.repo}/${c.issueNumber} (Room: \`${c.roomId}\`, ID: \`${c.connectionId}\`)`, + ) + .join("\n"); + const discussionsFormatted = connections.discussions + .map( + (c) => + ` - ${c.owner}/${c.repo} (Room: \`${c.roomId}\`, ID: \`${c.connectionId}\`)`, + ) + .join("\n"); + + const content = + [ + connections.repos.length > 0 ? `Repositories:\n${reposFormatted}` : "", + connections.issues.length > 0 ? `Issues:\n${issuesFormatted}` : "", + connections.discussions.length > 0 + ? `Discussions:\n${discussionsFormatted}` + : "", + ] + .filter((v) => !!v) + .join("\n\n") || "No GitHub bridges"; + + return this.botIntent.sendEvent(this.roomId, { + msgtype: "m.notice", + body: content, + formatted_body: md.render(content), + format: "org.matrix.custom.html", + }); + } + + @botCommand("github project list-for-user", { + help: "List GitHub projects for a user", + optionalArgs: ["user", "repo"], + category: Category.Github, + }) + private async listGitHubProjectsForUser(username?: string, repo?: string) { + if (!this.config.github) { + return this.sendNotice( + "The bridge is not configured with GitHub support.", + ); } - - @botCommand("github project open", {help: "Open a GitHub project as a room", requiredArgs: ['projectId'], category: Category.Github}) - private async openProject(projectId: string) { - if (!this.config.github) { - return this.sendNotice("The bridge is not configured with GitHub support."); - } - const octokit = await this.tokenStore.getOctokitForUser(this.userId); - if (!octokit) { - return this.sendNotice("You can not list projects without an account."); - } - - try { - const project = await octokit.projects.get({ - project_id: parseInt(projectId, 10), - }); - return this.emit('open.project', project.data); - } catch (ex) { - if (ex.status === 404) { - return this.sendNotice('Project does not exist.'); - } - log.warn(`Failed to fetch project:`, ex); - return this.sendNotice(`Failed to fetch project due to an error. See logs for details.`); - } + const octokit = await this.tokenStore.getOctokitForUser(this.userId); + if (!octokit) { + return this.sendNotice("You can not list projects without an account."); } - @botCommand("github discussion open", {help: "Open a discussion room", requiredArgs: ['owner', 'repo', 'number'], category: Category.Github}) - private async listDiscussions(owner: string, repo: string, numberStr: string) { - const number = parseInt(numberStr); - if (!this.config.github) { - return this.sendNotice("The bridge is not configured with GitHub support."); - } - const octokit = await this.tokenStore.getOctokitForUser(this.userId); - if (!octokit) { - return this.sendNotice("You can not list projects without an account."); - } - try { - const graphql = new GithubGraphQLClient(octokit); - const discussions = await graphql.getDiscussionByNumber(owner, repo, number); - return this.emit('open.discussion', owner, repo, discussions); - } catch (ex) { - if (ex.status === 404) { - return this.sendNotice('Discussion does not exist.'); - } - log.warn(`Failed to fetch discussions:`, ex); - return this.sendNotice(`Failed to fetch discussions due to an error. See logs for details.`); - } - + if (!username) { + const me = await octokit.users.getAuthenticated(); + // TODO: Fix + username = me.data.login; } - /* GitLab commands */ - - @botCommand("gitlab open issue", {help: "Open or join a issue room for GitLab", requiredArgs: ['url'], category: Category.Gitlab}) - private async gitLabOpenIssue(url: string) { - if (!this.config.gitlab) { - return this.sendNotice("The bridge is not configured with GitLab support."); - } - - const urlResult = GitLabClient.splitUrlIntoParts(this.config.gitlab.instances, url); - if (!urlResult) { - return this.sendNotice("The URL was not understood. The URL must be an issue and the bridge must know of the GitLab instance."); - } - const [instanceName, parts] = urlResult; - const instance = this.config.gitlab.instances[instanceName]; - const client = await this.tokenStore.getGitLabForUser(this.userId, instance.url); - if (!client) { - return this.sendNotice("You have not added a personal access token for GitLab."); - } - const getIssueOpts = { - issue: parseInt(parts[parts.length-1]), - projects: parts.slice(0, parts.length-3), // Remove - and /issues - }; - log.info(`Looking up issue ${instanceName} ${getIssueOpts.projects.join("/")}#${getIssueOpts.issue}`); - const issue = await client.issues.get(getIssueOpts); - return this.emit('open.gitlab-issue', getIssueOpts, issue, instanceName, instance); + let res: ProjectsListResponseData; + try { + if (repo) { + res = ( + await octokit.projects.listForRepo({ + repo, + owner: username, + }) + ).data; + } + res = ( + await octokit.projects.listForUser({ + username, + }) + ).data; + } catch (ex) { + log.warn(`Failed to fetch projects:`, ex); + return this.sendNotice( + `Failed to fetch projects due to an error. See logs for details.`, + ); } - @botCommand("gitlab personaltoken", {help: "Set your personal access token for GitLab", requiredArgs: ['instanceName', 'accessToken'], category: Category.Gitlab, permissionLevel: BridgePermissionLevel.login}) - public async setGitLabPersonalAccessToken(instanceName: string, accessToken: string) { - let me: GetUserResponse; - if (!this.config.gitlab) { - return this.sendNotice("The bridge is not configured with GitLab support."); - } - const instance = this.config.gitlab.instances[instanceName]; - if (!instance) { - return this.sendNotice("The bridge is not configured for this GitLab instance. Ask your administrator for a list of instances."); - } - try { - const client = new GitLabClient(instance.url, accessToken); - me = await client.user(); - } catch (ex) { - log.error("Gitlab auth error:", ex); - return this.sendNotice("Could not authenticate with GitLab. Is your token correct?"); - } - await this.sendNotice(`Connected as ${me.username}. Token stored.`); - return this.tokenStore.storeUserToken("gitlab", this.userId, accessToken, instance.url); + const content = `Projects for ${username}:\n${FormatUtil.projectListing(res)}\n`; + return this.botIntent.sendEvent(this.roomId, { + msgtype: "m.notice", + body: content, + formatted_body: md.render(content), + format: "org.matrix.custom.html", + }); + } + + @botCommand("github project list-for-org", { + help: "List GitHub projects for an org", + requiredArgs: ["org"], + optionalArgs: ["repo"], + category: Category.Github, + }) + private async listGitHubProjectsForOrg(org: string, repo?: string) { + if (!this.config.github) { + return this.sendNotice( + "The bridge is not configured with GitHub support.", + ); } - - @botCommand("gitlab hastoken", {help: "Check if you have a token stored for GitLab", requiredArgs: ["instanceName"], category: Category.Gitlab, permissionLevel: BridgePermissionLevel.login}) - public async gitlabHasPersonalToken(instanceName: string) { - if (!this.config.gitlab) { - return this.sendNotice("The bridge is not configured with GitLab support."); - } - const instance = this.config.gitlab.instances[instanceName]; - if (!instance) { - return this.sendNotice("The bridge is not configured for this GitLab instance."); - } - const result = await this.tokenStore.getUserToken("gitlab", this.userId, instance.url); - if (result === null) { - return this.sendNotice("You do not currently have a token stored."); - } - return this.sendNotice("A token is stored for your GitLab account."); + const octokit = await this.tokenStore.getOctokitForUser(this.userId); + if (!octokit) { + return this.sendNotice("You can not list projects without an account."); } - @botCommand("filters list", { help: "List your saved filters", category: Category.Github, permissionLevel: BridgePermissionLevel.login }) - public async getFilters() { - if (this.notifFilter.empty) { - return this.sendNotice("You do not currently have any filters."); - } - const filterText = Object.entries(this.notifFilter.filters).map(([name, value]) => { - const userText = value.users.length ? `users: ${value.users.join("|")}` : ''; - const reposText = value.repos.length ? `users: ${value.repos.join("|")}` : ''; - const orgsText = value.orgs.length ? `users: ${value.orgs.join("|")}` : ''; - return `${name}: ${userText} ${reposText} ${orgsText}` - }).join("\n"); - const enabledForInvites = [...this.notifFilter.forInvites].join(', '); - const enabledForNotifications = [...this.notifFilter.forNotifications].join(', '); - return this.sendNotice(`Your filters:\n ${filterText}\nEnabled for automatic room invites: ${enabledForInvites}\nEnabled for notifications: ${enabledForNotifications}`); - } - - @botCommand("filters set", { help: "Create (or update) a filter. You can use 'orgs:', 'users:' or 'repos:' as filter parameters.", requiredArgs: ["name", "...parameters"], category: Category.Github, permissionLevel: BridgePermissionLevel.login }) - public async setFilter(name: string, ...parameters: string[]) { - const orgs = parameters.filter(param => param.toLowerCase().startsWith("orgs:")).map(param => param.toLowerCase().substring("orgs:".length).split(",")).flat(); - const users = parameters.filter(param => param.toLowerCase().startsWith("users:")).map(param => param.toLowerCase().substring("users:".length).split(",")).flat(); - const repos = parameters.filter(param => param.toLowerCase().startsWith("repos:")).map(param => param.toLowerCase().substring("repos:".length).split(",")).flat(); - if (orgs.length + users.length + repos.length === 0) { - return this.sendNotice("You must specify some filter options like 'orgs:matrix-org,half-shot', 'users:Half-Shot' or 'repos:matrix-hookshot'."); - } - this.notifFilter.setFilter(name, { - orgs, - users, - repos, + let res: ProjectsListForUserResponseData | ProjectsListForRepoResponseData; + try { + if (repo) { + res = await octokit.projects.listForRepo({ + repo, + owner: org, }); - await this.botIntent.underlyingClient.sendStateEvent(this.roomId, NotifFilter.StateType, "", this.notifFilter.getStateContent()); - return this.sendNotice(`Stored new filter "${name}". You can now apply the filter by saying 'filters notifications toggle $name'.`); + } + res = await octokit.projects.listForOrg({ + org, + }); + } catch (ex) { + if (ex.status === 404) { + return this.sendNotice( + `${repo ? "Repository" : "Org"} does not exist.`, + ); + } + log.warn(`Failed to fetch projects:`, ex); + return this.sendNotice( + `Failed to fetch projects due to an error. See logs for details.`, + ); } - @botCommand("filters notifications toggle", { help: "Apply a filter as a whitelist to your notifications", requiredArgs: ["name"], category: Category.Github, permissionLevel: BridgePermissionLevel.login }) - public async setFiltersNotificationsToggle(name: string) { - if (!this.notifFilter.filters[name]) { - return this.sendNotice(`Filter "${name}" doesn't exist.`); - } - if (this.notifFilter.forNotifications.has(name)) { - this.notifFilter.forNotifications.delete(name); - await this.sendNotice(`Filter "${name}" disabled for notifications.`); - } else { - this.notifFilter.forNotifications.add(name); - await this.sendNotice(`Filter "${name}" enabled for notifications.`); - } - return this.botIntent.underlyingClient.sendStateEvent(this.roomId, NotifFilter.StateType, "", this.notifFilter.getStateContent()); + const content = + `Projects for ${org}:\n` + + res.data.map((r) => ` - ${FormatUtil.projectListing([r])}\n`).join("\n"); + return this.botIntent.sendEvent(this.roomId, { + msgtype: "m.notice", + body: content, + formatted_body: md.render(content), + format: "org.matrix.custom.html", + }); + } + + @botCommand("github project open", { + help: "Open a GitHub project as a room", + requiredArgs: ["projectId"], + category: Category.Github, + }) + private async openProject(projectId: string) { + if (!this.config.github) { + return this.sendNotice( + "The bridge is not configured with GitHub support.", + ); + } + const octokit = await this.tokenStore.getOctokitForUser(this.userId); + if (!octokit) { + return this.sendNotice("You can not list projects without an account."); } - private canAdminConnections(service: string): boolean { - return this.config.checkPermission(this.userId, service, BridgePermissionLevel.admin); + try { + const project = await octokit.projects.get({ + project_id: parseInt(projectId, 10), + }); + return this.emit("open.project", project.data); + } catch (ex) { + if (ex.status === 404) { + return this.sendNotice("Project does not exist."); + } + log.warn(`Failed to fetch project:`, ex); + return this.sendNotice( + `Failed to fetch project due to an error. See logs for details.`, + ); + } + } + + @botCommand("github discussion open", { + help: "Open a discussion room", + requiredArgs: ["owner", "repo", "number"], + category: Category.Github, + }) + private async listDiscussions( + owner: string, + repo: string, + numberStr: string, + ) { + const number = parseInt(numberStr); + if (!this.config.github) { + return this.sendNotice( + "The bridge is not configured with GitHub support.", + ); + } + const octokit = await this.tokenStore.getOctokitForUser(this.userId); + if (!octokit) { + return this.sendNotice("You can not list projects without an account."); + } + try { + const graphql = new GithubGraphQLClient(octokit); + const discussions = await graphql.getDiscussionByNumber( + owner, + repo, + number, + ); + return this.emit("open.discussion", owner, repo, discussions); + } catch (ex) { + if (ex.status === 404) { + return this.sendNotice("Discussion does not exist."); + } + log.warn(`Failed to fetch discussions:`, ex); + return this.sendNotice( + `Failed to fetch discussions due to an error. See logs for details.`, + ); + } + } + + /* GitLab commands */ + + @botCommand("gitlab open issue", { + help: "Open or join a issue room for GitLab", + requiredArgs: ["url"], + category: Category.Gitlab, + }) + private async gitLabOpenIssue(url: string) { + if (!this.config.gitlab) { + return this.sendNotice( + "The bridge is not configured with GitLab support.", + ); } - private async saveAccountData(updateFn: (record: AdminAccountData) => AdminAccountData) { - let oldData: AdminAccountData|null = await this.botIntent.underlyingClient.getSafeRoomAccountData(BRIDGE_ROOM_TYPE, this.roomId, null); - if (!oldData) { - oldData = await this.botIntent.underlyingClient.getSafeRoomAccountData(LEGACY_BRIDGE_ROOM_TYPE, this.roomId, {admin_user: this.userId}); - } - const newData = updateFn(oldData); - await this.botIntent.underlyingClient.setRoomAccountData(BRIDGE_ROOM_TYPE, this.roomId, newData); - this.emit("settings.changed", this, newData, oldData); - this.data = newData; - return newData; - } - - public async handleCommand(eventId: string, command: string) { - const checkPermission = (service: string, level: BridgePermissionLevel) => this.config.checkPermission(this.userId, service, level); - const result = await handleCommand(this.userId, command, AdminRoom.botCommands, this, checkPermission); - if (!result.handled) { - return this.sendNotice("Command not understood. For a list of commands, try `help`."); - } - - if ("humanError" in result) { - return this.sendNotice(`Failed to handle command: ${result.humanError}`); - } else if ("error" in result) { - // Error is not something we want to print to the user. - return this.sendNotice(`Failed to handle command: A unknown failure occurred. Contact your bridge admin`); - } - return null; + const urlResult = GitLabClient.splitUrlIntoParts( + this.config.gitlab.instances, + url, + ); + if (!urlResult) { + return this.sendNotice( + "The URL was not understood. The URL must be an issue and the bridge must know of the GitLab instance.", + ); + } + const [instanceName, parts] = urlResult; + const instance = this.config.gitlab.instances[instanceName]; + const client = await this.tokenStore.getGitLabForUser( + this.userId, + instance.url, + ); + if (!client) { + return this.sendNotice( + "You have not added a personal access token for GitLab.", + ); + } + const getIssueOpts = { + issue: parseInt(parts[parts.length - 1]), + projects: parts.slice(0, parts.length - 3), // Remove - and /issues + }; + log.info( + `Looking up issue ${instanceName} ${getIssueOpts.projects.join("/")}#${getIssueOpts.issue}`, + ); + const issue = await client.issues.get(getIssueOpts); + return this.emit( + "open.gitlab-issue", + getIssueOpts, + issue, + instanceName, + instance, + ); + } + + @botCommand("gitlab personaltoken", { + help: "Set your personal access token for GitLab", + requiredArgs: ["instanceName", "accessToken"], + category: Category.Gitlab, + permissionLevel: BridgePermissionLevel.login, + }) + public async setGitLabPersonalAccessToken( + instanceName: string, + accessToken: string, + ) { + let me: GetUserResponse; + if (!this.config.gitlab) { + return this.sendNotice( + "The bridge is not configured with GitLab support.", + ); + } + const instance = this.config.gitlab.instances[instanceName]; + if (!instance) { + return this.sendNotice( + "The bridge is not configured for this GitLab instance. Ask your administrator for a list of instances.", + ); + } + try { + const client = new GitLabClient(instance.url, accessToken); + me = await client.user(); + } catch (ex) { + log.error("Gitlab auth error:", ex); + return this.sendNotice( + "Could not authenticate with GitLab. Is your token correct?", + ); + } + await this.sendNotice(`Connected as ${me.username}. Token stored.`); + return this.tokenStore.storeUserToken( + "gitlab", + this.userId, + accessToken, + instance.url, + ); + } + + @botCommand("gitlab hastoken", { + help: "Check if you have a token stored for GitLab", + requiredArgs: ["instanceName"], + category: Category.Gitlab, + permissionLevel: BridgePermissionLevel.login, + }) + public async gitlabHasPersonalToken(instanceName: string) { + if (!this.config.gitlab) { + return this.sendNotice( + "The bridge is not configured with GitLab support.", + ); + } + const instance = this.config.gitlab.instances[instanceName]; + if (!instance) { + return this.sendNotice( + "The bridge is not configured for this GitLab instance.", + ); + } + const result = await this.tokenStore.getUserToken( + "gitlab", + this.userId, + instance.url, + ); + if (result === null) { + return this.sendNotice("You do not currently have a token stored."); + } + return this.sendNotice("A token is stored for your GitLab account."); + } + + @botCommand("filters list", { + help: "List your saved filters", + category: Category.Github, + permissionLevel: BridgePermissionLevel.login, + }) + public async getFilters() { + if (this.notifFilter.empty) { + return this.sendNotice("You do not currently have any filters."); + } + const filterText = Object.entries(this.notifFilter.filters) + .map(([name, value]) => { + const userText = value.users.length + ? `users: ${value.users.join("|")}` + : ""; + const reposText = value.repos.length + ? `users: ${value.repos.join("|")}` + : ""; + const orgsText = value.orgs.length + ? `users: ${value.orgs.join("|")}` + : ""; + return `${name}: ${userText} ${reposText} ${orgsText}`; + }) + .join("\n"); + const enabledForInvites = [...this.notifFilter.forInvites].join(", "); + const enabledForNotifications = [...this.notifFilter.forNotifications].join( + ", ", + ); + return this.sendNotice( + `Your filters:\n ${filterText}\nEnabled for automatic room invites: ${enabledForInvites}\nEnabled for notifications: ${enabledForNotifications}`, + ); + } + + @botCommand("filters set", { + help: "Create (or update) a filter. You can use 'orgs:', 'users:' or 'repos:' as filter parameters.", + requiredArgs: ["name", "...parameters"], + category: Category.Github, + permissionLevel: BridgePermissionLevel.login, + }) + public async setFilter(name: string, ...parameters: string[]) { + const orgs = parameters + .filter((param) => param.toLowerCase().startsWith("orgs:")) + .map((param) => param.toLowerCase().substring("orgs:".length).split(",")) + .flat(); + const users = parameters + .filter((param) => param.toLowerCase().startsWith("users:")) + .map((param) => param.toLowerCase().substring("users:".length).split(",")) + .flat(); + const repos = parameters + .filter((param) => param.toLowerCase().startsWith("repos:")) + .map((param) => param.toLowerCase().substring("repos:".length).split(",")) + .flat(); + if (orgs.length + users.length + repos.length === 0) { + return this.sendNotice( + "You must specify some filter options like 'orgs:matrix-org,half-shot', 'users:Half-Shot' or 'repos:matrix-hookshot'.", + ); + } + this.notifFilter.setFilter(name, { + orgs, + users, + repos, + }); + await this.botIntent.underlyingClient.sendStateEvent( + this.roomId, + NotifFilter.StateType, + "", + this.notifFilter.getStateContent(), + ); + return this.sendNotice( + `Stored new filter "${name}". You can now apply the filter by saying 'filters notifications toggle $name'.`, + ); + } + + @botCommand("filters notifications toggle", { + help: "Apply a filter as a whitelist to your notifications", + requiredArgs: ["name"], + category: Category.Github, + permissionLevel: BridgePermissionLevel.login, + }) + public async setFiltersNotificationsToggle(name: string) { + if (!this.notifFilter.filters[name]) { + return this.sendNotice(`Filter "${name}" doesn't exist.`); + } + if (this.notifFilter.forNotifications.has(name)) { + this.notifFilter.forNotifications.delete(name); + await this.sendNotice(`Filter "${name}" disabled for notifications.`); + } else { + this.notifFilter.forNotifications.add(name); + await this.sendNotice(`Filter "${name}" enabled for notifications.`); + } + return this.botIntent.underlyingClient.sendStateEvent( + this.roomId, + NotifFilter.StateType, + "", + this.notifFilter.getStateContent(), + ); + } + + private canAdminConnections(service: string): boolean { + return this.config.checkPermission( + this.userId, + service, + BridgePermissionLevel.admin, + ); + } + + private async saveAccountData( + updateFn: (record: AdminAccountData) => AdminAccountData, + ) { + let oldData: AdminAccountData | null = + await this.botIntent.underlyingClient.getSafeRoomAccountData( + BRIDGE_ROOM_TYPE, + this.roomId, + null, + ); + if (!oldData) { + oldData = await this.botIntent.underlyingClient.getSafeRoomAccountData( + LEGACY_BRIDGE_ROOM_TYPE, + this.roomId, + { admin_user: this.userId }, + ); + } + const newData = updateFn(oldData); + await this.botIntent.underlyingClient.setRoomAccountData( + BRIDGE_ROOM_TYPE, + this.roomId, + newData, + ); + this.emit("settings.changed", this, newData, oldData); + this.data = newData; + return newData; + } + + public async handleCommand(eventId: string, command: string) { + const checkPermission = (service: string, level: BridgePermissionLevel) => + this.config.checkPermission(this.userId, service, level); + const result = await handleCommand( + this.userId, + command, + AdminRoom.botCommands, + this, + checkPermission, + ); + if (!result.handled) { + return this.sendNotice( + "Command not understood. For a list of commands, try `help`.", + ); } - public async getBridgeState(): Promise { - const gitHubEnabled = !!this.config.github; - const github: BridgeRoomStateGitHub = { - enabled: false, - tokenStored: false, - identity: null, - notifications: false, + if ("humanError" in result) { + return this.sendNotice(`Failed to handle command: ${result.humanError}`); + } else if ("error" in result) { + // Error is not something we want to print to the user. + return this.sendNotice( + `Failed to handle command: A unknown failure occurred. Contact your bridge admin`, + ); + } + return null; + } + + public async getBridgeState(): Promise { + const gitHubEnabled = !!this.config.github; + const github: BridgeRoomStateGitHub = { + enabled: false, + tokenStored: false, + identity: null, + notifications: false, + }; + if (gitHubEnabled) { + const octokit = await this.tokenStore.getOctokitForUser(this.userId); + try { + const identity = await octokit?.users.getAuthenticated(); + github.enabled = true; + github.tokenStored = !!octokit; + github.identity = { + name: identity?.data.login || null, + avatarUrl: identity?.data.avatar_url || null, }; - if (gitHubEnabled) { - const octokit = await this.tokenStore.getOctokitForUser(this.userId); - try { - const identity = await octokit?.users.getAuthenticated(); - github.enabled = true; - github.tokenStored = !!octokit; - github.identity = { - name: identity?.data.login || null, - avatarUrl: identity?.data.avatar_url || null, - }; - github.notifications = this.notificationsEnabled("github") || false; - } catch (ex) { - log.warn(`Failed to get user identity: ${ex}`); - } - } - - return { - title: "Admin Room", - github, - } + github.notifications = this.notificationsEnabled("github") || false; + } catch (ex) { + log.warn(`Failed to get user identity: ${ex}`); + } } - public toString() { - return `AdminRoom(${this.roomId}, ${this.userId})`; - } + return { + title: "Admin Room", + github, + }; + } + + public toString() { + return `AdminRoom(${this.roomId}, ${this.userId})`; + } } -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const res = compileBotCommands(AdminRoom.prototype as any, GitHubBotCommands.prototype as any, JiraBotCommands.prototype as any, OpenProjectBotCommands.prototype as any); + +const res = compileBotCommands( + AdminRoom.prototype as any, + GitHubBotCommands.prototype as any, + JiraBotCommands.prototype as any, + OpenProjectBotCommands.prototype as any, +); AdminRoom.helpMessage = res.helpMessage; AdminRoom.botCommands = res.botCommands; diff --git a/src/AdminRoomCommandHandler.ts b/src/AdminRoomCommandHandler.ts index f2a011667..1f9321560 100644 --- a/src/AdminRoomCommandHandler.ts +++ b/src/AdminRoomCommandHandler.ts @@ -3,54 +3,50 @@ import { Intent } from "matrix-bot-sdk"; import { BridgeConfig } from "./config/Config"; import { UserTokenStore } from "./tokens/UserTokenStore"; - export enum Category { - ConnectionManagement = "Connection Management", - Github = "Github", - Gitlab = "Gitlab", - Jira = "Jira", - OpenProject = "OpenProject", + ConnectionManagement = "Connection Management", + Github = "Github", + Gitlab = "Gitlab", + Jira = "Jira", + OpenProject = "OpenProject", } - export interface AdminAccountData { - admin_user: string; - github?: { - notifications?: { - enabled: boolean; - participating?: boolean; - }; + admin_user: string; + github?: { + notifications?: { + enabled: boolean; + participating?: boolean; }; - gitlab?: { - [instanceUrl: string]: { - notifications: { - enabled: boolean; - } - } - } + }; + gitlab?: { + [instanceUrl: string]: { + notifications: { + enabled: boolean; + }; + }; + }; } - export abstract class AdminRoomCommandHandler extends EventEmitter { - public get accountData() { - return {...this.data}; - } - - public get userId() { - return this.data.admin_user; - } - - constructor( - protected readonly botIntent: Intent, - public readonly roomId: string, - protected tokenStore: UserTokenStore, - protected readonly config: BridgeConfig, - protected data: AdminAccountData, - ) { - super(); - } - public async sendNotice(noticeText: string) { - return this.botIntent.sendText(this.roomId, noticeText, "m.notice"); - } - -} \ No newline at end of file + public get accountData() { + return { ...this.data }; + } + + public get userId() { + return this.data.admin_user; + } + + constructor( + protected readonly botIntent: Intent, + public readonly roomId: string, + protected tokenStore: UserTokenStore, + protected readonly config: BridgeConfig, + protected data: AdminAccountData, + ) { + super(); + } + public async sendNotice(noticeText: string) { + return this.botIntent.sendText(this.roomId, noticeText, "m.notice"); + } +} diff --git a/src/App/BridgeApp.ts b/src/App/BridgeApp.ts index da886efb3..76961b79f 100644 --- a/src/App/BridgeApp.ts +++ b/src/App/BridgeApp.ts @@ -7,87 +7,101 @@ 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 * as Sentry from "@sentry/node"; import { GenericHookConnection } from "../Connections"; import { UserTokenStore } from "../tokens/UserTokenStore"; import { WebhookTransformer } from "../generic/transformer"; -Logger.configure({console: "info"}); +Logger.configure({ console: "info" }); const log = new Logger("App"); -export async function start(config: BridgeConfig, registration: IAppserviceRegistration) { - const listener = new ListenerService(config.listeners); - listener.start(); - Logger.configure({ - console: config.logging.level, - colorize: config.logging.colorize, - json: config.logging.json, - timestampFormat: config.logging.timestampFormat - }); - LogService.setLogger(Logger.botSdkLogger); +export async function start( + config: BridgeConfig, + registration: IAppserviceRegistration, +) { + const listener = new ListenerService(config.listeners); + listener.start(); + Logger.configure({ + console: config.logging.level, + colorize: config.logging.colorize, + json: config.logging.json, + timestampFormat: config.logging.timestampFormat, + }); + LogService.setLogger(Logger.botSdkLogger); - const {appservice, storage} = getAppservice(config, registration); + const { appservice, storage } = getAppservice(config, registration); - if (!config.queue) { - const matrixSender = new MatrixSender(config, appservice); - matrixSender.listen(); - const userNotificationWatcher = new UserNotificationWatcher(config); - userNotificationWatcher.start(); - } + if (!config.queue) { + const matrixSender = new MatrixSender(config, appservice); + matrixSender.listen(); + const userNotificationWatcher = new UserNotificationWatcher(config); + 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.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 WebhookTransformer.initialiseQuickJS(); - } + if (config.generic?.allowJsTransformationFunctions) { + await WebhookTransformer.initialiseQuickJS(); + } - const botUsersManager = new BotUsersManager(config, appservice); + const botUsersManager = new BotUsersManager(config, appservice); - const tokenStore = await UserTokenStore.fromKeyPath(config.passFile , appservice.botIntent, config); - const bridgeApp = new Bridge(config, tokenStore, listener, appservice, storage, botUsersManager); + const tokenStore = await UserTokenStore.fromKeyPath( + config.passFile, + appservice.botIntent, + config, + ); + const bridgeApp = new Bridge( + config, + tokenStore, + listener, + appservice, + storage, + botUsersManager, + ); - return { - appservice, - bridgeApp, - storage, - listener, - }; + return { + appservice, + bridgeApp, + storage, + listener, + }; } 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, storage } = await start(config, registration); - process.once("SIGTERM", () => { - log.error("Got SIGTERM"); - listener.stop(); - bridgeApp.stop(); - // Don't care to await this, as the process is about to end - storage.disconnect?.(); - }); - await bridgeApp.start(); - listener.finaliseListeners(); + 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, storage } = await start(config, registration); + process.once("SIGTERM", () => { + log.error("Got SIGTERM"); + listener.stop(); + bridgeApp.stop(); + // Don't care to await this, as the process is about to end + storage.disconnect?.(); + }); + 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); - }); + 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); + }); } diff --git a/src/App/GithubWebhookApp.ts b/src/App/GithubWebhookApp.ts index 34d5aa7b9..2eb518b10 100644 --- a/src/App/GithubWebhookApp.ts +++ b/src/App/GithubWebhookApp.ts @@ -6,41 +6,40 @@ import Metrics from "../Metrics"; import { ListenerService } from "../ListenerService"; import { LogService } from "matrix-bot-sdk"; - const log = new Logger("App"); async function start() { - const configFile = process.argv[2] || "./config.yml"; - const config = await BridgeConfig.parseConfig(configFile, process.env); - Logger.configure({ - console: config.logging.level, - colorize: config.logging.colorize, - json: config.logging.json, - timestampFormat: config.logging.timestampFormat - }); - LogService.setLogger(Logger.botSdkLogger); - const listener = new ListenerService(config.listeners); - listener.start(); - if (config.metrics) { - if (!config.metrics.port) { - log.warn(`Not running metrics for service, no port specified`); - } else { - listener.bindResource('metrics', Metrics.expressRouter); - } + const configFile = process.argv[2] || "./config.yml"; + const config = await BridgeConfig.parseConfig(configFile, process.env); + Logger.configure({ + console: config.logging.level, + colorize: config.logging.colorize, + json: config.logging.json, + timestampFormat: config.logging.timestampFormat, + }); + LogService.setLogger(Logger.botSdkLogger); + const listener = new ListenerService(config.listeners); + listener.start(); + if (config.metrics) { + if (!config.metrics.port) { + log.warn(`Not running metrics for service, no port specified`); + } else { + listener.bindResource("metrics", Metrics.expressRouter); } - const webhookHandler = new Webhooks(config); - listener.bindResource('webhooks', webhookHandler.expressRouter); - listener.finaliseListeners(); - const userWatcher = new UserNotificationWatcher(config); - userWatcher.start(); - process.once("SIGTERM", () => { - log.error("Got SIGTERM"); - webhookHandler.stop(); - listener.stop(); - userWatcher.stop(); - }); + } + const webhookHandler = new Webhooks(config); + listener.bindResource("webhooks", webhookHandler.expressRouter); + listener.finaliseListeners(); + const userWatcher = new UserNotificationWatcher(config); + userWatcher.start(); + process.once("SIGTERM", () => { + log.error("Got SIGTERM"); + webhookHandler.stop(); + listener.stop(); + userWatcher.stop(); + }); } start().catch((ex) => { - log.error("GithubWebhookApp encountered an error and has stopped:", ex); + log.error("GithubWebhookApp encountered an error and has stopped:", ex); }); diff --git a/src/App/MatrixSenderApp.ts b/src/App/MatrixSenderApp.ts index 9e7847f64..b977ad7f1 100644 --- a/src/App/MatrixSenderApp.ts +++ b/src/App/MatrixSenderApp.ts @@ -6,42 +6,41 @@ import { ListenerService } from "../ListenerService"; import { LogService } from "matrix-bot-sdk"; import { getAppservice } from "../appservice"; - 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); - Logger.configure({ - console: config.logging.level, - colorize: config.logging.colorize, - json: config.logging.json, - timestampFormat: config.logging.timestampFormat - }); - LogService.setLogger(Logger.botSdkLogger); - const listener = new ListenerService(config.listeners); - listener.start(); - const {appservice, storage} = getAppservice(config, registration); - const sender = new MatrixSender(config, appservice); - if (config.metrics) { - if (!config.metrics.port) { - log.warn(`Not running metrics for service, no port specified`); - } else { - listener.bindResource('metrics', Metrics.expressRouter); - } + 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); + Logger.configure({ + console: config.logging.level, + colorize: config.logging.colorize, + json: config.logging.json, + timestampFormat: config.logging.timestampFormat, + }); + LogService.setLogger(Logger.botSdkLogger); + const listener = new ListenerService(config.listeners); + listener.start(); + const { appservice, storage } = getAppservice(config, registration); + const sender = new MatrixSender(config, appservice); + if (config.metrics) { + if (!config.metrics.port) { + log.warn(`Not running metrics for service, no port specified`); + } else { + listener.bindResource("metrics", Metrics.expressRouter); } - listener.finaliseListeners(); - sender.listen(); - process.once("SIGTERM", () => { - log.error("Got SIGTERM"); - sender.stop(); - listener.stop(); - storage.disconnect?.(); - }); + } + listener.finaliseListeners(); + sender.listen(); + process.once("SIGTERM", () => { + log.error("Got SIGTERM"); + sender.stop(); + listener.stop(); + storage.disconnect?.(); + }); } start().catch((ex) => { - log.error("MatrixSenderApp encountered an error and has stopped:", ex); + log.error("MatrixSenderApp encountered an error and has stopped:", ex); }); diff --git a/src/App/ResetCryptoStore.ts b/src/App/ResetCryptoStore.ts index 1fddf1458..1da1026c4 100644 --- a/src/App/ResetCryptoStore.ts +++ b/src/App/ResetCryptoStore.ts @@ -12,82 +12,97 @@ const log = new Logger("ResetCryptoStore"); let bridgeStorage: IBridgeStorageProvider | undefined; 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); - Logger.configure({ - console: config.logging.level, - colorize: config.logging.colorize, - json: config.logging.json, - timestampFormat: config.logging.timestampFormat - }); - LogService.setLogger(Logger.botSdkLogger); + 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); + Logger.configure({ + console: config.logging.level, + colorize: config.logging.colorize, + json: config.logging.json, + timestampFormat: config.logging.timestampFormat, + }); + LogService.setLogger(Logger.botSdkLogger); - const {appservice, storage, cryptoStorage} = getAppservice(config, registration); - bridgeStorage = storage; - if (!cryptoStorage) { - log.info(`Encryption is not enabled in the configuration file "${configFile}", so there is no encryption state to be reset`); - return; - } - - const botUsersManager = new BotUsersManager(config, appservice); + const { appservice, storage, cryptoStorage } = getAppservice( + config, + registration, + ); + bridgeStorage = storage; + if (!cryptoStorage) { + log.info( + `Encryption is not enabled in the configuration file "${configFile}", so there is no encryption state to be reset`, + ); + return; + } - for (const botUser of botUsersManager.botUsers) { - try { - const userStorage = storage.storageForUser?.(botUser.userId); - if (!userStorage) { - log.warn(`No storage for ${botUser.userId}`); - continue; - } - const accessToken = await userStorage?.readValue("accessToken"); - if (!accessToken) { - log.debug(`No access token for ${botUser.userId}: no session to remove`); - continue; - } + const botUsersManager = new BotUsersManager(config, appservice); - const userCryptoStorage = cryptoStorage?.storageForUser(botUser.userId); - if (!userCryptoStorage) { - log.warn(`No crypto storage for ${botUser.userId}`); - continue; - } - const deviceId = await userCryptoStorage?.getDeviceId(); - if (!deviceId) { - log.debug(`No crypto device ID for ${botUser.userId}: no crypto state to remove`); - continue; - } + for (const botUser of botUsersManager.botUsers) { + try { + const userStorage = storage.storageForUser?.(botUser.userId); + if (!userStorage) { + log.warn(`No storage for ${botUser.userId}`); + continue; + } + const accessToken = await userStorage?.readValue("accessToken"); + if (!accessToken) { + log.debug( + `No access token for ${botUser.userId}: no session to remove`, + ); + continue; + } - const client = new MatrixClient(config.bridge.url, accessToken, userStorage, userCryptoStorage); - await client.doRequest("POST", "/_matrix/client/v3/logout", { - user_id: botUser.userId, - "org.matrix.msc3202.device_id": deviceId, - }); - log.info(`Logged out crypto device for ${botUser.userId}`); + const userCryptoStorage = cryptoStorage?.storageForUser(botUser.userId); + if (!userCryptoStorage) { + log.warn(`No crypto storage for ${botUser.userId}`); + continue; + } + const deviceId = await userCryptoStorage?.getDeviceId(); + if (!deviceId) { + log.debug( + `No crypto device ID for ${botUser.userId}: no crypto state to remove`, + ); + continue; + } - try { - await userStorage.storeValue("accessToken", ""); - log.info(`Deleted access token for ${botUser.userId}`); - } catch (ex: unknown) { - log.error(`Failed to delete access token for ${botUser.userId}`, ex); - } + const client = new MatrixClient( + config.bridge.url, + accessToken, + userStorage, + userCryptoStorage, + ); + await client.doRequest("POST", "/_matrix/client/v3/logout", { + user_id: botUser.userId, + "org.matrix.msc3202.device_id": deviceId, + }); + log.info(`Logged out crypto device for ${botUser.userId}`); - } catch (ex: unknown) { - log.error(`Failed to log out crypto device for ${botUser.userId}`, ex); - } + try { + await userStorage.storeValue("accessToken", ""); + log.info(`Deleted access token for ${botUser.userId}`); + } catch (ex: unknown) { + log.error(`Failed to delete access token for ${botUser.userId}`, ex); + } + } catch (ex: unknown) { + log.error(`Failed to log out crypto device for ${botUser.userId}`, ex); } + } - if (config.encryption?.storagePath) { - try { - await rm(config.encryption.storagePath, { recursive: true, force: true }); - log.info("Removed crypto store from disk"); - } catch (ex) { - log.error("Failed to remove crypto store from disk", ex); - } + if (config.encryption?.storagePath) { + try { + await rm(config.encryption.storagePath, { recursive: true, force: true }); + log.info("Removed crypto store from disk"); + } catch (ex) { + log.error("Failed to remove crypto store from disk", ex); } + } } -start().catch((ex) => { +start() + .catch((ex) => { log.error("ResetCryptoStore encountered an error and has stopped:", ex); -}).finally(() => { + }) + .finally(() => { bridgeStorage?.disconnect?.(); -}); + }); diff --git a/src/BotCommands.ts b/src/BotCommands.ts index 76695424a..6f3023523 100644 --- a/src/BotCommands.ts +++ b/src/BotCommands.ts @@ -9,149 +9,204 @@ const stringArgv = import("string-argv"); const md = new markdown(); export const botCommandSymbol = Symbol("botCommandMetadata"); -export function botCommand(prefix: string, helpOrOpts: string|BotCommandOptions, requiredArgs: string[] = [], optionalArgs: string[] = [], includeUserId = false) { - if (typeof helpOrOpts === "string") { - return Reflect.metadata(botCommandSymbol, { - prefix, - help: helpOrOpts, - requiredArgs, - optionalArgs, - includeUserId, - }); - } +export function botCommand( + prefix: string, + helpOrOpts: string | BotCommandOptions, + requiredArgs: string[] = [], + optionalArgs: string[] = [], + includeUserId = false, +) { + if (typeof helpOrOpts === "string") { return Reflect.metadata(botCommandSymbol, { - prefix, - ...helpOrOpts + prefix, + help: helpOrOpts, + requiredArgs, + optionalArgs, + includeUserId, }); + } + return Reflect.metadata(botCommandSymbol, { + prefix, + ...helpOrOpts, + }); } export interface BotCommandOptions { - help: string, - requiredArgs?: string[], - optionalArgs?: string[], - includeUserId?: boolean, - category?: string, - permissionLevel?: BridgePermissionLevel, - permissionService?: string, + help: string; + requiredArgs?: string[]; + optionalArgs?: string[]; + includeUserId?: boolean; + category?: string; + permissionLevel?: BridgePermissionLevel; + permissionService?: string; } - -type BotCommandResult = {status?: boolean, reaction?: string}|undefined; +type BotCommandResult = { status?: boolean; reaction?: string } | undefined; type BotCommandFunction = (...args: string[]) => Promise; -export type BotCommands = {[prefix: string]: {fn: BotCommandFunction} & BotCommandOptions}; -export type HelpFunction = (cmdPrefix?: string, categories?: string[], includeTitles?: boolean) => MatrixMessageContent +export type BotCommands = { + [prefix: string]: { fn: BotCommandFunction } & BotCommandOptions; +}; +export type HelpFunction = ( + cmdPrefix?: string, + categories?: string[], + includeTitles?: boolean, +) => MatrixMessageContent; -export function compileBotCommands(...prototypes: Record[]): {helpMessage: HelpFunction, botCommands: BotCommands} { - const botCommands: BotCommands = {}; - const cmdStrs: {[category: string]: string[]} = {}; - prototypes.forEach(prototype => { - Object.getOwnPropertyNames(prototype).forEach(propertyKey => { - const b = Reflect.getMetadata(botCommandSymbol, prototype, propertyKey); - if (b) { - const category = b.category || "default"; - const requiredArgs = b.requiredArgs?.map((arg: string) => `<${arg}>`).join(" ") || ""; - const optionalArgs = b.optionalArgs?.map((arg: string) => `[${arg}]`).join(" ") || ""; - const cmdStr = - ` - \`££PREFIX££${b.prefix}` + - (requiredArgs ? ` ${requiredArgs}` : "") + - (optionalArgs ? ` ${optionalArgs}` : "") + - `\` - ${b.help}`; - cmdStrs[category] = cmdStrs[category] || [] - cmdStrs[category].push(cmdStr); - // We know that these types are safe. - botCommands[b.prefix as string] = { - fn: prototype[propertyKey], - help: b.help, - requiredArgs: b.requiredArgs, - optionalArgs: b.optionalArgs, - includeUserId: b.includeUserId, - category: b.category, - }; - } - }); - }) - return { - helpMessage: (cmdPrefix?: string, onlyCategories?: string[], includeTitles=true) => { - let content = ""; - for (const [categoryName, commands] of Object.entries(cmdStrs)) { - if (categoryName !== "default" && onlyCategories && !onlyCategories.includes(categoryName)) { - continue; - } - if (includeTitles && categoryName !== "default") { - content += `### ${categoryName[0].toUpperCase()}${categoryName.substring(1).toLowerCase()}\n`; - } - content += commands.join('\n') + "\n"; - } - return { - msgtype: "m.notice", - body: content.replace(/££PREFIX££/g, cmdPrefix || ""), - formatted_body: md.render(content).replace(/££PREFIX££/g, cmdPrefix || ""), - format: "org.matrix.custom.html" - } - }, - botCommands, - } +export function compileBotCommands( + ...prototypes: Record[] +): { helpMessage: HelpFunction; botCommands: BotCommands } { + const botCommands: BotCommands = {}; + const cmdStrs: { [category: string]: string[] } = {}; + prototypes.forEach((prototype) => { + Object.getOwnPropertyNames(prototype).forEach((propertyKey) => { + const b = Reflect.getMetadata(botCommandSymbol, prototype, propertyKey); + if (b) { + const category = b.category || "default"; + const requiredArgs = + b.requiredArgs?.map((arg: string) => `<${arg}>`).join(" ") || ""; + const optionalArgs = + b.optionalArgs?.map((arg: string) => `[${arg}]`).join(" ") || ""; + const cmdStr = + ` - \`££PREFIX££${b.prefix}` + + (requiredArgs ? ` ${requiredArgs}` : "") + + (optionalArgs ? ` ${optionalArgs}` : "") + + `\` - ${b.help}`; + cmdStrs[category] = cmdStrs[category] || []; + cmdStrs[category].push(cmdStr); + // We know that these types are safe. + botCommands[b.prefix as string] = { + fn: prototype[propertyKey], + help: b.help, + requiredArgs: b.requiredArgs, + optionalArgs: b.optionalArgs, + includeUserId: b.includeUserId, + category: b.category, + }; + } + }); + }); + return { + helpMessage: ( + cmdPrefix?: string, + onlyCategories?: string[], + includeTitles = true, + ) => { + let content = ""; + for (const [categoryName, commands] of Object.entries(cmdStrs)) { + if ( + categoryName !== "default" && + onlyCategories && + !onlyCategories.includes(categoryName) + ) { + continue; + } + if (includeTitles && categoryName !== "default") { + content += `### ${categoryName[0].toUpperCase()}${categoryName.substring(1).toLowerCase()}\n`; + } + content += commands.join("\n") + "\n"; + } + return { + msgtype: "m.notice", + body: content.replace(/££PREFIX££/g, cmdPrefix || ""), + formatted_body: md + .render(content) + .replace(/££PREFIX££/g, cmdPrefix || ""), + format: "org.matrix.custom.html", + }; + }, + botCommands, + }; } interface CommandResultNotHandled { - handled: false; + handled: false; } interface CommandResultSuccess { - handled: true; - result: BotCommandResult; + handled: true; + result: BotCommandResult; } interface CommandResultErrorUnknown { - handled: true; - humanError?: string; - error: Error; + handled: true; + humanError?: string; + error: Error; } interface CommandResultErrorHuman { - handled: true; - humanError: string; - error?: Error; + handled: true; + humanError: string; + error?: Error; } export async function handleCommand( - userId: string, command: string, botCommands: BotCommands, obj: unknown, permissionCheckFn: PermissionCheckFn, - defaultPermissionService?: string, prefix?: string) -: Promise { - if (prefix) { - if (!command.startsWith(prefix)) { - return {handled: false}; - } - command = command.substring(prefix.length); + userId: string, + command: string, + botCommands: BotCommands, + obj: unknown, + permissionCheckFn: PermissionCheckFn, + defaultPermissionService?: string, + prefix?: string, +): Promise< + | CommandResultNotHandled + | CommandResultSuccess + | CommandResultErrorUnknown + | CommandResultErrorHuman +> { + if (prefix) { + if (!command.startsWith(prefix)) { + return { handled: false }; } - 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! - const command = botCommands[prefix]; - if (command) { - const permissionService = command.permissionService || defaultPermissionService; - if (permissionService && !permissionCheckFn(permissionService, command.permissionLevel || BridgePermissionLevel.commands)) { - return {handled: true, humanError: "You do not have permission to use this command."}; - } - if (command.requiredArgs && command.requiredArgs.length > parts.length - i) { - return {handled: true, humanError: "Missing at least one required parameter."}; - } - const args = parts.slice(i); - if (command.includeUserId) { - args.splice(0,0, userId); - } - try { - const result = await botCommands[prefix].fn.apply(obj, args); - return {handled: true, result}; - } catch (ex) { - const commandError = ex as CommandError; - if (ex instanceof ApiError) { - return {handled: true, humanError: ex.error}; - } - return {handled: true, error: commandError, humanError: commandError.humanError}; - } + command = command.substring(prefix.length); + } + 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! + const command = botCommands[prefix]; + if (command) { + const permissionService = + command.permissionService || defaultPermissionService; + if ( + permissionService && + !permissionCheckFn( + permissionService, + command.permissionLevel || BridgePermissionLevel.commands, + ) + ) { + return { + handled: true, + humanError: "You do not have permission to use this command.", + }; + } + if ( + command.requiredArgs && + command.requiredArgs.length > parts.length - i + ) { + return { + handled: true, + humanError: "Missing at least one required parameter.", + }; + } + const args = parts.slice(i); + if (command.includeUserId) { + args.splice(0, 0, userId); + } + try { + const result = await botCommands[prefix].fn.apply(obj, args); + return { handled: true, result }; + } catch (ex) { + const commandError = ex as CommandError; + if (ex instanceof ApiError) { + return { handled: true, humanError: ex.error }; } + return { + handled: true, + error: commandError, + humanError: commandError.humanError, + }; + } } - return {handled: false}; -} \ No newline at end of file + } + return { handled: false }; +} diff --git a/src/Bridge.ts b/src/Bridge.ts index 1e88952d5..d4dfa6254 100644 --- a/src/Bridge.ts +++ b/src/Bridge.ts @@ -1,26 +1,86 @@ 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 { + 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 { GetIssueResponse, GetIssueOpts } from "./Gitlab/Types"; import { GithubInstance } from "./github/GithubInstance"; import { IBridgeStorageProvider } from "./Stores/StorageProvider"; -import { IConnection, GitHubDiscussionSpace, GitHubDiscussionConnection, GitHubUserSpace, JiraProjectConnection, GitLabRepoConnection, - GitHubIssueConnection, GitHubProjectConnection, GitHubRepoConnection, GitLabIssueConnection, FigmaFileConnection, FeedConnection, GenericHookConnection } from "./Connections"; -import { IGitLabWebhookIssueStateEvent, IGitLabWebhookMREvent, IGitLabWebhookNoteEvent, IGitLabWebhookPushEvent, IGitLabWebhookReleaseEvent, IGitLabWebhookTagPushEvent, IGitLabWebhookWikiPageEvent } from "./Gitlab/WebhookTypes"; -import { JiraIssueEvent, JiraIssueUpdatedEvent, JiraVersionEvent } from "./jira/WebhookTypes"; +import { + IConnection, + GitHubDiscussionSpace, + GitHubDiscussionConnection, + GitHubUserSpace, + JiraProjectConnection, + GitLabRepoConnection, + GitHubIssueConnection, + GitHubProjectConnection, + GitHubRepoConnection, + GitLabIssueConnection, + FigmaFileConnection, + FeedConnection, + GenericHookConnection, +} from "./Connections"; +import { + IGitLabWebhookIssueStateEvent, + IGitLabWebhookMREvent, + IGitLabWebhookNoteEvent, + IGitLabWebhookPushEvent, + IGitLabWebhookReleaseEvent, + IGitLabWebhookTagPushEvent, + IGitLabWebhookWikiPageEvent, +} from "./Gitlab/WebhookTypes"; +import { + JiraIssueEvent, + JiraIssueUpdatedEvent, + JiraVersionEvent, +} from "./jira/WebhookTypes"; import { JiraOAuthResult } from "./jira/Types"; -import { MatrixEvent, MatrixMemberContent, MatrixMessageContent } from "./MatrixEvent"; -import { MessageQueue, MessageQueueMessageOut, createMessageQueue } from "./MessageQueue"; +import { + MatrixEvent, + MatrixMemberContent, + MatrixMessageContent, +} from "./MatrixEvent"; +import { + MessageQueue, + MessageQueueMessageOut, + createMessageQueue, +} from "./MessageQueue"; import { MessageSenderClient } from "./MatrixSender"; -import { NotifFilter, NotificationFilterStateContent } from "./NotificationFilters"; +import { + NotifFilter, + NotificationFilterStateContent, +} from "./NotificationFilters"; import { NotificationProcessor } from "./NotificationsProcessor"; -import { NotificationsEnableEvent, NotificationsDisableEvent, Webhooks } 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 "./tokens/UserTokenStore"; @@ -34,10 +94,18 @@ import { FigmaEvent, ensureFigmaWebhooks } from "./figma"; import { ListenerService } from "./ListenerService"; import { SetupConnection } from "./Connections/SetupConnection"; import { JiraOAuthRequestOnPrem } from "./jira/OAuth"; -import { GenericWebhookEvent, GenericWebhookEventResult } from "./generic/types"; +import { + GenericWebhookEvent, + GenericWebhookEventResult, +} from "./generic/types"; import { SetupWidget } from "./Widgets/SetupWidget"; -import { FeedEntry, FeedError, FeedReader, FeedSuccess } from "./feeds/FeedReader"; -import * as Sentry from '@sentry/node'; +import { + FeedEntry, + FeedError, + FeedReader, + FeedSuccess, +} from "./feeds/FeedReader"; +import * as Sentry from "@sentry/node"; import { HoundConnection, HoundPayload } from "./Connections/HoundConnection"; import { HoundReader } from "./hound/reader"; import { OpenProjectWebhookPayloadWorkPackage } from "./openproject/types"; @@ -47,1431 +115,1990 @@ import { OAuthRequest, OAuthRequestResult } from "./tokens/oauth"; const log = new Logger("Bridge"); export class Bridge { - private readonly messageClient: MessageSenderClient; - private readonly queue: MessageQueue; - private readonly commentProcessor: CommentProcessor; - private readonly notifProcessor: NotificationProcessor; - private connectionManager?: ConnectionManager; - private github?: GithubInstance; - private adminRooms: Map = new Map(); - private feedReader?: FeedReader; - private houndReader?: HoundReader; - private replyProcessor = new RichRepliesPreprocessor(true); - - private ready = false; - - constructor( - private config: BridgeConfig, - private readonly tokenStore: UserTokenStore, - private readonly listener: ListenerService, - private readonly as: Appservice, - private readonly storage: IBridgeStorageProvider, - private readonly botUsersManager: BotUsersManager, - ) { - this.queue = createMessageQueue(this.config.queue); - this.messageClient = new MessageSenderClient(this.queue); - this.commentProcessor = new CommentProcessor(this.as, this.config.bridge.mediaUrl || this.config.bridge.url); - this.notifProcessor = new NotificationProcessor(this.storage, this.messageClient); - - // Legacy routes, to be removed. - this.as.expressAppInstance.get("/live", (_, res) => res.send({ok: true})); - this.as.expressAppInstance.get("/ready", (_, res) => res.status(this.ready ? 200 : 500).send({ready: this.ready})); - } - - public async stop() { - this.feedReader?.stop(); - this.houndReader?.stop(); - this.tokenStore.stop(); - this.as.stop(); - await this.queue.stop?.(); + private readonly messageClient: MessageSenderClient; + private readonly queue: MessageQueue; + private readonly commentProcessor: CommentProcessor; + private readonly notifProcessor: NotificationProcessor; + private connectionManager?: ConnectionManager; + private github?: GithubInstance; + private adminRooms: Map = new Map(); + private feedReader?: FeedReader; + private houndReader?: HoundReader; + private replyProcessor = new RichRepliesPreprocessor(true); + + private ready = false; + + constructor( + private config: BridgeConfig, + private readonly tokenStore: UserTokenStore, + private readonly listener: ListenerService, + private readonly as: Appservice, + private readonly storage: IBridgeStorageProvider, + private readonly botUsersManager: BotUsersManager, + ) { + this.queue = createMessageQueue(this.config.queue); + this.messageClient = new MessageSenderClient(this.queue); + this.commentProcessor = new CommentProcessor( + this.as, + this.config.bridge.mediaUrl || this.config.bridge.url, + ); + this.notifProcessor = new NotificationProcessor( + this.storage, + this.messageClient, + ); + + // Legacy routes, to be removed. + this.as.expressAppInstance.get("/live", (_, res) => res.send({ ok: true })); + this.as.expressAppInstance.get("/ready", (_, res) => + res.status(this.ready ? 200 : 500).send({ ready: this.ready }), + ); + } + + public async stop() { + this.feedReader?.stop(); + this.houndReader?.stop(); + this.tokenStore.stop(); + this.as.stop(); + await this.queue.stop?.(); + } + + public async start() { + this.tokenStore.on("onNewToken", this.onTokenUpdated.bind(this)); + log.info("Starting up"); + await this.storage.connect?.(); + await this.queue.connect?.(); + + log.info("Ensuring homeserver can be reached..."); + let reached = false; + while (!reached) { + try { + // Make a request to determine if we can reach the homeserver + await this.as.botIntent.underlyingClient.getWhoAmI(); + reached = true; + } catch (e) { + log.warn("Failed to connect to homeserver, retrying in 5s", e); + await new Promise((r) => setTimeout(r, 5000)); + } } - public async start() { - this.tokenStore.on("onNewToken", this.onTokenUpdated.bind(this)); - log.info('Starting up'); - await this.storage.connect?.(); - await this.queue.connect?.(); - - log.info("Ensuring homeserver can be reached..."); - let reached = false; - while (!reached) { - try { - // Make a request to determine if we can reach the homeserver - await this.as.botIntent.underlyingClient.getWhoAmI(); - reached = true; - } catch (e) { - log.warn("Failed to connect to homeserver, retrying in 5s", e); - await new Promise((r) => setTimeout(r, 5000)); - } - } + await this.botUsersManager.start(); - await this.botUsersManager.start(); - - await this.config.prefillMembershipCache(this.as.botClient); - - if (this.config.github) { - this.github = new GithubInstance( - this.config.github.auth.id, - await fs.readFile(this.config.github.auth.privateKeyFile, 'utf-8'), - this.config.github.baseUrl, - ); - await this.github.start(); - } - - if (this.config.figma) { - // Ensure webhooks are set up - await ensureFigmaWebhooks(this.config.figma, this.as.botClient); - } + await this.config.prefillMembershipCache(this.as.botClient); - const connManager = this.connectionManager = new ConnectionManager( - this.as, - this.config, - this.tokenStore, - this.commentProcessor, - this.messageClient, - this.storage, - this.botUsersManager, - this.github, - ); - - this.as.on("query.room", async (roomAlias, cb) => { - try { - cb(await this.onQueryRoom(roomAlias)); - } catch (ex) { - log.error("Failed to create room:", ex); - cb(false); - } - }); - - this.as.on("room.invite", async (roomId, event) => { - return this.onRoomInvite(roomId, event); - }); - - this.as.on("room.message", async (roomId, event) => { - return this.onRoomMessage(roomId, event); - }); - - this.as.on("room.event", async (roomId, event) => { - Metrics.matrixAppserviceEvents.inc(); - return this.onRoomEvent(roomId, event); - }); - - this.as.on("room.leave", async (roomId, event) => { - return this.onRoomLeave(roomId, event); - }); - - this.as.on("room.join", async (roomId, event) => { - return this.onRoomJoin(roomId, event); - }); - - this.as.on("room.failed_decryption", (roomId, event, err) => { - log.warn(`Failed to decrypt event ${event.event_id} from ${roomId}: ${err.message}`); - Metrics.matrixAppserviceDecryptionFailed.inc(); - }); - - this.queue.subscribe("response.matrix.message"); - this.queue.subscribe("notifications.user.events"); - this.queue.subscribe("github.*"); - this.queue.subscribe("gitlab.*"); - this.queue.subscribe("jira.*"); - this.queue.subscribe("figma.*"); - this.queue.subscribe("feed.*"); - - const validateRepoIssue = (data: GitHubWebhookTypes.IssuesEvent|GitHubWebhookTypes.IssueCommentEvent) => { - if (!data.repository || !data.issue) { - throw Error("Malformed webhook event, missing repository or issue"); - } - if (!data.repository.owner?.login) { - throw Error('Cannot get connection for ownerless issue'); - } - return { - owner: data.repository.owner?.login, - repository: data.repository, - issue: data.issue, - }; - } - - - this.queue.on("github.installation.created", async (data) => { - this.github?.onInstallationCreated(data.data); - }); - this.queue.on("github.installation.unsuspend", async (data) => { - this.github?.onInstallationCreated(data.data); - }); - this.queue.on("github.installation.deleted", async (data) => { - this.github?.onInstallationRemoved(data.data); - }); - this.queue.on("github.installation.suspend", async (data) => { - this.github?.onInstallationRemoved(data.data); - }); - - this.bindHandlerToQueue( - "github.issue_comment.created", - (data) => { - const { repository, issue, owner } = validateRepoIssue(data); - return connManager.getConnectionsForGithubIssue(owner, repository.name, issue.number); - }, - (c, data) => c.onIssueCommentCreated(data), - ); - - this.bindHandlerToQueue( - "github.issues.opened", - (data) => { - const { repository, owner } = validateRepoIssue(data); - return connManager.getConnectionsForGithubRepo(owner, repository.name); - }, - (c, data) => c.onIssueCreated(data), - ); - - this.bindHandlerToQueue( - "github.issues.edited", - (data) => { - const { repository, issue, owner } = validateRepoIssue(data); - return connManager.getConnectionsForGithubIssue(owner, repository.name, issue.number); - }, - (c, data) => c.onIssueEdited(data), - ); - - this.bindHandlerToQueue( - "github.issues.closed", - (data) => { - const { repository, issue, owner } = validateRepoIssue(data); - return connManager.getConnectionsForGithubIssue(owner, repository.name, issue.number); - }, - (c, data) => c.onIssueStateChange(data), - ); - - this.bindHandlerToQueue( - "github.issues.reopened", - (data) => { - const { repository, issue, owner } = validateRepoIssue(data); - return connManager.getConnectionsForGithubIssue(owner, repository.name, issue.number); - }, - (c, data) => c.onIssueStateChange(data), - ); - - this.bindHandlerToQueue( - "github.issues.unlabeled", - (data) => connManager.getConnectionsForGithubRepo(data.repository.owner.login, data.repository.name), - (c, data) => c.onIssueUnlabeled(data), - ); - this.bindHandlerToQueue( - "github.issues.labeled", - (data) => connManager.getConnectionsForGithubRepo(data.repository.owner.login, data.repository.name), - (c, data) => c.onIssueLabeled(data), - ); - - this.bindHandlerToQueue( - "github.pull_request.opened", - (data) => connManager.getConnectionsForGithubRepo(data.repository.owner.login, data.repository.name), - (c, data) => c.onPROpened(data), - ); - - this.bindHandlerToQueue( - "github.push", - (data) => connManager.getConnectionsForGithubRepo(data.repository.owner.login, data.repository.name), - (c, data) => c.onPush(data), - ); - - this.bindHandlerToQueue( - "github.pull_request.closed", - (data) => connManager.getConnectionsForGithubRepo(data.repository.owner.login, data.repository.name), - (c, data) => c.onPRClosed(data), - ); - - this.bindHandlerToQueue( - "github.pull_request.ready_for_review", - (data) => connManager.getConnectionsForGithubRepo(data.repository.owner.login, data.repository.name), - (c, data) => c.onPRReadyForReview(data), - ); - - this.bindHandlerToQueue( - "github.pull_request_review.submitted", - (data) => connManager.getConnectionsForGithubRepo(data.repository.owner.login, data.repository.name), - (c, data) => c.onPRReviewed(data), - ); - - this.bindHandlerToQueue( - "github.workflow_run.completed", - (data) => connManager.getConnectionsForGithubRepo(data.repository.owner.login, data.repository.name), - (c, data) => c.onWorkflowCompleted(data), - ); - - this.bindHandlerToQueue( - "github.release.published", - (data) => connManager.getConnectionsForGithubRepo(data.repository.owner.login, data.repository.name), - (c, data) => c.onReleaseCreated(data), - ); - - this.bindHandlerToQueue( - "github.release.created", - (data) => connManager.getConnectionsForGithubRepo(data.repository.owner.login, data.repository.name), - (c, data) => c.onReleaseDrafted(data), - ); - - this.bindHandlerToQueue( - "gitlab.merge_request.open", - (data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace), - (c, data) => c.onMergeRequestOpened(data), - ); - - this.bindHandlerToQueue( - "gitlab.merge_request.reopen", - (data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace), - (c, data) => c.onMergeRequestReopened(data), - ); - - this.bindHandlerToQueue( - "gitlab.merge_request.close", - (data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace), - (c, data) => c.onMergeRequestClosed(data), - ); - - this.bindHandlerToQueue( - "gitlab.merge_request.merge", - (data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace), - (c, data) => c.onMergeRequestMerged(data), - ); - - this.bindHandlerToQueue( - "gitlab.merge_request.approved", - (data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace), - (c, data) => c.onMergeRequestReviewed(data), - ); - - this.bindHandlerToQueue( - "gitlab.merge_request.unapproved", - (data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace), - (c, data) => c.onMergeRequestReviewed(data), - ); - - this.bindHandlerToQueue( - "gitlab.merge_request.approval", - (data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace), - (c, data) => c.onMergeRequestIndividualReview(data), - ); - - this.bindHandlerToQueue( - "gitlab.merge_request.unapproval", - (data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace), - (c, data) => c.onMergeRequestIndividualReview(data), - ); - - this.bindHandlerToQueue( - "gitlab.merge_request.update", - (data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace), - (c, data) => c.onMergeRequestUpdate(data), - ); - - this.bindHandlerToQueue( - "gitlab.release.create", - (data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace), - (c, data) => c.onRelease(data), - ); + if (this.config.github) { + this.github = new GithubInstance( + this.config.github.auth.id, + await fs.readFile(this.config.github.auth.privateKeyFile, "utf-8"), + this.config.github.baseUrl, + ); + await this.github.start(); + } - this.bindHandlerToQueue( - "gitlab.tag_push", - (data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace), - (c, data) => c.onGitLabTagPush(data), - ); + if (this.config.figma) { + // Ensure webhooks are set up + await ensureFigmaWebhooks(this.config.figma, this.as.botClient); + } - this.bindHandlerToQueue( - "gitlab.push", - (data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace), - (c, data) => c.onGitLabPush(data), + const connManager = (this.connectionManager = new ConnectionManager( + this.as, + this.config, + this.tokenStore, + this.commentProcessor, + this.messageClient, + this.storage, + this.botUsersManager, + this.github, + )); + + this.as.on("query.room", async (roomAlias, cb) => { + try { + cb(await this.onQueryRoom(roomAlias)); + } catch (ex) { + log.error("Failed to create room:", ex); + cb(false); + } + }); + + this.as.on("room.invite", async (roomId, event) => { + return this.onRoomInvite(roomId, event); + }); + + this.as.on("room.message", async (roomId, event) => { + return this.onRoomMessage(roomId, event); + }); + + this.as.on("room.event", async (roomId, event) => { + Metrics.matrixAppserviceEvents.inc(); + return this.onRoomEvent(roomId, event); + }); + + this.as.on("room.leave", async (roomId, event) => { + return this.onRoomLeave(roomId, event); + }); + + this.as.on("room.join", async (roomId, event) => { + return this.onRoomJoin(roomId, event); + }); + + this.as.on("room.failed_decryption", (roomId, event, err) => { + log.warn( + `Failed to decrypt event ${event.event_id} from ${roomId}: ${err.message}`, + ); + Metrics.matrixAppserviceDecryptionFailed.inc(); + }); + + this.queue.subscribe("response.matrix.message"); + this.queue.subscribe("notifications.user.events"); + this.queue.subscribe("github.*"); + this.queue.subscribe("gitlab.*"); + this.queue.subscribe("jira.*"); + this.queue.subscribe("figma.*"); + this.queue.subscribe("feed.*"); + + const validateRepoIssue = ( + data: + | GitHubWebhookTypes.IssuesEvent + | GitHubWebhookTypes.IssueCommentEvent, + ) => { + if (!data.repository || !data.issue) { + throw Error("Malformed webhook event, missing repository or issue"); + } + if (!data.repository.owner?.login) { + throw Error("Cannot get connection for ownerless issue"); + } + return { + owner: data.repository.owner?.login, + repository: data.repository, + issue: data.issue, + }; + }; + + this.queue.on( + "github.installation.created", + async (data) => { + this.github?.onInstallationCreated(data.data); + }, + ); + this.queue.on( + "github.installation.unsuspend", + async (data) => { + this.github?.onInstallationCreated(data.data); + }, + ); + this.queue.on( + "github.installation.deleted", + async (data) => { + this.github?.onInstallationRemoved(data.data); + }, + ); + this.queue.on( + "github.installation.suspend", + async (data) => { + this.github?.onInstallationRemoved(data.data); + }, + ); + + this.bindHandlerToQueue< + GitHubWebhookTypes.IssueCommentCreatedEvent, + GitHubIssueConnection | GitHubRepoConnection + >( + "github.issue_comment.created", + (data) => { + const { repository, issue, owner } = validateRepoIssue(data); + return connManager.getConnectionsForGithubIssue( + owner, + repository.name, + issue.number, ); - - this.bindHandlerToQueue( - "gitlab.wiki_page", - (data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace), - (c, data) => c.onWikiPageEvent(data), + }, + (c, data) => c.onIssueCommentCreated(data), + ); + + this.bindHandlerToQueue< + GitHubWebhookTypes.IssuesOpenedEvent, + GitHubRepoConnection + >( + "github.issues.opened", + (data) => { + const { repository, owner } = validateRepoIssue(data); + return connManager.getConnectionsForGithubRepo(owner, repository.name); + }, + (c, data) => c.onIssueCreated(data), + ); + + this.bindHandlerToQueue< + GitHubWebhookTypes.IssuesEditedEvent, + GitHubIssueConnection | GitHubRepoConnection + >( + "github.issues.edited", + (data) => { + const { repository, issue, owner } = validateRepoIssue(data); + return connManager.getConnectionsForGithubIssue( + owner, + repository.name, + issue.number, ); - - this.queue.on("notifications.user.events", async (msg) => { - const adminRoom = this.adminRooms.get(msg.data.roomId); - if (!adminRoom) { - log.warn("No admin room for this notif stream!"); - return; - } - await this.notifProcessor.onUserEvents(msg.data, adminRoom); - }); - - this.queue.on("github.oauth.response", async (msg) => { - const userId = this.tokenStore.getUserIdForOAuthState(msg.data.state, false); - await this.queue.push({ - data: !!userId, - sender: "Bridge", - messageId: msg.messageId, - eventName: "response.github.oauth.response", - }); - }); - - this.queue.on("github.oauth.tokens", async (msg) => { - const userId = this.tokenStore.getUserIdForOAuthState(msg.data.state); - if (!userId) { - log.warn("Could not find internal state for successful tokens request. This shouldn't happen!"); - return; - } - await this.tokenStore.storeUserToken("github", userId, JSON.stringify({ - access_token: msg.data.access_token, - expires_in: msg.data.expires_in && ((parseInt(msg.data.expires_in) * 1000) + Date.now()), - token_type: msg.data.token_type, - refresh_token: msg.data.refresh_token, - refresh_token_expires_in: msg.data.refresh_token_expires_in && ((parseInt(msg.data.refresh_token_expires_in) * 1000) + Date.now()), - } as GitHubOAuthToken)); - - // Some users won't have an admin room and would have gone through provisioning. - const adminRoom = this.getAdminRoomForUser(userId); - if (adminRoom) { - await adminRoom.sendNotice("Logged into GitHub"); - } - }); - - this.bindHandlerToQueue( - "gitlab.note.created", - (data) => { - const iid = data.issue?.iid || data.merge_request?.iid; - return [ - ...( iid ? connManager.getConnectionsForGitLabIssueWebhook(data.repository.homepage, iid) : []), - ...connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace), - ]}, - (c, data) => c instanceof GitLabRepoConnection ? c.onMergeRequestCommentCreated(data) : c.onCommentCreated(data), + }, + (c, data) => c.onIssueEdited(data), + ); + + this.bindHandlerToQueue< + GitHubWebhookTypes.IssuesClosedEvent, + GitHubIssueConnection | GitHubRepoConnection + >( + "github.issues.closed", + (data) => { + const { repository, issue, owner } = validateRepoIssue(data); + return connManager.getConnectionsForGithubIssue( + owner, + repository.name, + issue.number, ); - - this.bindHandlerToQueue( - "gitlab.issue.reopen", - (data) => connManager.getConnectionsForGitLabIssueWebhook(data.repository.homepage, data.object_attributes.iid), - (c) => c.onIssueReopened(), + }, + (c, data) => c.onIssueStateChange(data), + ); + + this.bindHandlerToQueue< + GitHubWebhookTypes.IssuesReopenedEvent, + GitHubIssueConnection | GitHubRepoConnection + >( + "github.issues.reopened", + (data) => { + const { repository, issue, owner } = validateRepoIssue(data); + return connManager.getConnectionsForGithubIssue( + owner, + repository.name, + issue.number, ); - - this.bindHandlerToQueue( - "gitlab.issue.close", - (data) => connManager.getConnectionsForGitLabIssueWebhook(data.repository.homepage, data.object_attributes.iid), - (c) => c.onIssueClosed(), + }, + (c, data) => c.onIssueStateChange(data), + ); + + this.bindHandlerToQueue< + GitHubWebhookTypes.IssuesUnlabeledEvent, + GitHubRepoConnection + >( + "github.issues.unlabeled", + (data) => + connManager.getConnectionsForGithubRepo( + data.repository.owner.login, + data.repository.name, + ), + (c, data) => c.onIssueUnlabeled(data), + ); + this.bindHandlerToQueue< + GitHubWebhookTypes.IssuesLabeledEvent, + GitHubRepoConnection + >( + "github.issues.labeled", + (data) => + connManager.getConnectionsForGithubRepo( + data.repository.owner.login, + data.repository.name, + ), + (c, data) => c.onIssueLabeled(data), + ); + + this.bindHandlerToQueue< + GitHubWebhookTypes.PullRequestOpenedEvent, + GitHubRepoConnection + >( + "github.pull_request.opened", + (data) => + connManager.getConnectionsForGithubRepo( + data.repository.owner.login, + data.repository.name, + ), + (c, data) => c.onPROpened(data), + ); + + this.bindHandlerToQueue( + "github.push", + (data) => + connManager.getConnectionsForGithubRepo( + data.repository.owner.login, + data.repository.name, + ), + (c, data) => c.onPush(data), + ); + + this.bindHandlerToQueue< + GitHubWebhookTypes.PullRequestClosedEvent, + GitHubRepoConnection + >( + "github.pull_request.closed", + (data) => + connManager.getConnectionsForGithubRepo( + data.repository.owner.login, + data.repository.name, + ), + (c, data) => c.onPRClosed(data), + ); + + this.bindHandlerToQueue< + GitHubWebhookTypes.PullRequestReadyForReviewEvent, + GitHubRepoConnection + >( + "github.pull_request.ready_for_review", + (data) => + connManager.getConnectionsForGithubRepo( + data.repository.owner.login, + data.repository.name, + ), + (c, data) => c.onPRReadyForReview(data), + ); + + this.bindHandlerToQueue< + GitHubWebhookTypes.PullRequestReviewSubmittedEvent, + GitHubRepoConnection + >( + "github.pull_request_review.submitted", + (data) => + connManager.getConnectionsForGithubRepo( + data.repository.owner.login, + data.repository.name, + ), + (c, data) => c.onPRReviewed(data), + ); + + this.bindHandlerToQueue< + GitHubWebhookTypes.WorkflowRunCompletedEvent, + GitHubRepoConnection + >( + "github.workflow_run.completed", + (data) => + connManager.getConnectionsForGithubRepo( + data.repository.owner.login, + data.repository.name, + ), + (c, data) => c.onWorkflowCompleted(data), + ); + + this.bindHandlerToQueue< + GitHubWebhookTypes.ReleasePublishedEvent, + GitHubRepoConnection + >( + "github.release.published", + (data) => + connManager.getConnectionsForGithubRepo( + data.repository.owner.login, + data.repository.name, + ), + (c, data) => c.onReleaseCreated(data), + ); + + this.bindHandlerToQueue< + GitHubWebhookTypes.ReleaseCreatedEvent, + GitHubRepoConnection + >( + "github.release.created", + (data) => + connManager.getConnectionsForGithubRepo( + data.repository.owner.login, + data.repository.name, + ), + (c, data) => c.onReleaseDrafted(data), + ); + + this.bindHandlerToQueue( + "gitlab.merge_request.open", + (data) => + connManager.getConnectionsForGitLabRepo( + data.project.path_with_namespace, + ), + (c, data) => c.onMergeRequestOpened(data), + ); + + this.bindHandlerToQueue( + "gitlab.merge_request.reopen", + (data) => + connManager.getConnectionsForGitLabRepo( + data.project.path_with_namespace, + ), + (c, data) => c.onMergeRequestReopened(data), + ); + + this.bindHandlerToQueue( + "gitlab.merge_request.close", + (data) => + connManager.getConnectionsForGitLabRepo( + data.project.path_with_namespace, + ), + (c, data) => c.onMergeRequestClosed(data), + ); + + this.bindHandlerToQueue( + "gitlab.merge_request.merge", + (data) => + connManager.getConnectionsForGitLabRepo( + data.project.path_with_namespace, + ), + (c, data) => c.onMergeRequestMerged(data), + ); + + this.bindHandlerToQueue( + "gitlab.merge_request.approved", + (data) => + connManager.getConnectionsForGitLabRepo( + data.project.path_with_namespace, + ), + (c, data) => c.onMergeRequestReviewed(data), + ); + + this.bindHandlerToQueue( + "gitlab.merge_request.unapproved", + (data) => + connManager.getConnectionsForGitLabRepo( + data.project.path_with_namespace, + ), + (c, data) => c.onMergeRequestReviewed(data), + ); + + this.bindHandlerToQueue( + "gitlab.merge_request.approval", + (data) => + connManager.getConnectionsForGitLabRepo( + data.project.path_with_namespace, + ), + (c, data) => c.onMergeRequestIndividualReview(data), + ); + + this.bindHandlerToQueue( + "gitlab.merge_request.unapproval", + (data) => + connManager.getConnectionsForGitLabRepo( + data.project.path_with_namespace, + ), + (c, data) => c.onMergeRequestIndividualReview(data), + ); + + this.bindHandlerToQueue( + "gitlab.merge_request.update", + (data) => + connManager.getConnectionsForGitLabRepo( + data.project.path_with_namespace, + ), + (c, data) => c.onMergeRequestUpdate(data), + ); + + this.bindHandlerToQueue( + "gitlab.release.create", + (data) => + connManager.getConnectionsForGitLabRepo( + data.project.path_with_namespace, + ), + (c, data) => c.onRelease(data), + ); + + this.bindHandlerToQueue( + "gitlab.tag_push", + (data) => + connManager.getConnectionsForGitLabRepo( + data.project.path_with_namespace, + ), + (c, data) => c.onGitLabTagPush(data), + ); + + this.bindHandlerToQueue( + "gitlab.push", + (data) => + connManager.getConnectionsForGitLabRepo( + data.project.path_with_namespace, + ), + (c, data) => c.onGitLabPush(data), + ); + + this.bindHandlerToQueue( + "gitlab.wiki_page", + (data) => + connManager.getConnectionsForGitLabRepo( + data.project.path_with_namespace, + ), + (c, data) => c.onWikiPageEvent(data), + ); + + this.queue.on( + "notifications.user.events", + async (msg) => { + const adminRoom = this.adminRooms.get(msg.data.roomId); + if (!adminRoom) { + log.warn("No admin room for this notif stream!"); + return; + } + await this.notifProcessor.onUserEvents(msg.data, adminRoom); + }, + ); + + this.queue.on("github.oauth.response", async (msg) => { + const userId = this.tokenStore.getUserIdForOAuthState( + msg.data.state, + false, + ); + await this.queue.push({ + data: !!userId, + sender: "Bridge", + messageId: msg.messageId, + eventName: "response.github.oauth.response", + }); + }); + + this.queue.on( + "github.oauth.tokens", + async (msg) => { + const userId = this.tokenStore.getUserIdForOAuthState(msg.data.state); + if (!userId) { + log.warn( + "Could not find internal state for successful tokens request. This shouldn't happen!", + ); + return; + } + await this.tokenStore.storeUserToken( + "github", + userId, + JSON.stringify({ + access_token: msg.data.access_token, + expires_in: + msg.data.expires_in && + parseInt(msg.data.expires_in) * 1000 + Date.now(), + token_type: msg.data.token_type, + refresh_token: msg.data.refresh_token, + refresh_token_expires_in: + msg.data.refresh_token_expires_in && + parseInt(msg.data.refresh_token_expires_in) * 1000 + Date.now(), + } as GitHubOAuthToken), ); - this.bindHandlerToQueue( - "github.discussion_comment.created", - (data) => connManager.getConnectionsForGithubDiscussion(data.repository.owner.login, data.repository.name, data.discussion.number), - (c, data) => c.onDiscussionCommentCreated(data), + // Some users won't have an admin room and would have gone through provisioning. + const adminRoom = this.getAdminRoomForUser(userId); + if (adminRoom) { + await adminRoom.sendNotice("Logged into GitHub"); + } + }, + ); + + this.bindHandlerToQueue< + IGitLabWebhookNoteEvent, + GitLabIssueConnection | GitLabRepoConnection + >( + "gitlab.note.created", + (data) => { + const iid = data.issue?.iid || data.merge_request?.iid; + return [ + ...(iid + ? connManager.getConnectionsForGitLabIssueWebhook( + data.repository.homepage, + iid, + ) + : []), + ...connManager.getConnectionsForGitLabRepo( + data.project.path_with_namespace, + ), + ]; + }, + (c, data) => + c instanceof GitLabRepoConnection + ? c.onMergeRequestCommentCreated(data) + : c.onCommentCreated(data), + ); + + this.bindHandlerToQueue< + IGitLabWebhookIssueStateEvent, + GitLabIssueConnection + >( + "gitlab.issue.reopen", + (data) => + connManager.getConnectionsForGitLabIssueWebhook( + data.repository.homepage, + data.object_attributes.iid, + ), + (c) => c.onIssueReopened(), + ); + + this.bindHandlerToQueue< + IGitLabWebhookIssueStateEvent, + GitLabIssueConnection + >( + "gitlab.issue.close", + (data) => + connManager.getConnectionsForGitLabIssueWebhook( + data.repository.homepage, + data.object_attributes.iid, + ), + (c) => c.onIssueClosed(), + ); + + this.bindHandlerToQueue< + GitHubWebhookTypes.DiscussionCommentCreatedEvent, + GitHubDiscussionConnection + >( + "github.discussion_comment.created", + (data) => + connManager.getConnectionsForGithubDiscussion( + data.repository.owner.login, + data.repository.name, + data.discussion.number, + ), + (c, data) => c.onDiscussionCommentCreated(data), + ); + + this.queue.on( + "github.discussion.created", + async ({ data }) => { + if (!this.github || !this.config.github) { + return; + } + const spaces = connManager.getConnectionsForGithubRepoDiscussion( + data.repository.owner.login, + data.repository.name, ); + if (spaces.length === 0) { + log.info( + `Not creating discussion ${data.discussion.id} ${data.repository.owner.login}/${data.repository.name}, no target spaces`, + ); + // We don't want to create any discussions if we have no target spaces. + return; + } + let [discussionConnection] = + connManager.getConnectionsForGithubDiscussion( + data.repository.owner.login, + data.repository.name, + data.discussion.id, + ); + if (!discussionConnection) { + const botUser = this.botUsersManager.getBotUserForService( + GitHubDiscussionConnection.ServiceCategory, + ); + if (!botUser) { + throw Error("Could not find a bot to handle this connection"); + } + + try { + // If we don't have an existing connection for this discussion (likely), then create one. + discussionConnection = + await GitHubDiscussionConnection.createDiscussionRoom( + this.as, + botUser.intent, + null, + data.repository.owner.login, + data.repository.name, + data.discussion, + this.tokenStore, + this.commentProcessor, + this.messageClient, + this.config, + ); + connManager.push(discussionConnection); + } catch (ex) { + log.error(ex); + throw Error("Failed to create discussion room"); + } + } - this.queue.on("github.discussion.created", async ({data}) => { - if (!this.github || !this.config.github) { - return; - } - const spaces = connManager.getConnectionsForGithubRepoDiscussion(data.repository.owner.login, data.repository.name); - if (spaces.length === 0) { - log.info(`Not creating discussion ${data.discussion.id} ${data.repository.owner.login}/${data.repository.name}, no target spaces`); - // We don't want to create any discussions if we have no target spaces. - return; - } - let [discussionConnection] = connManager.getConnectionsForGithubDiscussion(data.repository.owner.login, data.repository.name, data.discussion.id); - if (!discussionConnection) { - const botUser = this.botUsersManager.getBotUserForService(GitHubDiscussionConnection.ServiceCategory); - if (!botUser) { - throw Error('Could not find a bot to handle this connection'); - } - - try { - // If we don't have an existing connection for this discussion (likely), then create one. - discussionConnection = await GitHubDiscussionConnection.createDiscussionRoom( - this.as, - botUser.intent, - null, - data.repository.owner.login, - data.repository.name, - data.discussion, - this.tokenStore, - this.commentProcessor, - this.messageClient, - this.config, - ); - connManager.push(discussionConnection); - } catch (ex) { - log.error(ex); - throw Error('Failed to create discussion room'); - } - } - - spaces.map(async (c) => { - try { - await c.onDiscussionCreated(discussionConnection); - } catch (ex) { - log.warn(`Failed to add discussion ${c.toString()} failed to handle comment.created:`, ex); - } - }) + spaces.map(async (c) => { + try { + await c.onDiscussionCreated(discussionConnection); + } catch (ex) { + log.warn( + `Failed to add discussion ${c.toString()} failed to handle comment.created:`, + ex, + ); + } }); + }, + ); + + this.bindHandlerToQueue( + "jira.issue_created", + (data) => + connManager.getConnectionsForJiraProject(data.issue.fields.project), + (c, data) => c.onJiraIssueCreated(data), + ); + + this.bindHandlerToQueue( + "jira.issue_updated", + (data) => + connManager.getConnectionsForJiraProject(data.issue.fields.project), + (c, data) => c.onJiraIssueUpdated(data), + ); + + for (const event of ["created", "updated", "released"]) { + this.bindHandlerToQueue( + `jira.version_${event}`, + (data) => connManager.getConnectionsForJiraVersion(data.version), + (c, data) => c.onJiraVersionEvent(data), + ); + } - this.bindHandlerToQueue( - "jira.issue_created", - (data) => connManager.getConnectionsForJiraProject(data.issue.fields.project), - (c, data) => c.onJiraIssueCreated(data), - ); - - this.bindHandlerToQueue( - "jira.issue_updated", - (data) => connManager.getConnectionsForJiraProject(data.issue.fields.project), - (c, data) => c.onJiraIssueUpdated(data), + this.queue.on( + "jira.oauth.response", + async (msg) => { + if (!this.config.jira || !this.tokenStore.jiraOAuth) { + throw Error("Cannot handle, JIRA oauth support not enabled"); + } + let result: OAuthRequestResult; + const userId = this.tokenStore.getUserIdForOAuthState( + msg.data.state, + false, ); - - for (const event of ["created", "updated", "released"]) { - this.bindHandlerToQueue( - `jira.version_${event}`, - (data) => connManager.getConnectionsForJiraVersion(data.version), - (c, data) => c.onJiraVersionEvent(data), + if (!userId) { + return this.queue.push({ + data: OAuthRequestResult.UserNotFound, + sender: "Bridge", + messageId: msg.messageId, + eventName: "response.jira.oauth.response", + }); + } + try { + let tokenInfo: JiraOAuthResult; + if ("code" in msg.data) { + tokenInfo = await this.tokenStore.jiraOAuth.exchangeRequestForToken( + msg.data.code, ); + } else { + tokenInfo = await this.tokenStore.jiraOAuth.exchangeRequestForToken( + msg.data.oauthToken, + msg.data.oauthVerifier, + ); + } + await this.tokenStore.storeJiraToken(userId, { + access_token: tokenInfo.access_token, + refresh_token: tokenInfo.refresh_token, + instance: this.config.jira.instanceName, + expires_in: tokenInfo.expires_in, + }); + + // Some users won't have an admin room and would have gone through provisioning. + const adminRoom = this.getAdminRoomForUser(userId); + if (adminRoom) { + await adminRoom.sendNotice("Logged into Jira"); + } + result = OAuthRequestResult.Success; + } catch (ex) { + log.warn(`Failed to handle JIRA oauth token exchange`, ex); + result = OAuthRequestResult.UnknownFailure; } - - this.queue.on("jira.oauth.response", async (msg) => { - if (!this.config.jira || !this.tokenStore.jiraOAuth) { - throw Error('Cannot handle, JIRA oauth support not enabled'); - } - let result: OAuthRequestResult; - const userId = this.tokenStore.getUserIdForOAuthState(msg.data.state, false); - if (!userId) { - return this.queue.push({ - data: OAuthRequestResult.UserNotFound, - sender: "Bridge", - messageId: msg.messageId, - eventName: "response.jira.oauth.response", - }); - } - try { - let tokenInfo: JiraOAuthResult; - if ("code" in msg.data) { - tokenInfo = await this.tokenStore.jiraOAuth.exchangeRequestForToken(msg.data.code); - } else { - tokenInfo = await this.tokenStore.jiraOAuth.exchangeRequestForToken(msg.data.oauthToken, msg.data.oauthVerifier); - } - await this.tokenStore.storeJiraToken(userId, { - access_token: tokenInfo.access_token, - refresh_token: tokenInfo.refresh_token, - instance: this.config.jira.instanceName, - expires_in: tokenInfo.expires_in, - }); - - // Some users won't have an admin room and would have gone through provisioning. - const adminRoom = this.getAdminRoomForUser(userId); - if (adminRoom) { - await adminRoom.sendNotice("Logged into Jira"); - } - result = OAuthRequestResult.Success; - } catch (ex) { - log.warn(`Failed to handle JIRA oauth token exchange`, ex); - result = OAuthRequestResult.UnknownFailure; - } - await this.queue.push({ - data: result, - sender: "Bridge", - messageId: msg.messageId, - eventName: "response.jira.oauth.response", - }); + await this.queue.push({ + data: result, + sender: "Bridge", + messageId: msg.messageId, + eventName: "response.jira.oauth.response", }); - - this.queue.on("openproject.oauth.response", async (msg) => { - if (!this.tokenStore.openProjectOAuth) { - throw Error('Cannot handle, OpenProject oauth support not enabled'); + }, + ); + + this.queue.on("openproject.oauth.response", async (msg) => { + if (!this.tokenStore.openProjectOAuth) { + throw Error("Cannot handle, OpenProject oauth support not enabled"); + } + const result = await this.tokenStore.openProjectOAuth.handleOAuth( + msg.data, + ); + await this.queue.push({ + data: result, + sender: "Bridge", + messageId: msg.messageId, + eventName: "response.openproject.oauth.response", + }); + }); + + this.queue.on("generic-webhook.event", async (msg) => { + const { data, messageId } = msg; + const connections = connManager.getConnectionsForGenericWebhook( + data.hookId, + ); + log.debug( + `generic-webhook.event for ${connections.map((c) => c.toString()).join(", ") || "[empty]"}`, + ); + + if (!connections.length) { + await this.queue.push({ + data: { successful: true, notFound: true }, + sender: "Bridge", + messageId: messageId, + eventName: "response.generic-webhook.event", + }); + } + + let didPush = false; + await Promise.all( + connections.map(async (c, index) => { + try { + // TODO: Support webhook responses to more than one room + if (index !== 0) { + await c.onGenericHook(data.hookData); + return; } - const result = await this.tokenStore.openProjectOAuth.handleOAuth(msg.data); - await this.queue.push({ + if (this.config.generic?.waitForComplete || c.waitForComplete) { + const result = await c.onGenericHook(data.hookData); + await this.queue.push({ data: result, sender: "Bridge", - messageId: msg.messageId, - eventName: "response.openproject.oauth.response", - }); - }); - - this.queue.on("generic-webhook.event", async (msg) => { - const { data, messageId } = msg; - const connections = connManager.getConnectionsForGenericWebhook(data.hookId); - log.debug(`generic-webhook.event for ${connections.map(c => c.toString()).join(', ') || '[empty]'}`); - - if (!connections.length) { - await this.queue.push({ - data: {successful: true, notFound: true}, - sender: "Bridge", - messageId: messageId, - eventName: "response.generic-webhook.event", - }); - } - - let didPush = false; - await Promise.all(connections.map(async (c, index) => { - try { - // TODO: Support webhook responses to more than one room - if (index !== 0) { - await c.onGenericHook(data.hookData); - return; - } - if (this.config.generic?.waitForComplete || c.waitForComplete) { - const result = await c.onGenericHook(data.hookData); - await this.queue.push({ - data: result, - sender: "Bridge", - messageId, - eventName: "response.generic-webhook.event", - }); - } else { - await this.queue.push({ - data: { - successful: null, - }, - sender: "Bridge", - messageId, - eventName: "response.generic-webhook.event", - }); - await c.onGenericHook(data.hookData); - } - didPush = true; - } - catch (ex) { - log.warn(`Failed to handle generic webhook`, ex); - Metrics.connectionsEventFailed.inc({ - event: "generic-webhook.event", - connectionId: c.connectionId - }); - } - })); - - // We didn't manage to complete sending the event or even sending a failure. - if (!didPush) { - await this.queue.push({ - data: { - successful: false - }, - sender: "Bridge", - messageId, - eventName: "response.generic-webhook.event", - }); + messageId, + eventName: "response.generic-webhook.event", + }); + } else { + await this.queue.push({ + data: { + successful: null, + }, + sender: "Bridge", + messageId, + eventName: "response.generic-webhook.event", + }); + await c.onGenericHook(data.hookData); } + didPush = true; + } catch (ex) { + log.warn(`Failed to handle generic webhook`, ex); + Metrics.connectionsEventFailed.inc({ + event: "generic-webhook.event", + connectionId: c.connectionId, + }); + } + }), + ); + + // We didn't manage to complete sending the event or even sending a failure. + if (!didPush) { + await this.queue.push({ + data: { + successful: false, + }, + sender: "Bridge", + messageId, + eventName: "response.generic-webhook.event", }); + } + }); + + this.bindHandlerToQueue( + "figma.payload", + (data) => + connManager.getForFigmaFile(data.payload.file_key, data.instanceName), + (c, data) => c.handleNewComment(data.payload), + ); + + this.bindHandlerToQueue( + "feed.entry", + (data) => connManager.getConnectionsForFeedUrl(data.feed.url), + (c, data) => c.handleFeedEntry(data), + ); + this.bindHandlerToQueue( + "feed.success", + (data) => connManager.getConnectionsForFeedUrl(data.url), + (c) => c.handleFeedSuccess(), + ); + this.bindHandlerToQueue( + "feed.error", + (data) => connManager.getConnectionsForFeedUrl(data.url), + (c, data) => c.handleFeedError(data), + ); + + this.bindHandlerToQueue( + "hound.activity", + (data) => connManager.getConnectionsForHoundChallengeId(data.challengeId), + (c, data) => c.handleNewActivity(data.activity), + ); + + this.bindHandlerToQueue< + OpenProjectWebhookPayloadWorkPackage, + OpenProjectConnection + >( + "openproject.work_package:created", + (data) => + connManager.getConnectionsForOpenProject( + data.work_package._embedded.project.id, + ), + (c, data) => c.onWorkPackageCreated(data), + ); + this.bindHandlerToQueue< + OpenProjectWebhookPayloadWorkPackage, + OpenProjectConnection + >( + "openproject.work_package:updated", + (data) => + connManager.getConnectionsForOpenProject( + data.work_package._embedded.project.id, + ), + (c, data) => c.onWorkPackageUpdated(data), + ); + + const allRooms = this.botUsersManager.joinedRooms; + + const processRooms = async () => { + for ( + let roomId = allRooms.pop(); + roomId !== undefined; + roomId = allRooms.pop() + ) { + log.debug("Fetching state for " + roomId); - this.bindHandlerToQueue( - "figma.payload", - (data) => connManager.getForFigmaFile(data.payload.file_key, data.instanceName), - (c, data) => c.handleNewComment(data.payload), - ) - - this.bindHandlerToQueue( - "feed.entry", - (data) => connManager.getConnectionsForFeedUrl(data.feed.url), - (c, data) => c.handleFeedEntry(data), - ); - this.bindHandlerToQueue( - "feed.success", - (data) => connManager.getConnectionsForFeedUrl(data.url), - c => c.handleFeedSuccess(), - ); - this.bindHandlerToQueue( - "feed.error", - (data) => connManager.getConnectionsForFeedUrl(data.url), - (c, data) => c.handleFeedError(data), - ); - - this.bindHandlerToQueue( - "hound.activity", - (data) => connManager.getConnectionsForHoundChallengeId(data.challengeId), - (c, data) => c.handleNewActivity(data.activity) - ); - - this.bindHandlerToQueue( - "openproject.work_package:created", - (data) => connManager.getConnectionsForOpenProject(data.work_package._embedded.project.id), - (c, data) => c.onWorkPackageCreated(data) - ); - this.bindHandlerToQueue( - "openproject.work_package:updated", - (data) => connManager.getConnectionsForOpenProject(data.work_package._embedded.project.id), - (c, data) => c.onWorkPackageUpdated(data) - ); - - - const allRooms = this.botUsersManager.joinedRooms; - - const processRooms = async () => { - for (let roomId = allRooms.pop(); roomId !== undefined; roomId = allRooms.pop()) { - log.debug("Fetching state for " + roomId); - - try { - await connManager.createConnectionsForRoomId(roomId, false); - } catch (ex) { - log.error(`Unable to create connection for ${roomId}`, ex); - continue; - } - - const botUser = this.botUsersManager.getBotUserInRoom(roomId); - if (!botUser) { - log.error(`Failed to find a bot in room '${roomId}' when setting up admin room`); - continue; - } - - // TODO: Refactor this to be a connection - try { - let accountData = await botUser.intent.underlyingClient.getSafeRoomAccountData( - BRIDGE_ROOM_TYPE, roomId, - ); - if (!accountData) { - accountData = await botUser.intent.underlyingClient.getSafeRoomAccountData( - LEGACY_BRIDGE_ROOM_TYPE, roomId, - ); - if (!accountData) { - log.debug(`Room ${roomId} has no connections and is not an admin room`); - continue; - } else { - // Upgrade the room - await botUser.intent.underlyingClient.setRoomAccountData(BRIDGE_ROOM_TYPE, roomId, accountData); - } - } - - let notifContent; - try { - notifContent = await botUser.intent.underlyingClient.getRoomStateEvent( - roomId, NotifFilter.StateType, "", - ); - } catch { - try { - notifContent = await botUser.intent.underlyingClient.getRoomStateEvent( - roomId, NotifFilter.LegacyStateType, "", - ); - } - catch { - // No state yet - } - } - const adminRoom = await this.setUpAdminRoom(botUser.intent, roomId, accountData, notifContent || NotifFilter.getDefaultContent()); - // Call this on startup to set the state - await this.onAdminRoomSettingsChanged(adminRoom, accountData, { admin_user: accountData.admin_user }); - log.debug(`Room ${roomId} is connected to: ${adminRoom.toString()}`); - } catch (ex) { - log.error(`Failed to set up admin room ${roomId}:`, ex); - } - } - } - - // Concurrency of two. - const roomQueue = await Promise.all([processRooms(), processRooms()]) - - // Handle spaces - for (const discussion of connManager.getAllConnectionsOfType(GitHubDiscussionSpace)) { - const user = connManager.getConnectionForGithubUser(discussion.owner); - if (user) { - await user.ensureDiscussionInSpace(discussion); - } + try { + await connManager.createConnectionsForRoomId(roomId, false); + } catch (ex) { + log.error(`Unable to create connection for ${roomId}`, ex); + continue; } - if (this.config.widgets) { - const apps = this.listener.getApplicationsForResource('widgets'); - if (apps.length > 1) { - throw Error('You may only bind `widgets` to one listener.'); - } - new BridgeWidgetApi( - this.adminRooms, - this.config, - this.storage, - apps[0], - this.connectionManager, - this.botUsersManager, - this.as, - this.tokenStore, - this.github, - ); - } - if (this.config.metrics?.enabled) { - this.listener.bindResource('metrics', Metrics.expressRouter); - } - await roomQueue; - log.info(`All connections loaded`); - - // Load feeds after connections, to limit the chances of us double - // posting to rooms if a previous hookshot instance is being replaced. - if (this.config.feeds?.enabled) { - this.feedReader = new FeedReader( - this.config.feeds, - this.connectionManager, - this.queue, - this.storage, - ); + const botUser = this.botUsersManager.getBotUserInRoom(roomId); + if (!botUser) { + log.error( + `Failed to find a bot in room '${roomId}' when setting up admin room`, + ); + continue; } - if (this.config.challengeHound?.token) { - this.houndReader = new HoundReader( - this.config.challengeHound, - this.connectionManager, - this.queue, - this.storage, + // TODO: Refactor this to be a connection + try { + let accountData = + await botUser.intent.underlyingClient.getSafeRoomAccountData( + BRIDGE_ROOM_TYPE, + roomId, ); + if (!accountData) { + accountData = + await botUser.intent.underlyingClient.getSafeRoomAccountData( + LEGACY_BRIDGE_ROOM_TYPE, + roomId, + ); + if (!accountData) { + log.debug( + `Room ${roomId} has no connections and is not an admin room`, + ); + continue; + } else { + // Upgrade the room + await botUser.intent.underlyingClient.setRoomAccountData( + BRIDGE_ROOM_TYPE, + roomId, + accountData, + ); + } + } + + let notifContent; + try { + notifContent = + await botUser.intent.underlyingClient.getRoomStateEvent( + roomId, + NotifFilter.StateType, + "", + ); + } catch { + try { + notifContent = + await botUser.intent.underlyingClient.getRoomStateEvent( + roomId, + NotifFilter.LegacyStateType, + "", + ); + } catch { + // No state yet + } + } + const adminRoom = await this.setUpAdminRoom( + botUser.intent, + roomId, + accountData, + notifContent || NotifFilter.getDefaultContent(), + ); + // Call this on startup to set the state + await this.onAdminRoomSettingsChanged(adminRoom, accountData, { + admin_user: accountData.admin_user, + }); + log.debug(`Room ${roomId} is connected to: ${adminRoom.toString()}`); + } catch (ex) { + log.error(`Failed to set up admin room ${roomId}:`, ex); } - - const 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; + } + }; + + // Concurrency of two. + const roomQueue = await Promise.all([processRooms(), processRooms()]); + + // Handle spaces + for (const discussion of connManager.getAllConnectionsOfType( + GitHubDiscussionSpace, + )) { + const user = connManager.getConnectionForGithubUser(discussion.owner); + if (user) { + await user.ensureDiscussionInSpace(discussion); + } + } + if (this.config.widgets) { + const apps = this.listener.getApplicationsForResource("widgets"); + if (apps.length > 1) { + throw Error("You may only bind `widgets` to one listener."); + } + new BridgeWidgetApi( + this.adminRooms, + this.config, + this.storage, + apps[0], + this.connectionManager, + this.botUsersManager, + this.as, + this.tokenStore, + this.github, + ); + } + if (this.config.metrics?.enabled) { + this.listener.bindResource("metrics", Metrics.expressRouter); + } + await roomQueue; + log.info(`All connections loaded`); + + // Load feeds after connections, to limit the chances of us double + // posting to rooms if a previous hookshot instance is being replaced. + if (this.config.feeds?.enabled) { + this.feedReader = new FeedReader( + this.config.feeds, + this.connectionManager, + this.queue, + this.storage, + ); } - private async handleHookshotEvent(msg: MessageQueueMessageOut, connection: ConnType, handler: (c: ConnType, data: EventType) => Promise|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); - }); - } + if (this.config.challengeHound?.token) { + this.houndReader = new HoundReader( + this.config.challengeHound, + this.connectionManager, + this.queue, + this.storage, + ); } - private async bindHandlerToQueue(event: string, connectionFetcher: (data: EventType) => ConnType[], handler: (c: ConnType, data: EventType) => Promise|unknown) { - const connectionFetcherBound = connectionFetcher.bind(this); - this.queue.on(event, (msg) => { - const connections = connectionFetcherBound(msg.data); - log.debug(`${event} for ${connections.map(c => c.toString()).join(', ') || '[empty]'}`); - connections.forEach((connection) => { - void this.handleHookshotEvent(msg, connection, handler); - }); + 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( + msg: MessageQueueMessageOut, + connection: ConnType, + handler: (c: ConnType, data: EventType) => Promise | 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 onRoomInvite(roomId: string, event: MatrixEvent) { - if (this.as.isNamespacedUser(event.sender)) { - /* Do not handle invites from our users */ - return; - } - const invitedUserId = event.state_key; - if (!invitedUserId) { - return; - } - log.info(`Got invite roomId=${roomId} from=${event.sender} to=${invitedUserId}`); - - const botUser = this.botUsersManager.getBotUser(invitedUserId); - if (!botUser) { - // We got an invite but it's not a configured bot user, must be for a ghost user - log.debug(`Rejecting invite to room ${roomId} for ghost user ${invitedUserId}`); - const client = this.as.getIntentForUserId(invitedUserId).underlyingClient; - return client.leaveRoom(roomId, "Bridge does not support DMing ghosts."); - } - - // Don't accept invites from people who can't do anything - if (!this.config.checkPermissionAny(event.sender, BridgePermissionLevel.login)) { - return botUser.intent.underlyingClient.leaveRoom(roomId, "You do not have permission to invite this bot."); - } - - if (event.content.is_direct && botUser.userId !== this.as.botUserId) { - // Service bots do not support direct messages (admin rooms) - log.debug(`Rejecting direct message (admin room) invite to room ${roomId} for service bot ${botUser.userId}`); - return botUser.intent.underlyingClient.leaveRoom(roomId, "This bot does not support admin rooms."); - } - - // Accept the invite - await retry(async () => { - try { - await botUser.intent.joinRoom(roomId); - } catch (ex) { - log.warn(`Failed to join ${roomId}`, ex); - throw ex; - } - }, 5); - if (event.content.is_direct) { - await botUser.intent.underlyingClient.setRoomAccountData( - BRIDGE_ROOM_TYPE, roomId, {admin_user: event.sender}, - ); - } + } + + private async bindHandlerToQueue( + event: string, + connectionFetcher: (data: EventType) => ConnType[], + handler: (c: ConnType, data: EventType) => Promise | unknown, + ) { + const connectionFetcherBound = connectionFetcher.bind(this); + this.queue.on(event, (msg) => { + const connections = connectionFetcherBound(msg.data); + log.debug( + `${event} for ${connections.map((c) => c.toString()).join(", ") || "[empty]"}`, + ); + connections.forEach((connection) => { + void this.handleHookshotEvent(msg, connection, handler); + }); + }); + } + + private async onRoomInvite( + roomId: string, + event: MatrixEvent, + ) { + if (this.as.isNamespacedUser(event.sender)) { + /* Do not handle invites from our users */ + return; + } + const invitedUserId = event.state_key; + if (!invitedUserId) { + return; + } + log.info( + `Got invite roomId=${roomId} from=${event.sender} to=${invitedUserId}`, + ); + + const botUser = this.botUsersManager.getBotUser(invitedUserId); + if (!botUser) { + // We got an invite but it's not a configured bot user, must be for a ghost user + log.debug( + `Rejecting invite to room ${roomId} for ghost user ${invitedUserId}`, + ); + const client = this.as.getIntentForUserId(invitedUserId).underlyingClient; + return client.leaveRoom(roomId, "Bridge does not support DMing ghosts."); } + // Don't accept invites from people who can't do anything + if ( + !this.config.checkPermissionAny(event.sender, BridgePermissionLevel.login) + ) { + return botUser.intent.underlyingClient.leaveRoom( + roomId, + "You do not have permission to invite this bot.", + ); + } - private async onRoomLeave(roomId: string, matrixEvent: MatrixEvent) { - const userId = matrixEvent.state_key; - if (!userId) { - return; - } + if (event.content.is_direct && botUser.userId !== this.as.botUserId) { + // Service bots do not support direct messages (admin rooms) + log.debug( + `Rejecting direct message (admin room) invite to room ${roomId} for service bot ${botUser.userId}`, + ); + return botUser.intent.underlyingClient.leaveRoom( + roomId, + "This bot does not support admin rooms.", + ); + } - const botUser = this.botUsersManager.getBotUser(userId); - if (!botUser) { - // Not for one of our bots - return; - } - this.botUsersManager.onRoomLeave(botUser, roomId); + // Accept the invite + await retry(async () => { + try { + await botUser.intent.joinRoom(roomId); + } catch (ex) { + log.warn(`Failed to join ${roomId}`, ex); + throw ex; + } + }, 5); + if (event.content.is_direct) { + await botUser.intent.underlyingClient.setRoomAccountData( + BRIDGE_ROOM_TYPE, + roomId, + { admin_user: event.sender }, + ); + } + } + + private async onRoomLeave( + roomId: string, + matrixEvent: MatrixEvent, + ) { + const userId = matrixEvent.state_key; + if (!userId) { + return; + } - if (!this.connectionManager) { - return; - } + const botUser = this.botUsersManager.getBotUser(userId); + if (!botUser) { + // Not for one of our bots + return; + } + this.botUsersManager.onRoomLeave(botUser, roomId); - // Remove all the connections for this room - await this.connectionManager.removeConnectionsForRoom(roomId); - if (this.botUsersManager.getBotUsersInRoom(roomId).length > 0) { - // If there are still bots in the room, recreate connections - await this.connectionManager.createConnectionsForRoomId(roomId, true); - } + if (!this.connectionManager) { + return; } - private async onRoomMessage(roomId: string, event: MatrixEvent) { - if (!this.connectionManager) { - // Not ready yet. - return; - } - if (this.as.isNamespacedUser(event.sender)) { - /* We ignore messages from our users */ - return; - } - if (Date.now() - event.origin_server_ts > 30000) { - /* We ignore old messages too */ - return; - } - log.info(`Got message roomId=${roomId} type=${event.type} from=${event.sender}`); - log.debug("Content:", JSON.stringify(event)); - let processedReply; - let processedReplyMetadata: IRichReplyMetadata|undefined = undefined; + // Remove all the connections for this room + await this.connectionManager.removeConnectionsForRoom(roomId); + if (this.botUsersManager.getBotUsersInRoom(roomId).length > 0) { + // If there are still bots in the room, recreate connections + await this.connectionManager.createConnectionsForRoomId(roomId, true); + } + } + + private async onRoomMessage( + roomId: string, + event: MatrixEvent, + ) { + if (!this.connectionManager) { + // Not ready yet. + return; + } + if (this.as.isNamespacedUser(event.sender)) { + /* We ignore messages from our users */ + return; + } + if (Date.now() - event.origin_server_ts > 30000) { + /* We ignore old messages too */ + return; + } + log.info( + `Got message roomId=${roomId} type=${event.type} from=${event.sender}`, + ); + log.debug("Content:", JSON.stringify(event)); + let processedReply; + let processedReplyMetadata: IRichReplyMetadata | undefined = undefined; + try { + processedReply = await this.replyProcessor.processEvent( + event, + this.as.botClient, + EventKind.RoomEvent, + ); + processedReplyMetadata = processedReply?.mx_richreply; + } catch (ex) { + log.warn( + `Event ${event.event_id} could not be processed by the reply processor, possibly a faulty event`, + ex, + ); + } + const adminRoom = this.adminRooms.get(roomId); + const checkPermission = (service: string, level: BridgePermissionLevel) => + this.config.checkPermission(event.sender, service, level); + + 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 { - processedReply = await this.replyProcessor.processEvent(event, this.as.botClient, EventKind.RoomEvent); - processedReplyMetadata = processedReply?.mx_richreply; + if (connection.onMessageEvent) { + handled = await connection.onMessageEvent( + event, + checkPermission, + processedReplyMetadata, + ); + } } catch (ex) { - log.warn(`Event ${event.event_id} could not be processed by the reply processor, possibly a faulty event`, ex); + log.warn( + `Connection ${connection.toString()} failed to handle message:`, + ex, + ); + Sentry.captureException(ex, scope); } - const adminRoom = this.adminRooms.get(roomId); - const checkPermission = (service: string, level: BridgePermissionLevel) => this.config.checkPermission(event.sender, service, level); - - 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; - } - } - if (!handled && this.config.checkPermissionAny(event.sender, BridgePermissionLevel.manageConnections)) { - // Divert to the setup room code if we didn't match any of these - - const botUsersInRoom = this.botUsersManager.getBotUsersInRoom(roomId); - // Try each bot in the room until one handles the command - for (const botUser of botUsersInRoom) { - try { - const setupConnection = new SetupConnection( - roomId, - botUser.prefix, - botUser.services, - [ - ...botUser.services, - this.config.widgets?.roomSetupWidget ? "widget" : "", - ], - { - config: this.config, - as: this.as, - intent: botUser.intent, - tokenStore: this.tokenStore, - commentProcessor: this.commentProcessor, - messageClient: this.messageClient, - storage: this.storage, - github: this.github, - getAllConnectionsOfType: this.connectionManager.getAllConnectionsOfType.bind(this.connectionManager), - }, - this.getOrCreateAdminRoom.bind(this), - this.connectionManager.push.bind(this.connectionManager), - ); - const handled = await setupConnection.onMessageEvent(event, checkPermission); - if (handled) { - break; - } - } catch (ex) { - log.warn(`Setup connection failed to handle:`, ex); - } - } - } - return; - } - - if (adminRoom.userId !== event.sender) { - return; + if (handled) { + break; } - - if (processedReply && processedReplyMetadata) { - log.info(`Handling reply to ${processedReplyMetadata.parentEventId} for ${adminRoom.userId}`); - // This might be a reply to a notification - try { - const ev = processedReplyMetadata.realEvent; - const splitParts: string[] = ev.content["uk.half-shot.matrix-hookshot.github.repo"]?.name.split("/"); - const issueNumber = ev.content["uk.half-shot.matrix-hookshot.github.issue"]?.number; - if (splitParts && issueNumber) { - log.info(`Handling reply for ${splitParts}${issueNumber}`); - const connections = this.connectionManager.getConnectionsForGithubIssue(splitParts[0], splitParts[1], issueNumber); - await Promise.all(connections.map(async c => { - if (c instanceof GitHubIssueConnection) { - return c.onMatrixIssueComment(processedReply); - } - })); - } else { - log.info("Missing parts!:", splitParts, issueNumber); - } - } catch (ex) { - await adminRoom.sendNotice("Failed to handle reply. You may not be authenticated to do that."); - log.error("Reply event could not be handled:", ex); + } + if ( + !handled && + this.config.checkPermissionAny( + event.sender, + BridgePermissionLevel.manageConnections, + ) + ) { + // Divert to the setup room code if we didn't match any of these + + const botUsersInRoom = this.botUsersManager.getBotUsersInRoom(roomId); + // Try each bot in the room until one handles the command + for (const botUser of botUsersInRoom) { + try { + const setupConnection = new SetupConnection( + roomId, + botUser.prefix, + botUser.services, + [ + ...botUser.services, + this.config.widgets?.roomSetupWidget ? "widget" : "", + ], + { + config: this.config, + as: this.as, + intent: botUser.intent, + tokenStore: this.tokenStore, + commentProcessor: this.commentProcessor, + messageClient: this.messageClient, + storage: this.storage, + github: this.github, + getAllConnectionsOfType: + this.connectionManager.getAllConnectionsOfType.bind( + this.connectionManager, + ), + }, + this.getOrCreateAdminRoom.bind(this), + this.connectionManager.push.bind(this.connectionManager), + ); + const handled = await setupConnection.onMessageEvent( + event, + checkPermission, + ); + if (handled) { + break; } - return; + } catch (ex) { + log.warn(`Setup connection failed to handle:`, ex); + } } - - const command = event.content.body; - if (command) { - await adminRoom.handleCommand(event.event_id, command); - } - + } + return; } - private async onRoomJoin(roomId: string, matrixEvent: MatrixEvent) { - const userId = matrixEvent.state_key; - if (!userId) { - return; - } - - const botUser = this.botUsersManager.getBotUser(userId); - if (!botUser) { - // Not for one of our bots - return; - } - this.botUsersManager.onRoomJoin(botUser, roomId); - - if (this.config.encryption) { - // Ensure crypto is aware of all members of this room before posting any messages, - // so that the bot can share room keys to all recipients first. - await botUser.intent.underlyingClient.crypto.onRoomJoin(roomId); - } + if (adminRoom.userId !== event.sender) { + return; + } - const adminAccountData = await botUser.intent.underlyingClient.getSafeRoomAccountData( - BRIDGE_ROOM_TYPE, roomId, - ); - if (adminAccountData) { - const room = await this.setUpAdminRoom(botUser.intent, roomId, adminAccountData, NotifFilter.getDefaultContent()); - await botUser.intent.underlyingClient.setRoomAccountData( - BRIDGE_ROOM_TYPE, roomId, room.accountData, + if (processedReply && processedReplyMetadata) { + log.info( + `Handling reply to ${processedReplyMetadata.parentEventId} for ${adminRoom.userId}`, + ); + // This might be a reply to a notification + try { + const ev = processedReplyMetadata.realEvent; + const splitParts: string[] = + ev.content["uk.half-shot.matrix-hookshot.github.repo"]?.name.split( + "/", + ); + const issueNumber = + ev.content["uk.half-shot.matrix-hookshot.github.issue"]?.number; + if (splitParts && issueNumber) { + log.info(`Handling reply for ${splitParts}${issueNumber}`); + const connections = + this.connectionManager.getConnectionsForGithubIssue( + splitParts[0], + splitParts[1], + issueNumber, ); + await Promise.all( + connections.map(async (c) => { + if (c instanceof GitHubIssueConnection) { + return c.onMatrixIssueComment(processedReply); + } + }), + ); + } else { + log.info("Missing parts!:", splitParts, issueNumber); } + } catch (ex) { + await adminRoom.sendNotice( + "Failed to handle reply. You may not be authenticated to do that.", + ); + log.error("Reply event could not be handled:", ex); + } + return; + } - if (!this.connectionManager) { - // Not ready yet. - return; - } + const command = event.content.body; + if (command) { + await adminRoom.handleCommand(event.event_id, command); + } + } + + private async onRoomJoin( + roomId: string, + matrixEvent: MatrixEvent, + ) { + const userId = matrixEvent.state_key; + if (!userId) { + return; + } - // Recreate connections for the room - await this.connectionManager.removeConnectionsForRoom(roomId); - await this.connectionManager.createConnectionsForRoomId(roomId, true); + const botUser = this.botUsersManager.getBotUser(userId); + if (!botUser) { + // Not for one of our bots + return; + } + this.botUsersManager.onRoomJoin(botUser, roomId); - // Only fetch rooms we have no connections in yet. - const roomHasConnection = this.connectionManager.isRoomConnected(roomId); + if (this.config.encryption) { + // Ensure crypto is aware of all members of this room before posting any messages, + // so that the bot can share room keys to all recipients first. + await botUser.intent.underlyingClient.crypto.onRoomJoin(roomId); + } - // If room has connections or is an admin room, don't set up a wizard. - // Otherwise it's a new room - if (!roomHasConnection && !adminAccountData && this.config.widgets?.roomSetupWidget?.addOnInvite) { - try { - const hasPowerlevel = await botUser.intent.underlyingClient.userHasPowerLevelFor( - botUser.intent.userId, - roomId, - "im.vector.modular.widgets", - true, - ); - if (!hasPowerlevel) { - await botUser.intent.sendText(roomId, "Hello! To set up new integrations in this room, please promote me to a Moderator/Admin."); - } else { - // Set up the widget - await SetupWidget.SetupRoomConfigWidget(roomId, botUser.intent, this.config.widgets, botUser.services); - } - } catch (ex) { - log.error(`Failed to setup new widget for room`, ex); - } - } + const adminAccountData = + await botUser.intent.underlyingClient.getSafeRoomAccountData( + BRIDGE_ROOM_TYPE, + roomId, + ); + if (adminAccountData) { + const room = await this.setUpAdminRoom( + botUser.intent, + roomId, + adminAccountData, + NotifFilter.getDefaultContent(), + ); + await botUser.intent.underlyingClient.setRoomAccountData( + BRIDGE_ROOM_TYPE, + roomId, + room.accountData, + ); } - private async onRoomEvent(roomId: string, event: MatrixEvent>) { - if (!this.connectionManager) { - // Not ready yet. - return; - } - if (event.state_key !== undefined) { - if (event.type === "m.room.member") { - if (event.content.membership === "join") { - this.config.addMemberToCache(roomId, event.state_key); - } else { - this.config.removeMemberFromCache(roomId, event.state_key); - } - return; - } - // A state update, hurrah! - const existingConnections = this.connectionManager.getInterestedForRoomState(roomId, event.type, event.state_key); - const state = new StateEvent(event); - for (const connection of existingConnections) { - 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) { - await this.connectionManager.purgeConnection(connection.roomId, connection.connectionId, false); - } else { - await connection.onStateUpdate?.(event); - } - try { - await this.as.botClient.sendReadReceipt(connection.roomId, event.event_id); - } catch { - // Nonessentail - } - } catch (ex) { - log.warn(`Connection ${connection.toString()} for ${roomId} failed to handle state update:`, ex); - } - } - if (!existingConnections.length) { - // Is anyone interested in this state? - try { - const connection = await this.connectionManager.createConnectionForState(roomId, new StateEvent(event), true); - if (connection) { - log.info(`New connected added to ${roomId}: ${connection.toString()}`); - this.connectionManager.push(connection); - } - } catch (ex) { - log.error(`Failed to handle connection for state ${event.type} in ${roomId}`, ex); - } - } + if (!this.connectionManager) { + // Not ready yet. + return; + } - const botUsersInRoom = this.botUsersManager.getBotUsersInRoom(roomId); - for (const botUser of botUsersInRoom) { - // If it's a power level event for a new room, we might want to create the setup widget. - if (this.config.widgets?.roomSetupWidget?.addOnInvite && event.type === "m.room.power_levels" && event.state_key === "" && !this.connectionManager.isRoomConnected(roomId)) { - log.debug(`${roomId} got a new powerlevel change and isn't connected to any connections, testing to see if we should create a setup widget`) - const plEvent = new PowerLevelsEvent(event); - const currentPl = plEvent.content.users?.[botUser.userId] ?? plEvent.defaultUserLevel; - const previousPl = plEvent.previousContent?.users?.[botUser.userId] ?? plEvent.previousContent?.users_default; - const requiredPl = plEvent.content.events?.["im.vector.modular.widgets"] ?? plEvent.defaultStateEventLevel; - if (currentPl !== previousPl && currentPl >= requiredPl) { - // PL changed for bot user, check to see if the widget can be created. - try { - log.info(`Bot has powerlevel required to create a setup widget, attempting`); - await SetupWidget.SetupRoomConfigWidget(roomId, botUser.intent, this.config.widgets, botUser.services); - } catch (ex) { - log.error(`Failed to create setup widget for ${roomId}`, ex); - } - } - } - } - return; - } + // Recreate connections for the room + await this.connectionManager.removeConnectionsForRoom(roomId); + await this.connectionManager.createConnectionsForRoomId(roomId, true); - // We still want to react to our own state events. - if (this.botUsersManager.isBotUser(event.sender)) { - // It's us - return; - } + // Only fetch rooms we have no connections in yet. + const roomHasConnection = this.connectionManager.isRoomConnected(roomId); - 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 { - await connection.onEvent(event); - } catch (ex) { - Sentry.captureException(ex, scope); - log.warn(`Connection ${connection.toString()} failed to handle onEvent:`, ex); - } + // If room has connections or is an admin room, don't set up a wizard. + // Otherwise it's a new room + if ( + !roomHasConnection && + !adminAccountData && + this.config.widgets?.roomSetupWidget?.addOnInvite + ) { + try { + const hasPowerlevel = + await botUser.intent.underlyingClient.userHasPowerLevelFor( + botUser.intent.userId, + roomId, + "im.vector.modular.widgets", + true, + ); + if (!hasPowerlevel) { + await botUser.intent.sendText( + roomId, + "Hello! To set up new integrations in this room, please promote me to a Moderator/Admin.", + ); + } else { + // Set up the widget + await SetupWidget.SetupRoomConfigWidget( + roomId, + botUser.intent, + this.config.widgets, + botUser.services, + ); } + } catch (ex) { + log.error(`Failed to setup new widget for room`, ex); + } } - - private async onQueryRoom(roomAlias: string) { - log.info("Got room query request:", roomAlias); - // Determine which type of room it is. - let res: RegExpExecArray | null; - res = GitHubIssueConnection.QueryRoomRegex.exec(roomAlias); - if (res) { - if (!this.github) { - throw Error("GitHub is not configured on this bridge"); - } - try { - return await GitHubIssueConnection.onQueryRoom(res, { - as: this.as, - tokenStore: this.tokenStore, - messageClient: this.messageClient, - commentProcessor: this.commentProcessor, - githubInstance: this.github, - }); - } catch (ex) { - log.error(`Could not handle alias with GitHubIssueConnection`, ex); - throw ex; - } + } + + private async onRoomEvent( + roomId: string, + event: MatrixEvent>, + ) { + if (!this.connectionManager) { + // Not ready yet. + return; + } + if (event.state_key !== undefined) { + if (event.type === "m.room.member") { + if (event.content.membership === "join") { + this.config.addMemberToCache(roomId, event.state_key); + } else { + this.config.removeMemberFromCache(roomId, event.state_key); } - - res = GitHubDiscussionSpace.QueryRoomRegex.exec(roomAlias); - if (res) { - if (!this.github) { - throw Error("GitHub is not configured on this bridge"); - } - try { - return await GitHubDiscussionSpace.onQueryRoom(res, { - githubInstance: this.github, - as: this.as, - }); - } catch (ex) { - log.error(`Could not handle alias with GitHubRepoConnection`, ex); - throw ex; - } + return; + } + // A state update, hurrah! + const existingConnections = + this.connectionManager.getInterestedForRoomState( + roomId, + event.type, + event.state_key, + ); + const state = new StateEvent(event); + for (const connection of existingConnections) { + if ( + !this.connectionManager.verifyStateEventForConnection( + connection, + state, + true, + ) + ) { + continue; } - - res = GitHubRepoConnection.QueryRoomRegex.exec(roomAlias); - if (res) { - if (!this.github) { - throw Error("GitHub is not configured on this bridge"); - } - try { - return await GitHubRepoConnection.onQueryRoom(res, { - as: this.as, - tokenStore: this.tokenStore, - messageClient: this.messageClient, - commentProcessor: this.commentProcessor, - githubInstance: this.github, - }); - } catch (ex) { - log.error(`Could not handle alias with GitHubRepoConnection`, ex); - throw ex; - } + 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 + ) { + await this.connectionManager.purgeConnection( + connection.roomId, + connection.connectionId, + false, + ); + } else { + await connection.onStateUpdate?.(event); + } + try { + await this.as.botClient.sendReadReceipt( + connection.roomId, + event.event_id, + ); + } catch { + // Nonessentail + } + } catch (ex) { + log.warn( + `Connection ${connection.toString()} for ${roomId} failed to handle state update:`, + ex, + ); } - - res = GitHubUserSpace.QueryRoomRegex.exec(roomAlias); - if (res) { - if (!this.github) { - throw Error("GitHub is not configured on this bridge"); - } + } + if (!existingConnections.length) { + // Is anyone interested in this state? + try { + const connection = + await this.connectionManager.createConnectionForState( + roomId, + new StateEvent(event), + true, + ); + if (connection) { + log.info( + `New connected added to ${roomId}: ${connection.toString()}`, + ); + this.connectionManager.push(connection); + } + } catch (ex) { + log.error( + `Failed to handle connection for state ${event.type} in ${roomId}`, + ex, + ); + } + } + + const botUsersInRoom = this.botUsersManager.getBotUsersInRoom(roomId); + for (const botUser of botUsersInRoom) { + // If it's a power level event for a new room, we might want to create the setup widget. + if ( + this.config.widgets?.roomSetupWidget?.addOnInvite && + event.type === "m.room.power_levels" && + event.state_key === "" && + !this.connectionManager.isRoomConnected(roomId) + ) { + log.debug( + `${roomId} got a new powerlevel change and isn't connected to any connections, testing to see if we should create a setup widget`, + ); + const plEvent = new PowerLevelsEvent(event); + const currentPl = + plEvent.content.users?.[botUser.userId] ?? plEvent.defaultUserLevel; + const previousPl = + plEvent.previousContent?.users?.[botUser.userId] ?? + plEvent.previousContent?.users_default; + const requiredPl = + plEvent.content.events?.["im.vector.modular.widgets"] ?? + plEvent.defaultStateEventLevel; + if (currentPl !== previousPl && currentPl >= requiredPl) { + // PL changed for bot user, check to see if the widget can be created. try { - return await GitHubUserSpace.onQueryRoom(res, { - githubInstance: this.github, - as: this.as, - }); + log.info( + `Bot has powerlevel required to create a setup widget, attempting`, + ); + await SetupWidget.SetupRoomConfigWidget( + roomId, + botUser.intent, + this.config.widgets, + botUser.services, + ); } catch (ex) { - log.error(`Could not handle alias with GitHubRepoConnection`, ex); - throw ex; + log.error(`Failed to create setup widget for ${roomId}`, ex); } + } } - - throw Error('No regex matching query pattern'); + } + return; } - private async onAdminRoomSettingsChanged(adminRoom: AdminRoom, settings: AdminAccountData, oldSettings: AdminAccountData) { - log.debug(`Settings changed for ${adminRoom.userId}`, settings); - // Make this more efficent. - if (!oldSettings.github?.notifications?.enabled && settings.github?.notifications?.enabled) { - log.info(`Notifications enabled for ${adminRoom.userId}`); - const token = await this.tokenStore.getGitHubToken(adminRoom.userId); - if (token) { - log.info(`Notifications enabled for ${adminRoom.userId} and token was found`); - await this.queue.push({ - eventName: "notifications.user.enable", - sender: "Bridge", - data: { - userId: adminRoom.userId, - roomId: adminRoom.roomId, - token, - since: await adminRoom.getNotifSince("github"), - filterParticipating: adminRoom.notificationsParticipating("github"), - type: "github", - instanceUrl: undefined, - }, - }); - } else { - log.warn(`Notifications enabled for ${adminRoom.userId} but no token stored!`); - } - } else if (oldSettings.github?.notifications?.enabled && !settings.github?.notifications?.enabled) { - await this.queue.push({ - eventName: "notifications.user.disable", - sender: "Bridge", - data: { - userId: adminRoom.userId, - type: "github", - instanceUrl: undefined, - }, - }); - } + // We still want to react to our own state events. + if (this.botUsersManager.isBotUser(event.sender)) { + // It's us + return; + } - for (const [instanceName, instanceSettings] of Object.entries(settings.gitlab || {})) { - const instanceUrl = this.config.gitlab?.instances[instanceName].url; - const token = await this.tokenStore.getUserToken("gitlab", adminRoom.userId, instanceUrl); - if (token && instanceSettings.notifications.enabled) { - log.info(`GitLab ${instanceName} notifications enabled for ${adminRoom.userId}`); - await this.queue.push({ - eventName: "notifications.user.enable", - sender: "Bridge", - data: { - userId: adminRoom.userId, - roomId: adminRoom.roomId, - token, - since: await adminRoom.getNotifSince("gitlab", instanceName), - filterParticipating: adminRoom.notificationsParticipating("gitlab"), - type: "gitlab", - instanceUrl, - }, - }); - } else if (!instanceSettings.notifications.enabled) { - log.info(`GitLab ${instanceName} notifications disabled for ${adminRoom.userId}`); - await this.queue.push({ - eventName: "notifications.user.disable", - sender: "Bridge", - data: { - userId: adminRoom.userId, - type: "gitlab", - instanceUrl, - }, - }); - } - } + 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 { + await connection.onEvent(event); + } catch (ex) { + Sentry.captureException(ex, scope); + log.warn( + `Connection ${connection.toString()} failed to handle onEvent:`, + ex, + ); + } + } + } + + private async onQueryRoom(roomAlias: string) { + log.info("Got room query request:", roomAlias); + // Determine which type of room it is. + let res: RegExpExecArray | null; + res = GitHubIssueConnection.QueryRoomRegex.exec(roomAlias); + if (res) { + if (!this.github) { + throw Error("GitHub is not configured on this bridge"); + } + try { + return await GitHubIssueConnection.onQueryRoom(res, { + as: this.as, + tokenStore: this.tokenStore, + messageClient: this.messageClient, + commentProcessor: this.commentProcessor, + githubInstance: this.github, + }); + } catch (ex) { + log.error(`Could not handle alias with GitHubIssueConnection`, ex); + throw ex; + } + } + res = GitHubDiscussionSpace.QueryRoomRegex.exec(roomAlias); + if (res) { + if (!this.github) { + throw Error("GitHub is not configured on this bridge"); + } + try { + return await GitHubDiscussionSpace.onQueryRoom(res, { + githubInstance: this.github, + as: this.as, + }); + } catch (ex) { + log.error(`Could not handle alias with GitHubRepoConnection`, ex); + throw ex; + } } - private async getOrCreateAdminRoom(intent: Intent, userId: string): Promise { - const existingRoom = this.getAdminRoomForUser(userId); - if (existingRoom) { - return existingRoom; - } - const roomId = await intent.underlyingClient.dms.getOrCreateDm(userId); - const room = await this.setUpAdminRoom(intent, roomId, {admin_user: userId}, NotifFilter.getDefaultContent()); - await this.as.botClient.setRoomAccountData( - BRIDGE_ROOM_TYPE, roomId, room.accountData, - ); - return room; + res = GitHubRepoConnection.QueryRoomRegex.exec(roomAlias); + if (res) { + if (!this.github) { + throw Error("GitHub is not configured on this bridge"); + } + try { + return await GitHubRepoConnection.onQueryRoom(res, { + as: this.as, + tokenStore: this.tokenStore, + messageClient: this.messageClient, + commentProcessor: this.commentProcessor, + githubInstance: this.github, + }); + } catch (ex) { + log.error(`Could not handle alias with GitHubRepoConnection`, ex); + throw ex; + } } - private getAdminRoomForUser(userId: string): AdminRoom|null { - for (const adminRoom of this.adminRooms.values()) { - if (adminRoom.userId === userId) { - return adminRoom; - } - } - return null; + res = GitHubUserSpace.QueryRoomRegex.exec(roomAlias); + if (res) { + if (!this.github) { + throw Error("GitHub is not configured on this bridge"); + } + try { + return await GitHubUserSpace.onQueryRoom(res, { + githubInstance: this.github, + as: this.as, + }); + } catch (ex) { + log.error(`Could not handle alias with GitHubRepoConnection`, ex); + throw ex; + } } - private async setUpAdminRoom( - intent: Intent, - roomId: string, - accountData: AdminAccountData, - notifContent: NotificationFilterStateContent, + throw Error("No regex matching query pattern"); + } + + private async onAdminRoomSettingsChanged( + adminRoom: AdminRoom, + settings: AdminAccountData, + oldSettings: AdminAccountData, + ) { + log.debug(`Settings changed for ${adminRoom.userId}`, settings); + // Make this more efficent. + if ( + !oldSettings.github?.notifications?.enabled && + settings.github?.notifications?.enabled ) { - if (!this.connectionManager) { - throw Error('setUpAdminRoom() called before connectionManager was ready'); - } - - const adminRoom = new AdminRoom( - roomId, accountData, notifContent, intent, this.tokenStore, this.config, this.connectionManager, + log.info(`Notifications enabled for ${adminRoom.userId}`); + const token = await this.tokenStore.getGitHubToken(adminRoom.userId); + if (token) { + log.info( + `Notifications enabled for ${adminRoom.userId} and token was found`, ); + await this.queue.push({ + eventName: "notifications.user.enable", + sender: "Bridge", + data: { + userId: adminRoom.userId, + roomId: adminRoom.roomId, + token, + since: await adminRoom.getNotifSince("github"), + filterParticipating: adminRoom.notificationsParticipating("github"), + type: "github", + instanceUrl: undefined, + }, + }); + } else { + log.warn( + `Notifications enabled for ${adminRoom.userId} but no token stored!`, + ); + } + } else if ( + oldSettings.github?.notifications?.enabled && + !settings.github?.notifications?.enabled + ) { + await this.queue.push({ + eventName: "notifications.user.disable", + sender: "Bridge", + data: { + userId: adminRoom.userId, + type: "github", + instanceUrl: undefined, + }, + }); + } - adminRoom.on("settings.changed", this.onAdminRoomSettingsChanged.bind(this)); - adminRoom.on("open.project", async (project: ProjectsGetResponseData) => { - const [connection] = this.connectionManager?.getForGitHubProject(project.id) || []; - if (!connection) { - const connection = await GitHubProjectConnection.onOpenProject(project, this.as, intent, this.config, adminRoom.userId); - this.connectionManager?.push(connection); - } else { - await intent.underlyingClient.inviteUser(adminRoom.userId, connection.roomId); - } + for (const [instanceName, instanceSettings] of Object.entries( + settings.gitlab || {}, + )) { + const instanceUrl = this.config.gitlab?.instances[instanceName].url; + const token = await this.tokenStore.getUserToken( + "gitlab", + adminRoom.userId, + instanceUrl, + ); + if (token && instanceSettings.notifications.enabled) { + log.info( + `GitLab ${instanceName} notifications enabled for ${adminRoom.userId}`, + ); + await this.queue.push({ + eventName: "notifications.user.enable", + sender: "Bridge", + data: { + userId: adminRoom.userId, + roomId: adminRoom.roomId, + token, + since: await adminRoom.getNotifSince("gitlab", instanceName), + filterParticipating: adminRoom.notificationsParticipating("gitlab"), + type: "gitlab", + instanceUrl, + }, }); - adminRoom.on("open.gitlab-issue", async (issueInfo: GetIssueOpts, res: GetIssueResponse, instanceName: string, instance: GitLabInstance) => { - if (!this.config.gitlab) { - return; - } - const [ connection ] = this.connectionManager?.getConnectionsForGitLabIssue(instance, issueInfo.projects, issueInfo.issue) || []; - if (connection) { - return intent.underlyingClient.inviteUser(adminRoom.userId, connection.roomId); - } - const newConnection = await GitLabIssueConnection.createRoomForIssue( - instanceName, - instance, - res, - issueInfo.projects, - this.as, - intent, - this.tokenStore, - this.commentProcessor, - this.messageClient, - this.config, - ); - this.connectionManager?.push(newConnection); - return intent.underlyingClient.inviteUser(adminRoom.userId, newConnection.roomId); + } else if (!instanceSettings.notifications.enabled) { + log.info( + `GitLab ${instanceName} notifications disabled for ${adminRoom.userId}`, + ); + await this.queue.push({ + eventName: "notifications.user.disable", + sender: "Bridge", + data: { + userId: adminRoom.userId, + type: "gitlab", + instanceUrl, + }, }); - this.adminRooms.set(roomId, adminRoom); - if (this.config.widgets?.addToAdminRooms) { - await SetupWidget.SetupAdminRoomConfigWidget(roomId, intent, this.config.widgets); - } - log.debug(`Set up ${roomId} as an admin room for ${adminRoom.userId}`); + } + } + } + + private async getOrCreateAdminRoom( + intent: Intent, + userId: string, + ): Promise { + const existingRoom = this.getAdminRoomForUser(userId); + if (existingRoom) { + return existingRoom; + } + const roomId = await intent.underlyingClient.dms.getOrCreateDm(userId); + const room = await this.setUpAdminRoom( + intent, + roomId, + { admin_user: userId }, + NotifFilter.getDefaultContent(), + ); + await this.as.botClient.setRoomAccountData( + BRIDGE_ROOM_TYPE, + roomId, + room.accountData, + ); + return room; + } + + private getAdminRoomForUser(userId: string): AdminRoom | null { + for (const adminRoom of this.adminRooms.values()) { + if (adminRoom.userId === userId) { return adminRoom; + } + } + return null; + } + + private async setUpAdminRoom( + intent: Intent, + roomId: string, + accountData: AdminAccountData, + notifContent: NotificationFilterStateContent, + ) { + if (!this.connectionManager) { + throw Error("setUpAdminRoom() called before connectionManager was ready"); } - private onTokenUpdated(type: string, userId: string, token: string, instanceUrl?: string) { - let instanceName: string|undefined; - if (type === "gitlab") { - // TODO: Refactor our API to depend on either instanceUrl or instanceName. - instanceName = Object.entries(this.config.gitlab?.instances || {}).find(i => i[1].url === instanceUrl)?.[0]; - } else if (type === "github") { - // GitHub tokens are special - token = UserTokenStore.parseGitHubToken(token).access_token; - } else { - return; + const adminRoom = new AdminRoom( + roomId, + accountData, + notifContent, + intent, + this.tokenStore, + this.config, + this.connectionManager, + ); + + adminRoom.on( + "settings.changed", + this.onAdminRoomSettingsChanged.bind(this), + ); + adminRoom.on("open.project", async (project: ProjectsGetResponseData) => { + const [connection] = + this.connectionManager?.getForGitHubProject(project.id) || []; + if (!connection) { + const connection = await GitHubProjectConnection.onOpenProject( + project, + this.as, + intent, + this.config, + adminRoom.userId, + ); + this.connectionManager?.push(connection); + } else { + await intent.underlyingClient.inviteUser( + adminRoom.userId, + connection.roomId, + ); + } + }); + adminRoom.on( + "open.gitlab-issue", + async ( + issueInfo: GetIssueOpts, + res: GetIssueResponse, + instanceName: string, + instance: GitLabInstance, + ) => { + if (!this.config.gitlab) { + return; } - for (const adminRoom of this.adminRooms.values()) { - if (adminRoom.userId !== userId) continue; - if (adminRoom?.notificationsEnabled(type, instanceName)) { - log.debug(`Token was updated for ${userId} (${type}), notifying notification watcher`); - this.queue.push({ - eventName: "notifications.user.enable", - sender: "Bridge", - data: { - userId: adminRoom.userId, - roomId: adminRoom.roomId, - token, - filterParticipating: adminRoom.notificationsParticipating("github"), - type, - instanceUrl, - }, - }).catch(ex => log.error(`Failed to push notifications.user.enable:`, ex)); - } + const [connection] = + this.connectionManager?.getConnectionsForGitLabIssue( + instance, + issueInfo.projects, + issueInfo.issue, + ) || []; + if (connection) { + return intent.underlyingClient.inviteUser( + adminRoom.userId, + connection.roomId, + ); } + const newConnection = await GitLabIssueConnection.createRoomForIssue( + instanceName, + instance, + res, + issueInfo.projects, + this.as, + intent, + this.tokenStore, + this.commentProcessor, + this.messageClient, + this.config, + ); + this.connectionManager?.push(newConnection); + return intent.underlyingClient.inviteUser( + adminRoom.userId, + newConnection.roomId, + ); + }, + ); + this.adminRooms.set(roomId, adminRoom); + if (this.config.widgets?.addToAdminRooms) { + await SetupWidget.SetupAdminRoomConfigWidget( + roomId, + intent, + this.config.widgets, + ); + } + log.debug(`Set up ${roomId} as an admin room for ${adminRoom.userId}`); + return adminRoom; + } + + private onTokenUpdated( + type: string, + userId: string, + token: string, + instanceUrl?: string, + ) { + let instanceName: string | undefined; + if (type === "gitlab") { + // TODO: Refactor our API to depend on either instanceUrl or instanceName. + instanceName = Object.entries(this.config.gitlab?.instances || {}).find( + (i) => i[1].url === instanceUrl, + )?.[0]; + } else if (type === "github") { + // GitHub tokens are special + token = UserTokenStore.parseGitHubToken(token).access_token; + } else { + return; + } + for (const adminRoom of this.adminRooms.values()) { + if (adminRoom.userId !== userId) continue; + if (adminRoom?.notificationsEnabled(type, instanceName)) { + log.debug( + `Token was updated for ${userId} (${type}), notifying notification watcher`, + ); + this.queue + .push({ + eventName: "notifications.user.enable", + sender: "Bridge", + data: { + userId: adminRoom.userId, + roomId: adminRoom.roomId, + token, + filterParticipating: + adminRoom.notificationsParticipating("github"), + type, + instanceUrl, + }, + }) + .catch((ex) => + log.error(`Failed to push notifications.user.enable:`, ex), + ); + } } + } } diff --git a/src/CommentProcessor.ts b/src/CommentProcessor.ts index 19e60bc18..d252823c6 100644 --- a/src/CommentProcessor.ts +++ b/src/CommentProcessor.ts @@ -5,161 +5,203 @@ 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; -const REGEX_MATRIX_MENTION = /(.*)<\/a>/gmi; +const REGEX_MENTION = /(^|\s)(@[a-z\d](?:[a-z\d]|-(?=[a-z\d])){0,38})(\s|$)/gi; +const REGEX_MATRIX_MENTION = + /(.*)<\/a>/gim; const REGEX_IMAGES = /!\[.*]\((.*\.(\w+))\)/gm; const md = new markdown(); const log = new Logger("CommentProcessor"); -const mime = import('mime'); +const mime = import("mime"); interface IMatrixCommentEvent extends MatrixMessageContent { - external_url: string; - "uk.half-shot.matrix-hookshot.github.comment": { - id: number; - }; + external_url: string; + "uk.half-shot.matrix-hookshot.github.comment": { + id: number; + }; } export class CommentProcessor { - private processedComments = new Set(); - private processedEvents = new Set(); - - constructor(private as: Appservice, private mediaUrl: string) {} - - public hasCommentBeenProcessed(org: string, repo: string, issue: string, id: number) { - return this.processedComments.has(`${org}/${repo}#${issue}~${id}`.toLowerCase()); + private processedComments = new Set(); + private processedEvents = new Set(); + + constructor( + private as: Appservice, + private mediaUrl: string, + ) {} + + public hasCommentBeenProcessed( + org: string, + repo: string, + issue: string, + id: number, + ) { + return this.processedComments.has( + `${org}/${repo}#${issue}~${id}`.toLowerCase(), + ); + } + + public markCommentAsProcessed( + org: string, + repo: string, + issue: string, + id: number, + ) { + this.processedComments.add(`${org}/${repo}#${issue}~${id}`.toLowerCase()); + } + + public hasEventBeenProcessed(roomId: string, eventId: string) { + return this.processedEvents.has(`${roomId}/${eventId}`); + } + + public markEventAsProcessed(roomId: string, eventId: string) { + this.processedEvents.add(`${roomId}/${eventId}`); + } + + public async getCommentBodyForEvent( + event: MatrixEvent, + asBot: boolean, + ): Promise { + let body = event.content.body; + body = await this.replaceImages(body, false); + if (event.content.formatted_body) { + body = this.replaceMatrixMentions(body, event.content.formatted_body); } - - public markCommentAsProcessed(org: string, repo: string, issue: string, id: number) { - this.processedComments.add(`${org}/${repo}#${issue}~${id}`.toLowerCase()); + if (asBot) { + body = `[${event.sender}](https://matrix.to/#/${event.sender}): ${body}`; } - - public hasEventBeenProcessed(roomId: string, eventId: string) { - return this.processedEvents.has(`${roomId}/${eventId}`); + return body; + } + + public async getEventBodyForGitHubComment( + comment: IssuesGetCommentResponseData, + repo?: ReposGetResponseData, + issue?: IssuesGetResponseData, + ): Promise { + if (!comment.body) { + return undefined; } - - public markEventAsProcessed(roomId: string, eventId: string) { - this.processedEvents.add(`${roomId}/${eventId}`); - } - - public async getCommentBodyForEvent(event: MatrixEvent, asBot: boolean): Promise { - let body = event.content.body; - body = await this.replaceImages(body, false); - if (event.content.formatted_body) { - body = this.replaceMatrixMentions(body, event.content.formatted_body); - } - if (asBot) { - body = `[${event.sender}](https://matrix.to/#/${event.sender}): ${body}` - } - return body; + let body = comment.body; + body = this.replaceMentions(body); + body = await this.replaceImages(body, true); + body = emoji.emojify(body); + const htmlBody = md.render(body); + return { + body, + formatted_body: htmlBody, + msgtype: "m.text", + format: "org.matrix.custom.html", + ...FormatUtil.getPartialBodyForComment(comment, repo, issue), + }; + } + + public async getEventBodyForGitLabNote( + comment: IGitLabWebhookNoteEvent, + ): Promise { + let body = comment.object_attributes.note; + body = this.replaceMentions(body); + body = await this.replaceImages(body, true); + body = emoji.emojify(body); + const htmlBody = md.render(body); + return { + body, + formatted_body: htmlBody, + msgtype: "m.text", + format: "org.matrix.custom.html", + // ...FormatUtil.getPartialBodyForComment(comment, repo, issue) + }; + } + + private replaceMentions(body: string): string { + return body.replace( + REGEX_MENTION, + (match: string, part1: string, githubId: string) => { + const userId = this.as.getUserIdForSuffix(githubId.substr(1)); + return `[${githubId}](https://matrix.to/#/${userId})`; + }, + ); + } + + private replaceMatrixMentions(body: string, formattedBody: string): string { + let match; + let bodyCopy = formattedBody; + const mentionMatches: string[][] = []; + match = REGEX_MATRIX_MENTION.exec(bodyCopy); + while (match) { + bodyCopy = bodyCopy.replace(match[0], ""); + mentionMatches.push([match[2], match[1]]); + match = REGEX_MATRIX_MENTION.exec(bodyCopy); } - public async getEventBodyForGitHubComment(comment: IssuesGetCommentResponseData, - repo?: ReposGetResponseData, - issue?: IssuesGetResponseData): Promise { - if (!comment.body) { - return undefined; + for (const [full, userId] of mentionMatches) { + if (this.as.isNamespacedUser(userId)) { + // XXX: Prefix hack + const githubId = userId.split(":")[0].substr("@_github_".length); + if (!githubId) { + continue; } - let body = comment.body; - body = this.replaceMentions(body); - body = await this.replaceImages(body, true); - body = emoji.emojify(body); - const htmlBody = md.render(body); - return { - body, - formatted_body: htmlBody, - msgtype: "m.text", - format: "org.matrix.custom.html", - ...FormatUtil.getPartialBodyForComment(comment, repo, issue) - }; + body = body.replace(full, `@${githubId}`); + } else { + body = body.replace(full, `[${userId}](https://matrix.to/#/${userId})`); + } } - - public async getEventBodyForGitLabNote(comment: IGitLabWebhookNoteEvent): Promise { - let body = comment.object_attributes.note; - body = this.replaceMentions(body); - body = await this.replaceImages(body, true); - body = emoji.emojify(body); - const htmlBody = md.render(body); - return { - body, - formatted_body: htmlBody, - msgtype: "m.text", - format: "org.matrix.custom.html", - // ...FormatUtil.getPartialBodyForComment(comment, repo, issue) - }; + return body; + } + + private async replaceImages( + body: string, + convertToMxc: boolean, + ): Promise { + let bodyCopy = body; + const urlMatches: string[] = []; + let match = REGEX_IMAGES.exec(bodyCopy); + while (match) { + bodyCopy = bodyCopy.replace(match[1], ""); + const contentType = (await mime).default.getType(match[1]) || "none"; + if ( + !contentType.startsWith("image") && + !contentType.startsWith("video") && + !contentType.startsWith("audio") + ) { + // Not handling media. + urlMatches.push(match[1]); + } + match = REGEX_IMAGES.exec(bodyCopy); } - - private replaceMentions(body: string): string { - return body.replace(REGEX_MENTION, (match: string, part1: string, githubId: string) => { - const userId = this.as.getUserIdForSuffix(githubId.substr(1)); - return `[${githubId}](https://matrix.to/#/${userId})`; + for (const rawUrl of urlMatches) { + try { + const { data, headers } = await axios.get(rawUrl, { + responseType: "arraybuffer", }); - } - - private replaceMatrixMentions(body: string, formattedBody: string): string { - let match; - let bodyCopy = formattedBody; - const mentionMatches: string[][] = []; - match = REGEX_MATRIX_MENTION.exec(bodyCopy); - while (match) { - bodyCopy = bodyCopy.replace(match[0], ""); - mentionMatches.push([match[2], match[1]]); - match = REGEX_MATRIX_MENTION.exec(bodyCopy); + const imageData = data; + 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, + ); + } else if (rawUrl.startsWith("mxc://")) { + const mxcParts = rawUrl.substr("mxc://".length).split("/"); + url = `${this.mediaUrl}/_matrix/media/r0/download/${mxcParts[0]}/${mxcParts[1]}`; + } else { + url = rawUrl; } - for (const [full, userId] of mentionMatches) { - if (this.as.isNamespacedUser(userId)) { - // XXX: Prefix hack - const githubId = userId.split(":")[0].substr("@_github_".length); - if (!githubId) { - continue; - } - body = body.replace(full, `@${githubId}`); - } else { - body = body.replace(full, `[${userId}](https://matrix.to/#/${userId})`); - } - } - return body; - } - - private async replaceImages(body: string, convertToMxc: boolean): Promise { - let bodyCopy = body; - const urlMatches: string[] = []; - let match = REGEX_IMAGES.exec(bodyCopy); - while (match) { - bodyCopy = bodyCopy.replace(match[1], ""); - const contentType = (await mime).default.getType(match[1]) || "none"; - if ( - !contentType.startsWith("image") && - !contentType.startsWith("video") && - !contentType.startsWith("audio")) { - // Not handling media. - urlMatches.push(match[1]); - } - match = REGEX_IMAGES.exec(bodyCopy); - } - for (const rawUrl of urlMatches) { - try { - const { data, headers } = await axios.get(rawUrl, {responseType: "arraybuffer"}); - const imageData = data; - 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); - } else if (rawUrl.startsWith("mxc://")) { - const mxcParts = rawUrl.substr("mxc://".length).split("/"); - url = `${this.mediaUrl}/_matrix/media/r0/download/${mxcParts[0]}/${mxcParts[1]}`; - } else { - url = rawUrl; - } - - body = body.replace(rawUrl, url); - } catch (ex) { - log.warn("Failed to upload file", ex); - } - } - return body; + body = body.replace(rawUrl, url); + } catch (ex) { + log.warn("Failed to upload file", ex); + } } + return body; + } } diff --git a/src/ConnectionManager.ts b/src/ConnectionManager.ts index 12a11eb62..132145126 100644 --- a/src/ConnectionManager.ts +++ b/src/ConnectionManager.ts @@ -1,15 +1,31 @@ - - /** * Manages connections between Matrix rooms and the remote side. */ 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 { + ConnectionDeclaration, + ConnectionDeclarations, + GenericHookConnection, + GitHubDiscussionConnection, + GitHubDiscussionSpace, + GitHubIssueConnection, + GitHubProjectConnection, + GitHubRepoConnection, + GitHubUserSpace, + GitLabIssueConnection, + GitLabRepoConnection, + IConnection, + IConnectionState, + JiraProjectConnection, +} from "./Connections"; import { FigmaFileConnection, FeedConnection } from "./Connections"; import { GetConnectionTypeResponseItem } from "./Widgets/api"; import { GitLabClient } from "./Gitlab/Client"; @@ -32,439 +48,725 @@ const GET_STATE_ATTEMPTS = 5; const GET_STATE_TIMEOUT_MS = 1000; export class ConnectionManager extends EventEmitter { - private connections: IConnection[] = []; - public readonly enabledForProvisioning: Record = {}; - - public get size() { - return this.connections.length; - } - - constructor( - private readonly as: Appservice, - private readonly config: BridgeConfig, - private readonly tokenStore: UserTokenStore, - private readonly commentProcessor: CommentProcessor, - private readonly messageClient: MessageSenderClient, - private readonly storage: IBridgeStorageProvider, - private readonly botUsersManager: BotUsersManager, - private readonly github?: GithubInstance - ) { - super(); - } - - /** - * Push a new connection to the manager, if this connection already - * exists then this will no-op. - * @param connections The connection instances to push. - */ - public push(...connections: IConnection[]) { - for (const connection of connections) { - if (this.connections.some(c => c.connectionId === connection.connectionId)) { - return; - } - this.connections.push(connection); - this.emit('new-connection', connection); - } - Metrics.connections.set(this.connections.length); - // Already exists, noop. - } - - /** - * Used by the provisioner API to create new connections on behalf of users. - * - * @param roomId The target Matrix room. - * @param intent Bot user intent to create the connection with. - * @param userId The requesting Matrix user. - * @param connectionType The connection declaration to provision. - * @param data The data corresponding to the connection state. This will be validated. - * @returns The resulting connection. - */ - public async provisionConnection( - roomId: string, - intent: Intent, - userId: string, - connectionType: ConnectionDeclaration, - data: Record, - ) { - log.info(`Looking to provision connection for ${roomId} ${connectionType.ServiceCategory} for ${userId} with data ${JSON.stringify(data)}`); - if (connectionType?.provisionConnection) { - if (!this.config.checkPermission(userId, connectionType.ServiceCategory, BridgePermissionLevel.manageConnections)) { - throw new ApiError(`User is not permitted to provision connections for this type of service.`, ErrCode.ForbiddenUser); - } - const result = await connectionType.provisionConnection(roomId, userId, data, { - as: this.as, - intent: intent, - config: this.config, - tokenStore: this.tokenStore, - commentProcessor: this.commentProcessor, - messageClient: this.messageClient, - storage: this.storage, - github: this.github, - getAllConnectionsOfType: this.getAllConnectionsOfType.bind(this), - }); - this.push(result.connection); - return result; - } - throw new ApiError(`Connection type not known`); - } - - /** - * Check if a state event is sent by a user who is allowed to configure the type of connection the state event covers. - * If it isn't, optionally revert the state to the last-known valid value, or redact it if that isn't possible. - * @param roomId The target Matrix room. - * @param intent The bot intent to use. - * @param state The state event for altering a connection in the room. - * @param serviceType The type of connection the state event is altering. - * @returns Whether the state event was allowed to be set. If not, the state will be reverted asynchronously. - */ - public verifyStateEvent(roomId: string, intent: Intent, state: StateEvent, serviceType: string, rollbackBadState: boolean) { - if (!this.isStateAllowed(roomId, state, serviceType)) { - if (rollbackBadState) { - void this.tryRestoreState(roomId, intent, state, serviceType); - } - log.error(`User ${state.sender} is disallowed to manage state for ${serviceType} in ${roomId}`); - return false; + private connections: IConnection[] = []; + public readonly enabledForProvisioning: Record< + string, + GetConnectionTypeResponseItem + > = {}; + + public get size() { + return this.connections.length; + } + + constructor( + private readonly as: Appservice, + private readonly config: BridgeConfig, + private readonly tokenStore: UserTokenStore, + private readonly commentProcessor: CommentProcessor, + private readonly messageClient: MessageSenderClient, + private readonly storage: IBridgeStorageProvider, + private readonly botUsersManager: BotUsersManager, + private readonly github?: GithubInstance, + ) { + super(); + } + + /** + * Push a new connection to the manager, if this connection already + * exists then this will no-op. + * @param connections The connection instances to push. + */ + public push(...connections: IConnection[]) { + for (const connection of connections) { + if ( + this.connections.some((c) => c.connectionId === connection.connectionId) + ) { + return; + } + this.connections.push(connection); + this.emit("new-connection", connection); + } + Metrics.connections.set(this.connections.length); + // Already exists, noop. + } + + /** + * Used by the provisioner API to create new connections on behalf of users. + * + * @param roomId The target Matrix room. + * @param intent Bot user intent to create the connection with. + * @param userId The requesting Matrix user. + * @param connectionType The connection declaration to provision. + * @param data The data corresponding to the connection state. This will be validated. + * @returns The resulting connection. + */ + public async provisionConnection( + roomId: string, + intent: Intent, + userId: string, + connectionType: ConnectionDeclaration, + data: Record, + ) { + log.info( + `Looking to provision connection for ${roomId} ${connectionType.ServiceCategory} for ${userId} with data ${JSON.stringify(data)}`, + ); + if (connectionType?.provisionConnection) { + if ( + !this.config.checkPermission( + userId, + connectionType.ServiceCategory, + BridgePermissionLevel.manageConnections, + ) + ) { + throw new ApiError( + `User is not permitted to provision connections for this type of service.`, + ErrCode.ForbiddenUser, + ); + } + const result = await connectionType.provisionConnection( + roomId, + userId, + data, + { + as: this.as, + intent: intent, + config: this.config, + tokenStore: this.tokenStore, + commentProcessor: this.commentProcessor, + messageClient: this.messageClient, + storage: this.storage, + github: this.github, + getAllConnectionsOfType: this.getAllConnectionsOfType.bind(this), + }, + ); + this.push(result.connection); + return result; + } + throw new ApiError(`Connection type not known`); + } + + /** + * Check if a state event is sent by a user who is allowed to configure the type of connection the state event covers. + * If it isn't, optionally revert the state to the last-known valid value, or redact it if that isn't possible. + * @param roomId The target Matrix room. + * @param intent The bot intent to use. + * @param state The state event for altering a connection in the room. + * @param serviceType The type of connection the state event is altering. + * @returns Whether the state event was allowed to be set. If not, the state will be reverted asynchronously. + */ + public verifyStateEvent( + roomId: string, + intent: Intent, + state: StateEvent, + serviceType: string, + rollbackBadState: boolean, + ) { + if (!this.isStateAllowed(roomId, state, serviceType)) { + if (rollbackBadState) { + void this.tryRestoreState(roomId, intent, state, serviceType); + } + log.error( + `User ${state.sender} is disallowed to manage state for ${serviceType} in ${roomId}`, + ); + return false; + } else { + return true; + } + } + + /** + * The same as {@link verifyStateEvent}, but verifies the state event against the room & service type of the given connection. + * @param connection The connection to verify the state event against. + * @param state The state event for altering a connection in the room targeted by {@link connection}. + * @returns Whether the state event was allowed to be set. If not, the state will be reverted asynchronously. + */ + public verifyStateEventForConnection( + connection: IConnection, + state: StateEvent, + rollbackBadState: boolean, + ): boolean { + const cd: ConnectionDeclaration = + Object.getPrototypeOf(connection).constructor; + const botUser = this.botUsersManager.getBotUserInRoom( + connection.roomId, + cd.ServiceCategory, + ); + if (!botUser) { + log.error( + `Failed to find a bot in room '${connection.roomId}' for service type '${cd.ServiceCategory}' when verifying state for connection`, + ); + throw Error("Could not find a bot to handle this connection"); + } + return this.verifyStateEvent( + connection.roomId, + botUser.intent, + state, + cd.ServiceCategory, + rollbackBadState, + ); + } + + private isStateAllowed( + roomId: string, + state: StateEvent, + serviceType: string, + ) { + return ( + this.botUsersManager.isBotUser(state.sender) || + this.config.checkPermission( + state.sender, + serviceType, + BridgePermissionLevel.manageConnections, + ) + ); + } + + private async tryRestoreState( + roomId: string, + intent: Intent, + originalState: StateEvent, + serviceType: string, + ) { + let state = originalState; + let attemptsRemaining = 5; + try { + do { + if (state.unsigned.replaces_state) { + state = new StateEvent( + await intent.underlyingClient.getEvent( + roomId, + state.unsigned.replaces_state, + ), + ); } else { - return true; + await intent.underlyingClient.redactEvent( + roomId, + originalState.eventId, + `User ${originalState.sender} is disallowed to manage state for ${serviceType} in ${roomId}`, + ); + return; } - } - - /** - * The same as {@link verifyStateEvent}, but verifies the state event against the room & service type of the given connection. - * @param connection The connection to verify the state event against. - * @param state The state event for altering a connection in the room targeted by {@link connection}. - * @returns Whether the state event was allowed to be set. If not, the state will be reverted asynchronously. - */ - public verifyStateEventForConnection(connection: IConnection, state: StateEvent, rollbackBadState: boolean): boolean { - const cd: ConnectionDeclaration = Object.getPrototypeOf(connection).constructor; - const botUser = this.botUsersManager.getBotUserInRoom(connection.roomId, cd.ServiceCategory); - if (!botUser) { - log.error(`Failed to find a bot in room '${connection.roomId}' for service type '${cd.ServiceCategory}' when verifying state for connection`); - throw Error('Could not find a bot to handle this connection'); - } - return this.verifyStateEvent(connection.roomId, botUser.intent, state, cd.ServiceCategory, rollbackBadState); - } - - private isStateAllowed(roomId: string, state: StateEvent, serviceType: string) { - return this.botUsersManager.isBotUser(state.sender) - || this.config.checkPermission(state.sender, serviceType, BridgePermissionLevel.manageConnections); - } - - private async tryRestoreState(roomId: string, intent: Intent, originalState: StateEvent, serviceType: string) { - let state = originalState; - let attemptsRemaining = 5; - try { - do { - if (state.unsigned.replaces_state) { - state = new StateEvent(await intent.underlyingClient.getEvent(roomId, state.unsigned.replaces_state)); - } else { - await intent.underlyingClient.redactEvent(roomId, originalState.eventId, - `User ${originalState.sender} is disallowed to manage state for ${serviceType} in ${roomId}`); - return; - } - } while (--attemptsRemaining > 0 && !this.isStateAllowed(roomId, state, serviceType)); - await intent.underlyingClient.sendStateEvent(roomId, state.type, state.stateKey, state.content); - } catch (ex) { - log.warn(`Unable to undo state event from ${state.sender} for disallowed ${serviceType} connection management in ${roomId}`); - } - } - - /** - * This is called ONLY when we spot new state in a room and want to create a connection for it. - * @param roomId - * @param state - * @param rollbackBadState - * @returns - */ - public async createConnectionForState(roomId: string, state: StateEvent, rollbackBadState: boolean): Promise { - // Empty object == redacted - if (state.content.disabled === true || Object.keys(state.content).length === 0) { - log.debug(`${roomId} has disabled state for ${state.type}`); - return; - } - const connectionType = this.getConnectionTypeForEventType(state.type); - if (!connectionType) { - return; - } - - // Get a bot user for the connection type - const botUser = this.botUsersManager.getBotUserInRoom(roomId, connectionType.ServiceCategory); - if (!botUser) { - log.error(`Failed to find a bot in room '${roomId}' for service type '${connectionType.ServiceCategory}' when creating connection for state`); - throw Error('Could not find a bot to handle this connection'); - } - - if (!this.verifyStateEvent(roomId, botUser.intent, state, connectionType.ServiceCategory, rollbackBadState)) { - return; - } - - try { - const connection = await connectionType.createConnectionForState(roomId, state, { - as: this.as, - intent: botUser.intent, - config: this.config, - tokenStore: this.tokenStore, - commentProcessor: this.commentProcessor, - messageClient: this.messageClient, - storage: this.storage, - github: this.github, - }); - // Finally, ensure the connection is allowed by us. - await connection.ensureGrant?.(state.sender); - return connection; - } catch (ex) { - log.error(`Not creating connection for state ${roomId}/${state.type}`, ex); - return; - } - } - - /** - * This is called when hookshot starts up, or a hookshot service bot has left - * and we need to recalculate the right bots for all the connections in a room. - * @param roomId - * @param rollbackBadState - * @returns - */ - public async createConnectionsForRoomId(roomId: string, rollbackBadState: boolean) { - const botUser = this.botUsersManager.getBotUserInRoom(roomId); - if (!botUser) { - log.error(`Failed to find a bot in room '${roomId}' when creating connections`); - return; - } - - // This endpoint can be heavy, wrap it in pillows. - const state = await retry( - () => botUser.intent.underlyingClient.getRoomState(roomId), - GET_STATE_ATTEMPTS, - GET_STATE_TIMEOUT_MS, - retryMatrixErrorFilter + } while ( + --attemptsRemaining > 0 && + !this.isStateAllowed(roomId, state, serviceType) + ); + await intent.underlyingClient.sendStateEvent( + roomId, + state.type, + state.stateKey, + state.content, + ); + } catch (ex) { + log.warn( + `Unable to undo state event from ${state.sender} for disallowed ${serviceType} connection management in ${roomId}`, + ); + } + } + + /** + * This is called ONLY when we spot new state in a room and want to create a connection for it. + * @param roomId + * @param state + * @param rollbackBadState + * @returns + */ + public async createConnectionForState( + roomId: string, + state: StateEvent, + rollbackBadState: boolean, + ): Promise { + // Empty object == redacted + if ( + state.content.disabled === true || + Object.keys(state.content).length === 0 + ) { + log.debug(`${roomId} has disabled state for ${state.type}`); + return; + } + const connectionType = this.getConnectionTypeForEventType(state.type); + if (!connectionType) { + return; + } + + // Get a bot user for the connection type + const botUser = this.botUsersManager.getBotUserInRoom( + roomId, + connectionType.ServiceCategory, + ); + if (!botUser) { + log.error( + `Failed to find a bot in room '${roomId}' for service type '${connectionType.ServiceCategory}' when creating connection for state`, + ); + throw Error("Could not find a bot to handle this connection"); + } + + if ( + !this.verifyStateEvent( + roomId, + botUser.intent, + state, + connectionType.ServiceCategory, + rollbackBadState, + ) + ) { + return; + } + + try { + const connection = await connectionType.createConnectionForState( + roomId, + state, + { + as: this.as, + intent: botUser.intent, + config: this.config, + tokenStore: this.tokenStore, + commentProcessor: this.commentProcessor, + messageClient: this.messageClient, + storage: this.storage, + github: this.github, + }, + ); + // Finally, ensure the connection is allowed by us. + await connection.ensureGrant?.(state.sender); + return connection; + } catch (ex) { + log.error( + `Not creating connection for state ${roomId}/${state.type}`, + ex, + ); + return; + } + } + + /** + * This is called when hookshot starts up, or a hookshot service bot has left + * and we need to recalculate the right bots for all the connections in a room. + * @param roomId + * @param rollbackBadState + * @returns + */ + public async createConnectionsForRoomId( + roomId: string, + rollbackBadState: boolean, + ) { + const botUser = this.botUsersManager.getBotUserInRoom(roomId); + if (!botUser) { + log.error( + `Failed to find a bot in room '${roomId}' when creating connections`, + ); + return; + } + + // This endpoint can be heavy, wrap it in pillows. + const state = await retry( + () => botUser.intent.underlyingClient.getRoomState(roomId), + GET_STATE_ATTEMPTS, + GET_STATE_TIMEOUT_MS, + retryMatrixErrorFilter, + ); + + for (const event of state) { + try { + const conn = await this.createConnectionForState( + roomId, + new StateEvent(event), + rollbackBadState, ); - - for (const event of state) { - try { - const conn = await this.createConnectionForState(roomId, new StateEvent(event), rollbackBadState); - if (conn) { - log.debug(`Room ${roomId} is connected to: ${conn}`); - this.push(conn); - } - } catch (ex) { - log.error(`Failed to create connection for ${roomId}:`, ex); - } - } - } - - public getConnectionsForGithubIssue(org: string, repo: string, issueNumber: number): (GitHubIssueConnection|GitHubRepoConnection)[] { - org = org.toLowerCase(); - repo = repo.toLowerCase(); - return this.connections.filter((c) => (c instanceof GitHubIssueConnection && c.org === org && c.repo === repo && c.issueNumber === issueNumber) || - (c instanceof GitHubRepoConnection && c.org === org && c.repo === repo)) as (GitHubIssueConnection|GitHubRepoConnection)[]; - } - - public getConnectionsForGithubRepo(org: string, repo: string): GitHubRepoConnection[] { - org = org.toLowerCase(); - repo = repo.toLowerCase(); - return this.connections.filter((c) => (c instanceof GitHubRepoConnection && c.org === org && c.repo === repo)) as GitHubRepoConnection[]; - } - - public getConnectionsForGithubRepoDiscussion(owner: string, repo: string): GitHubDiscussionSpace[] { - owner = owner.toLowerCase(); - repo = repo.toLowerCase(); - return this.connections.filter((c) => (c instanceof GitHubDiscussionSpace && c.owner === owner && c.repo === repo)) as GitHubDiscussionSpace[]; - } - - public getConnectionForGithubUser(user: string): GitHubUserSpace { - return this.connections.find(c => c instanceof GitHubUserSpace && c.owner === user.toLowerCase()) as GitHubUserSpace; - } - - public getConnectionsForGithubDiscussion(owner: string, repo: string, discussionNumber: number) { - owner = owner.toLowerCase(); - repo = repo.toLowerCase(); - return this.connections.filter( - c => ( - c instanceof GitHubDiscussionConnection && - c.owner === owner && - c.repo === repo && - c.discussionNumber === discussionNumber - ) - ) as GitHubDiscussionConnection[]; - } - - public getForGitHubProject(projectId: number): GitHubProjectConnection[] { - return this.connections.filter( - c => ( - c instanceof GitHubProjectConnection && - c.projectId === projectId - ) - ) as GitHubProjectConnection[]; - } - - public getConnectionsForGitLabIssueWebhook(repoHome: string, issueId: number) { - if (!this.config.gitlab) { - throw Error('GitLab configuration missing, cannot handle note'); + if (conn) { + log.debug(`Room ${roomId} is connected to: ${conn}`); + this.push(conn); } - const res = GitLabClient.splitUrlIntoParts(this.config.gitlab.instances, repoHome); - if (!res) { - throw Error('No instance found for note'); - } - const instance = this.config.gitlab.instances[res[0]]; - return this.getConnectionsForGitLabIssue(instance, res[1], issueId); - } - - public getConnectionsForGitLabIssue(instance: GitLabInstance, projects: string[], issueNumber: number): GitLabIssueConnection[] { - return this.connections.filter((c) => ( - c instanceof GitLabIssueConnection && - c.issueNumber == issueNumber && - c.instanceUrl == instance.url && - c.projectPath == projects.join("/") - )) as GitLabIssueConnection[]; - } - - public getConnectionsForGitLabRepo(pathWithNamespace: string): GitLabRepoConnection[] { - pathWithNamespace = pathWithNamespace.toLowerCase(); - return this.connections.filter((c) => (c instanceof GitLabRepoConnection && c.path === pathWithNamespace)) as GitLabRepoConnection[]; - } - - public getConnectionsForJiraProject(project: JiraProject): JiraProjectConnection[] { - return this.connections.filter((c) => (c instanceof JiraProjectConnection && c.interestedInProject(project))) as JiraProjectConnection[]; - } - - public getConnectionsForJiraVersion(version: JiraVersion): JiraProjectConnection[] { - return this.connections.filter((c) => (c instanceof JiraProjectConnection && c.interestedInVersion(version))) as JiraProjectConnection[]; - } - - public getConnectionsForGenericWebhook(hookId: string): GenericHookConnection[] { - return this.connections.filter((c) => (c instanceof GenericHookConnection && c.hookId === hookId)) as GenericHookConnection[]; - } - - public getForFigmaFile(fileKey: string, instanceName: string): FigmaFileConnection[] { - return this.connections.filter((c) => (c instanceof FigmaFileConnection && (c.fileId === fileKey || c.instanceName === instanceName))) as FigmaFileConnection[]; - } - - public getConnectionsForFeedUrl(url: string): FeedConnection[] { - return this.connections.filter(c => c instanceof FeedConnection && c.feedUrl === url) as FeedConnection[]; - } - - public getConnectionsForHoundChallengeId(challengeId: string): HoundConnection[] { - return this.connections.filter(c => c instanceof HoundConnection && c.challengeId === challengeId) as HoundConnection[]; - } - - public getConnectionsForOpenProject(projectId: number): OpenProjectConnection[] { - return this.connections.filter((c) => (c instanceof OpenProjectConnection && c.interestedInProject(projectId))) as OpenProjectConnection[]; - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - public getAllConnectionsOfType(typeT: new (...params : any[]) => T): T[] { - return this.connections.filter((c) => (c instanceof typeT)) as T[]; - } - - public getConnectionTypeForEventType(eventType: string): ConnectionDeclaration | undefined { - return ConnectionDeclarations.find(c => c.EventTypes.includes(eventType)); - } - - public isRoomConnected(roomId: string): boolean { - return !!this.connections.find(c => c.roomId === roomId); - } - - public getAllConnectionsForRoom(roomId: string): IConnection[] { - return this.connections.filter(c => c.roomId === roomId).sort((a,b) => b.priority - a.priority); - } - - public getInterestedForRoomState(roomId: string, eventType: string, stateKey: string): IConnection[] { - return this.connections.filter(c => c.roomId === roomId && c.isInterestedInStateEvent(eventType, stateKey)); - } - - public getConnectionById(roomId: string, connectionId: string) { - return this.connections.find((c) => c.connectionId === connectionId && c.roomId === roomId); - } - - public validateCommandPrefix(roomId: string, config: IConnectionState, currentConnection?: IConnection) { - if (config.commandPrefix === undefined) return; - for (const c of this.getAllConnectionsForRoom(roomId)) { - if (c != currentConnection && c.conflictsWithCommandPrefix?.(config.commandPrefix)) { - throw new ApiError(`Command prefix "${config.commandPrefix}" is already used in this room. Please choose another prefix.`, ErrCode.ConflictingConnection, -1, { - existingConnection: c.getProvisionerDetails?.(), - } - ); - } - } - } - - public async purgeConnection(roomId: string, connectionId: string, requireNoRemoveHandler = true) { - const connection = this.connections.find((c) => c.connectionId === connectionId && c.roomId == roomId); - if (!connection) { - throw Error("Connection not found"); - } - if (requireNoRemoveHandler && !connection.onRemove) { - throw Error("Connection doesn't support removal, and so cannot be safely removed"); - } - await connection.onRemove?.(); - const connectionIndex = this.connections.indexOf(connection); - if (connectionIndex === -1) { - throw Error('Could not find connection index'); - } - this.connections.splice(connectionIndex, 1); - Metrics.connections.set(this.connections.length); - this.emit('connection-removed', connection); - } - - /** - * Removes connections for a room from memory. This does NOT remove the state - * event from the room. - * @param roomId - */ - public async removeConnectionsForRoom(roomId: string) { - log.info(`Removing all connections from ${roomId}`); - this.connections = this.connections.filter((c) => c.roomId !== roomId); - Metrics.connections.set(this.connections.length); - } - - public registerProvisioningConnection(connType: {getProvisionerDetails: (botUserId: string) => GetConnectionTypeResponseItem}) { - const details = connType.getProvisionerDetails(this.as.botUserId); - if (this.enabledForProvisioning[details.type]) { - throw Error(`Type "${details.type}" already registered for provisioning`); - } - this.enabledForProvisioning[details.type] = details; - } - - - /** - * Get a list of possible targets for a given connection type when provisioning - * @param userId - * @param type - */ - async getConnectionTargets(userId: string, type: string, filters: Record = {}): Promise { - switch (type) { - case GitLabRepoConnection.CanonicalEventType: { - const configObject = this.validateConnectionTarget(userId, this.config.gitlab, "GitLab", "gitlab"); - return await GitLabRepoConnection.getConnectionTargets(userId, configObject, filters, this.tokenStore, this.storage); - } - case GitHubRepoConnection.CanonicalEventType: { - this.validateConnectionTarget(userId, this.config.github, "GitHub", "github"); - if (!this.github) { - throw Error("GitHub instance was never initialized"); - } - return await GitHubRepoConnection.getConnectionTargets(userId, this.tokenStore, this.github, filters); - } - case JiraProjectConnection.CanonicalEventType: { - const configObject = this.validateConnectionTarget(userId, this.config.jira, "JIRA", "jira"); - return await JiraProjectConnection.getConnectionTargets(userId, this.tokenStore, configObject, filters); - } - case OpenProjectConnection.CanonicalEventType: { - this.validateConnectionTarget(userId, this.config.openProject, "OpenProject", "openproject"); - return await OpenProjectConnection.getConnectionTargets(userId, this.tokenStore, filters); - } - default: - throw new ApiError(`Connection type doesn't support getting targets or is not known`, ErrCode.NotFound); + } catch (ex) { + log.error(`Failed to create connection for ${roomId}:`, ex); + } + } + } + + public getConnectionsForGithubIssue( + org: string, + repo: string, + issueNumber: number, + ): (GitHubIssueConnection | GitHubRepoConnection)[] { + org = org.toLowerCase(); + repo = repo.toLowerCase(); + return this.connections.filter( + (c) => + (c instanceof GitHubIssueConnection && + c.org === org && + c.repo === repo && + c.issueNumber === issueNumber) || + (c instanceof GitHubRepoConnection && c.org === org && c.repo === repo), + ) as (GitHubIssueConnection | GitHubRepoConnection)[]; + } + + public getConnectionsForGithubRepo( + org: string, + repo: string, + ): GitHubRepoConnection[] { + org = org.toLowerCase(); + repo = repo.toLowerCase(); + return this.connections.filter( + (c) => + c instanceof GitHubRepoConnection && c.org === org && c.repo === repo, + ) as GitHubRepoConnection[]; + } + + public getConnectionsForGithubRepoDiscussion( + owner: string, + repo: string, + ): GitHubDiscussionSpace[] { + owner = owner.toLowerCase(); + repo = repo.toLowerCase(); + return this.connections.filter( + (c) => + c instanceof GitHubDiscussionSpace && + c.owner === owner && + c.repo === repo, + ) as GitHubDiscussionSpace[]; + } + + public getConnectionForGithubUser(user: string): GitHubUserSpace { + return this.connections.find( + (c) => c instanceof GitHubUserSpace && c.owner === user.toLowerCase(), + ) as GitHubUserSpace; + } + + public getConnectionsForGithubDiscussion( + owner: string, + repo: string, + discussionNumber: number, + ) { + owner = owner.toLowerCase(); + repo = repo.toLowerCase(); + return this.connections.filter( + (c) => + c instanceof GitHubDiscussionConnection && + c.owner === owner && + c.repo === repo && + c.discussionNumber === discussionNumber, + ) as GitHubDiscussionConnection[]; + } + + public getForGitHubProject(projectId: number): GitHubProjectConnection[] { + return this.connections.filter( + (c) => c instanceof GitHubProjectConnection && c.projectId === projectId, + ) as GitHubProjectConnection[]; + } + + public getConnectionsForGitLabIssueWebhook( + repoHome: string, + issueId: number, + ) { + if (!this.config.gitlab) { + throw Error("GitLab configuration missing, cannot handle note"); + } + const res = GitLabClient.splitUrlIntoParts( + this.config.gitlab.instances, + repoHome, + ); + if (!res) { + throw Error("No instance found for note"); + } + const instance = this.config.gitlab.instances[res[0]]; + return this.getConnectionsForGitLabIssue(instance, res[1], issueId); + } + + public getConnectionsForGitLabIssue( + instance: GitLabInstance, + projects: string[], + issueNumber: number, + ): GitLabIssueConnection[] { + return this.connections.filter( + (c) => + c instanceof GitLabIssueConnection && + c.issueNumber == issueNumber && + c.instanceUrl == instance.url && + c.projectPath == projects.join("/"), + ) as GitLabIssueConnection[]; + } + + public getConnectionsForGitLabRepo( + pathWithNamespace: string, + ): GitLabRepoConnection[] { + pathWithNamespace = pathWithNamespace.toLowerCase(); + return this.connections.filter( + (c) => c instanceof GitLabRepoConnection && c.path === pathWithNamespace, + ) as GitLabRepoConnection[]; + } + + public getConnectionsForJiraProject( + project: JiraProject, + ): JiraProjectConnection[] { + return this.connections.filter( + (c) => + c instanceof JiraProjectConnection && c.interestedInProject(project), + ) as JiraProjectConnection[]; + } + + public getConnectionsForJiraVersion( + version: JiraVersion, + ): JiraProjectConnection[] { + return this.connections.filter( + (c) => + c instanceof JiraProjectConnection && c.interestedInVersion(version), + ) as JiraProjectConnection[]; + } + + public getConnectionsForGenericWebhook( + hookId: string, + ): GenericHookConnection[] { + return this.connections.filter( + (c) => c instanceof GenericHookConnection && c.hookId === hookId, + ) as GenericHookConnection[]; + } + + public getForFigmaFile( + fileKey: string, + instanceName: string, + ): FigmaFileConnection[] { + return this.connections.filter( + (c) => + c instanceof FigmaFileConnection && + (c.fileId === fileKey || c.instanceName === instanceName), + ) as FigmaFileConnection[]; + } + + public getConnectionsForFeedUrl(url: string): FeedConnection[] { + return this.connections.filter( + (c) => c instanceof FeedConnection && c.feedUrl === url, + ) as FeedConnection[]; + } + + public getConnectionsForHoundChallengeId( + challengeId: string, + ): HoundConnection[] { + return this.connections.filter( + (c) => c instanceof HoundConnection && c.challengeId === challengeId, + ) as HoundConnection[]; + } + + public getConnectionsForOpenProject( + projectId: number, + ): OpenProjectConnection[] { + return this.connections.filter( + (c) => + c instanceof OpenProjectConnection && c.interestedInProject(projectId), + ) as OpenProjectConnection[]; + } + + + public getAllConnectionsOfType( + typeT: new (...params: any[]) => T, + ): T[] { + return this.connections.filter((c) => c instanceof typeT) as T[]; + } + + public getConnectionTypeForEventType( + eventType: string, + ): ConnectionDeclaration | undefined { + return ConnectionDeclarations.find((c) => c.EventTypes.includes(eventType)); + } + + public isRoomConnected(roomId: string): boolean { + return !!this.connections.find((c) => c.roomId === roomId); + } + + public getAllConnectionsForRoom(roomId: string): IConnection[] { + return this.connections + .filter((c) => c.roomId === roomId) + .sort((a, b) => b.priority - a.priority); + } + + public getInterestedForRoomState( + roomId: string, + eventType: string, + stateKey: string, + ): IConnection[] { + return this.connections.filter( + (c) => + c.roomId === roomId && c.isInterestedInStateEvent(eventType, stateKey), + ); + } + + public getConnectionById(roomId: string, connectionId: string) { + return this.connections.find( + (c) => c.connectionId === connectionId && c.roomId === roomId, + ); + } + + public validateCommandPrefix( + roomId: string, + config: IConnectionState, + currentConnection?: IConnection, + ) { + if (config.commandPrefix === undefined) return; + for (const c of this.getAllConnectionsForRoom(roomId)) { + if ( + c != currentConnection && + c.conflictsWithCommandPrefix?.(config.commandPrefix) + ) { + throw new ApiError( + `Command prefix "${config.commandPrefix}" is already used in this room. Please choose another prefix.`, + ErrCode.ConflictingConnection, + -1, + { + existingConnection: c.getProvisionerDetails?.(), + }, + ); + } + } + } + + public async purgeConnection( + roomId: string, + connectionId: string, + requireNoRemoveHandler = true, + ) { + const connection = this.connections.find( + (c) => c.connectionId === connectionId && c.roomId == roomId, + ); + if (!connection) { + throw Error("Connection not found"); + } + if (requireNoRemoveHandler && !connection.onRemove) { + throw Error( + "Connection doesn't support removal, and so cannot be safely removed", + ); + } + await connection.onRemove?.(); + const connectionIndex = this.connections.indexOf(connection); + if (connectionIndex === -1) { + throw Error("Could not find connection index"); + } + this.connections.splice(connectionIndex, 1); + Metrics.connections.set(this.connections.length); + this.emit("connection-removed", connection); + } + + /** + * Removes connections for a room from memory. This does NOT remove the state + * event from the room. + * @param roomId + */ + public async removeConnectionsForRoom(roomId: string) { + log.info(`Removing all connections from ${roomId}`); + this.connections = this.connections.filter((c) => c.roomId !== roomId); + Metrics.connections.set(this.connections.length); + } + + public registerProvisioningConnection(connType: { + getProvisionerDetails: (botUserId: string) => GetConnectionTypeResponseItem; + }) { + const details = connType.getProvisionerDetails(this.as.botUserId); + if (this.enabledForProvisioning[details.type]) { + throw Error(`Type "${details.type}" already registered for provisioning`); + } + this.enabledForProvisioning[details.type] = details; + } + + /** + * Get a list of possible targets for a given connection type when provisioning + * @param userId + * @param type + */ + async getConnectionTargets( + userId: string, + type: string, + filters: Record = {}, + ): Promise { + switch (type) { + case GitLabRepoConnection.CanonicalEventType: { + const configObject = this.validateConnectionTarget( + userId, + this.config.gitlab, + "GitLab", + "gitlab", + ); + return await GitLabRepoConnection.getConnectionTargets( + userId, + configObject, + filters, + this.tokenStore, + this.storage, + ); + } + case GitHubRepoConnection.CanonicalEventType: { + this.validateConnectionTarget( + userId, + this.config.github, + "GitHub", + "github", + ); + if (!this.github) { + throw Error("GitHub instance was never initialized"); } + return await GitHubRepoConnection.getConnectionTargets( + userId, + this.tokenStore, + this.github, + filters, + ); + } + case JiraProjectConnection.CanonicalEventType: { + const configObject = this.validateConnectionTarget( + userId, + this.config.jira, + "JIRA", + "jira", + ); + return await JiraProjectConnection.getConnectionTargets( + userId, + this.tokenStore, + configObject, + filters, + ); + } + case OpenProjectConnection.CanonicalEventType: { + this.validateConnectionTarget( + userId, + this.config.openProject, + "OpenProject", + "openproject", + ); + return await OpenProjectConnection.getConnectionTargets( + userId, + this.tokenStore, + filters, + ); + } + default: + throw new ApiError( + `Connection type doesn't support getting targets or is not known`, + ErrCode.NotFound, + ); } - - private validateConnectionTarget(userId: string, configObject: T|undefined, serviceName: string, serviceId: string): T { - if (!configObject) { - throw new ApiError(`${serviceName} is not configured`, ErrCode.DisabledFeature); - } - if (!this.config.checkPermission(userId, serviceId, BridgePermissionLevel.manageConnections)) { - throw new ApiError(`User is not permitted to provision connections for ${serviceName}`, ErrCode.ForbiddenUser); - } - return configObject; + } + + private validateConnectionTarget( + userId: string, + configObject: T | undefined, + serviceName: string, + serviceId: string, + ): T { + if (!configObject) { + throw new ApiError( + `${serviceName} is not configured`, + ErrCode.DisabledFeature, + ); + } + if ( + !this.config.checkPermission( + userId, + serviceId, + BridgePermissionLevel.manageConnections, + ) + ) { + throw new ApiError( + `User is not permitted to provision connections for ${serviceName}`, + ErrCode.ForbiddenUser, + ); } + return configObject; + } } diff --git a/src/Connections/BaseConnection.ts b/src/Connections/BaseConnection.ts index b734c869c..9951a50b8 100644 --- a/src/Connections/BaseConnection.ts +++ b/src/Connections/BaseConnection.ts @@ -4,18 +4,19 @@ import { FormatUtil } from "../FormatUtil"; * Base connection class from which all connections should extend from. */ export abstract class BaseConnection { - constructor( - public readonly roomId: string, - public readonly stateKey: string, - public readonly canonicalStateType: string) { + constructor( + public readonly roomId: string, + public readonly stateKey: string, + public readonly canonicalStateType: string, + ) {} - } + public get connectionId(): string { + return FormatUtil.hashId( + `${this.roomId}/${this.canonicalStateType}/${this.stateKey}`, + ); + } - public get connectionId(): string { - return FormatUtil.hashId(`${this.roomId}/${this.canonicalStateType}/${this.stateKey}`); - } - - public get priority(): number { - return -1; - } -} \ No newline at end of file + public get priority(): number { + return -1; + } +} diff --git a/src/Connections/CommandConnection.ts b/src/Connections/CommandConnection.ts index 4d31d046f..6114ea88b 100644 --- a/src/Connections/CommandConnection.ts +++ b/src/Connections/CommandConnection.ts @@ -1,4 +1,9 @@ -import { botCommand, BotCommands, handleCommand, HelpFunction } from "../BotCommands"; +import { + botCommand, + BotCommands, + handleCommand, + HelpFunction, +} from "../BotCommands"; import { Logger } from "matrix-appservice-bridge"; import { MatrixClient } from "matrix-bot-sdk"; import { MatrixMessageContent, MatrixEvent } from "../MatrixEvent"; @@ -10,76 +15,99 @@ const log = new Logger("CommandConnection"); * Connection class that handles commands for a given connection. Should be used * by connections expecting to handle user input. */ -export abstract class CommandConnection extends BaseConnection { - protected includeTitlesInHelp?: boolean; - constructor( - roomId: string, - stateKey: string, - canonicalStateType: string, - protected state: ValidatedStateType, - private readonly botClient: MatrixClient, - private readonly botCommands: BotCommands, - private readonly helpMessage: HelpFunction, - protected readonly helpCategories: string[], - protected readonly defaultCommandPrefix: string, - protected readonly serviceName?: string, - ) { - super(roomId, stateKey, canonicalStateType); - } +export abstract class CommandConnection< + StateType extends IConnectionState = IConnectionState, + ValidatedStateType extends StateType = StateType, +> extends BaseConnection { + protected includeTitlesInHelp?: boolean; + constructor( + roomId: string, + stateKey: string, + canonicalStateType: string, + protected state: ValidatedStateType, + private readonly botClient: MatrixClient, + private readonly botCommands: BotCommands, + private readonly helpMessage: HelpFunction, + protected readonly helpCategories: string[], + protected readonly defaultCommandPrefix: string, + protected readonly serviceName?: string, + ) { + super(roomId, stateKey, canonicalStateType); + } - protected get commandPrefix() { - return (this.state.commandPrefix || this.defaultCommandPrefix) + " "; - } + protected get commandPrefix() { + return (this.state.commandPrefix || this.defaultCommandPrefix) + " "; + } - public conflictsWithCommandPrefix(commandPrefix: string) { - return this.commandPrefix == commandPrefix + " "; - } + public conflictsWithCommandPrefix(commandPrefix: string) { + return this.commandPrefix == commandPrefix + " "; + } - public async onStateUpdate(stateEv: MatrixEvent) { - this.state = await this.validateConnectionState(stateEv.content); - } + public async onStateUpdate(stateEv: MatrixEvent) { + this.state = await this.validateConnectionState(stateEv.content); + } - protected abstract validateConnectionState(content: unknown): Promise|ValidatedStateType; + protected abstract validateConnectionState( + content: unknown, + ): Promise | ValidatedStateType; - public async onMessageEvent(ev: MatrixEvent, checkPermission: PermissionCheckFn) { - const commandResult = await handleCommand( - ev.sender, ev.content.body, this.botCommands, this,checkPermission, - this.serviceName, this.commandPrefix - ); - if (commandResult.handled !== true) { - // Not for us. - return false; - } - if ("error" in commandResult || "humanError" in commandResult) { - const { humanError, error } = commandResult; - await this.botClient.sendEvent(this.roomId, "m.reaction", { - "m.relates_to": { - rel_type: "m.annotation", - event_id: ev.event_id, - key: "⛔", - } - }); - await this.botClient.sendEvent(this.roomId, 'm.room.message', { - msgtype: "m.notice", - body: humanError ? `Failed to handle command: ${humanError}` : "Failed to handle command.", - }); - log.warn(`Failed to handle command:`, error ?? 'Unknown error'); - return true; - } else { - const reaction = commandResult.result?.reaction || '✅'; - await this.botClient.sendEvent(this.roomId, "m.reaction", { - "m.relates_to": { - rel_type: "m.annotation", - event_id: ev.event_id, - key: reaction, - } - }); - return true; - } + public async onMessageEvent( + ev: MatrixEvent, + checkPermission: PermissionCheckFn, + ) { + const commandResult = await handleCommand( + ev.sender, + ev.content.body, + this.botCommands, + this, + checkPermission, + this.serviceName, + this.commandPrefix, + ); + if (commandResult.handled !== true) { + // Not for us. + return false; } - - @botCommand("help", "This help text") - public async helpCommand() { - return this.botClient.sendEvent(this.roomId, 'm.room.message', this.helpMessage(this.commandPrefix, this.helpCategories, this.includeTitlesInHelp)); + if ("error" in commandResult || "humanError" in commandResult) { + const { humanError, error } = commandResult; + await this.botClient.sendEvent(this.roomId, "m.reaction", { + "m.relates_to": { + rel_type: "m.annotation", + event_id: ev.event_id, + key: "⛔", + }, + }); + await this.botClient.sendEvent(this.roomId, "m.room.message", { + msgtype: "m.notice", + body: humanError + ? `Failed to handle command: ${humanError}` + : "Failed to handle command.", + }); + log.warn(`Failed to handle command:`, error ?? "Unknown error"); + return true; + } else { + const reaction = commandResult.result?.reaction || "✅"; + await this.botClient.sendEvent(this.roomId, "m.reaction", { + "m.relates_to": { + rel_type: "m.annotation", + event_id: ev.event_id, + key: reaction, + }, + }); + return true; } + } + + @botCommand("help", "This help text") + public async helpCommand() { + return this.botClient.sendEvent( + this.roomId, + "m.room.message", + this.helpMessage( + this.commandPrefix, + this.helpCategories, + this.includeTitlesInHelp, + ), + ); + } } diff --git a/src/Connections/FeedConnection.ts b/src/Connections/FeedConnection.ts index 9f6182792..ebf2291e8 100644 --- a/src/Connections/FeedConnection.ts +++ b/src/Connections/FeedConnection.ts @@ -1,7 +1,7 @@ import { Intent, StateEvent } from "matrix-bot-sdk"; import { IConnection, IConnectionState, InstantiateConnectionOpts } from "."; import { ApiError, ErrCode } from "../api"; -import { FeedEntry, FeedError} from "../feeds/FeedReader"; +import { FeedEntry, FeedError } from "../feeds/FeedReader"; import { Logger } from "matrix-appservice-bridge"; import { BaseConnection } from "./BaseConnection"; import markdown from "markdown-it"; @@ -12,32 +12,34 @@ import UserAgent from "../UserAgent"; import { retry, retryMatrixErrorFilter } from "../PromiseUtil"; const log = new Logger("FeedConnection"); const md = new markdown({ - html: true, + html: true, }); export interface LastResultOk { - timestamp: number; - ok: true; + timestamp: number; + ok: true; } export interface LastResultFail { - timestamp: number; - ok: false; - error?: string; + timestamp: number; + ok: false; + error?: string; } - export interface FeedConnectionState extends IConnectionState { - url: string; - label: string|undefined; - template: string|undefined; - notifyOnFailure: boolean|undefined; + url: string; + label: string | undefined; + template: string | undefined; + notifyOnFailure: boolean | undefined; } export interface FeedConnectionSecrets { - lastResults: Array; + lastResults: Array; } -export type FeedResponseItem = GetConnectionsResponseItem; +export type FeedResponseItem = GetConnectionsResponseItem< + FeedConnectionState, + FeedConnectionSecrets +>; const MAX_LAST_RESULT_ITEMS = 5; const VALIDATION_FETCH_TIMEOUT_S = 5; @@ -47,250 +49,308 @@ const SEND_EVENT_MAX_ATTEMPTS = 5; const SEND_EVENT_INTERVAL_MS = 5000; const DEFAULT_TEMPLATE = "New post in $FEEDNAME"; -const DEFAULT_TEMPLATE_WITH_CONTENT = "New post in $FEEDNAME: $LINK" -const DEFAULT_TEMPLATE_WITH_ONLY_TITLE = "New post in $FEEDNAME: $TITLE" +const DEFAULT_TEMPLATE_WITH_CONTENT = "New post in $FEEDNAME: $LINK"; +const DEFAULT_TEMPLATE_WITH_ONLY_TITLE = "New post in $FEEDNAME: $TITLE"; @Connection export class FeedConnection extends BaseConnection implements IConnection { - static readonly CanonicalEventType = "uk.half-shot.matrix-hookshot.feed"; - static readonly EventTypes = [ FeedConnection.CanonicalEventType ]; - static readonly ServiceCategory = "feeds"; - - - public static createConnectionForState(roomId: string, event: StateEvent, {config, intent}: InstantiateConnectionOpts) { - if (!config.feeds?.enabled) { - throw Error('RSS/Atom feeds are not configured'); - } - return new FeedConnection(roomId, event.stateKey, event.content, intent); - } - - static async validateUrl(url: string): Promise { - try { - new URL(url); - } catch (ex) { - throw new ApiError("Feed URL doesn't appear valid", ErrCode.BadValue); - } - - try { - 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); - } + static readonly CanonicalEventType = "uk.half-shot.matrix-hookshot.feed"; + static readonly EventTypes = [FeedConnection.CanonicalEventType]; + static readonly ServiceCategory = "feeds"; + + public static createConnectionForState( + roomId: string, + event: StateEvent, + { config, intent }: InstantiateConnectionOpts, + ) { + if (!config.feeds?.enabled) { + throw Error("RSS/Atom feeds are not configured"); } - - static validateState(data: Record = {}): FeedConnectionState { - const url = data.url; - if (typeof url !== 'string') { - throw new ApiError('No URL specified', ErrCode.BadValue); - } - if (typeof data.label !== 'undefined' && typeof data.label !== 'string') { - throw new ApiError('Label must be a string', ErrCode.BadValue); - } - - if (typeof data.template !== 'undefined') { - if (typeof data.template !== 'string') { - throw new ApiError('Template must be a string', ErrCode.BadValue); - } - // Sanity to prevent slowing hookshot down with massive templates. - if (data.template.length > MAX_TEMPLATE_LENGTH) { - throw new ApiError(`Template should not be longer than ${MAX_TEMPLATE_LENGTH} characters`, ErrCode.BadValue); - } - } - - 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, notifyOnFailure: data.notifyOnFailure }; + return new FeedConnection(roomId, event.stateKey, event.content, intent); + } + + static async validateUrl(url: string): Promise { + try { + new URL(url); + } catch (ex) { + throw new ApiError("Feed URL doesn't appear valid", ErrCode.BadValue); } - static async provisionConnection(roomId: string, _userId: string, data: Record = {}, { intent, config }: ProvisionConnectionOpts) { - if (!config.feeds?.enabled) { - throw new ApiError('RSS/Atom feeds are not configured', ErrCode.DisabledFeature); - } - - const state = this.validateState(data); - await FeedConnection.validateUrl(state.url); - const connection = new FeedConnection(roomId, state.url, state, intent); - await intent.underlyingClient.sendStateEvent(roomId, FeedConnection.CanonicalEventType, state.url, state); - - return { - connection, - stateEventContent: state, - } + try { + 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, + ); } - - public static getProvisionerDetails(botUserId: string) { - return { - service: "feeds", - eventType: FeedConnection.CanonicalEventType, - type: "Feed", - botUserId: botUserId, - } + } + + static validateState( + data: Record = {}, + ): FeedConnectionState { + const url = data.url; + if (typeof url !== "string") { + throw new ApiError("No URL specified", ErrCode.BadValue); } - - public getProvisionerDetails(): FeedResponseItem { - return { - ...FeedConnection.getProvisionerDetails(this.intent.userId), - id: this.connectionId, - config: { - url: this.feedUrl, - label: this.state.label, - template: this.state.template, - notifyOnFailure: this.state.notifyOnFailure, - }, - secrets: { - lastResults: this.lastResults, - } - } + if (typeof data.label !== "undefined" && typeof data.label !== "string") { + throw new ApiError("Label must be a string", ErrCode.BadValue); } - public templateFeedEntry(template: string, entry: FeedEntry) { - return template.replace(/(\$[A-Z]+)/g, (token: string) => { - switch(token) { - case "$FEEDNAME": - return this.state.label || entry.feed.title || entry.feed.url || ""; - case "$FEEDURL": - return entry.feed.url || ""; - case "$FEEDTITLE": - return entry.feed.title || ""; - case "$TITLE": - return entry.title || ""; - case "$LINK": - return entry.link ? `[${entry.title ?? entry.link}](${entry.link})` : ""; - case "$URL": - return entry.link || ""; - case "$AUTHOR": - return entry.author || ""; - case "$DATE": - return entry.pubdate || ""; - case "$SUMMARY": - return entry.summary || ""; - default: - return token; - } - }); - } - - private hasError = false; - private readonly lastResults = new Array(); - - public get feedUrl(): string { - return this.state.url; + if (typeof data.template !== "undefined") { + if (typeof data.template !== "string") { + throw new ApiError("Template must be a string", ErrCode.BadValue); + } + // Sanity to prevent slowing hookshot down with massive templates. + if (data.template.length > MAX_TEMPLATE_LENGTH) { + throw new ApiError( + `Template should not be longer than ${MAX_TEMPLATE_LENGTH} characters`, + ErrCode.BadValue, + ); + } } - constructor( - roomId: string, - stateKey: string, - private state: FeedConnectionState, - private readonly intent: Intent, + if ( + typeof data.notifyOnFailure !== "undefined" && + typeof data.notifyOnFailure !== "boolean" ) { - super(roomId, stateKey, FeedConnection.CanonicalEventType) - log.info(`Connection ${this.connectionId} created for ${roomId}, ${JSON.stringify(state)}`); + throw new ApiError("notifyOnFailure must be a boolean", ErrCode.BadValue); } - public isInterestedInStateEvent(eventType: string, stateKey: string): boolean { - return !!FeedConnection.EventTypes.find(e => e === eventType) && stateKey === this.feedUrl; + return { + url, + label: data.label, + template: data.template, + notifyOnFailure: data.notifyOnFailure, + }; + } + + static async provisionConnection( + roomId: string, + _userId: string, + data: Record = {}, + { intent, config }: ProvisionConnectionOpts, + ) { + if (!config.feeds?.enabled) { + throw new ApiError( + "RSS/Atom feeds are not configured", + ErrCode.DisabledFeature, + ); } - public async handleFeedEntry(entry: FeedEntry): Promise { - // This might be massive and cause us to fail to send the message - // so confine to a maximum size. - - if (entry.summary) { - if (entry.summary.length > MAX_SUMMARY_LENGTH) { - entry.summary = entry.summary.substring(0, MAX_SUMMARY_LENGTH) + "…"; - } - entry.summary = sanitizeHtml(entry.summary); - } - - let message: string; - if (this.state.template) { - message = this.templateFeedEntry(this.state.template, entry); - } else if (entry.link) { - message = this.templateFeedEntry(DEFAULT_TEMPLATE_WITH_CONTENT, entry); - } else if (entry.title) { - message = this.templateFeedEntry(DEFAULT_TEMPLATE_WITH_ONLY_TITLE, entry); - } else { - message = this.templateFeedEntry(DEFAULT_TEMPLATE, entry); - } - - // We want to retry these sends, because sometimes the network / HS - // craps out. - const content = { - msgtype: 'm.notice', - format: "org.matrix.custom.html", - formatted_body: md.renderInline(message), - body: message, - external_url: entry.link ?? undefined, - "uk.half-shot.matrix-hookshot.feeds.item": entry, - }; - await retry( - () => this.intent.sendEvent(this.roomId, content), - SEND_EVENT_MAX_ATTEMPTS, - SEND_EVENT_INTERVAL_MS, - // Filter for showstopper errors like 4XX errors, but otherwise - // retry until we hit the attempt limit. - retryMatrixErrorFilter - ); + const state = this.validateState(data); + await FeedConnection.validateUrl(state.url); + const connection = new FeedConnection(roomId, state.url, state, intent); + await intent.underlyingClient.sendStateEvent( + roomId, + FeedConnection.CanonicalEventType, + state.url, + state, + ); + + return { + connection, + stateEventContent: state, + }; + } + + public static getProvisionerDetails(botUserId: string) { + return { + service: "feeds", + eventType: FeedConnection.CanonicalEventType, + type: "Feed", + botUserId: botUserId, + }; + } + + public getProvisionerDetails(): FeedResponseItem { + return { + ...FeedConnection.getProvisionerDetails(this.intent.userId), + id: this.connectionId, + config: { + url: this.feedUrl, + label: this.state.label, + template: this.state.template, + notifyOnFailure: this.state.notifyOnFailure, + }, + secrets: { + lastResults: this.lastResults, + }, + }; + } + + public templateFeedEntry(template: string, entry: FeedEntry) { + return template.replace(/(\$[A-Z]+)/g, (token: string) => { + switch (token) { + case "$FEEDNAME": + return this.state.label || entry.feed.title || entry.feed.url || ""; + case "$FEEDURL": + return entry.feed.url || ""; + case "$FEEDTITLE": + return entry.feed.title || ""; + case "$TITLE": + return entry.title || ""; + case "$LINK": + return entry.link + ? `[${entry.title ?? entry.link}](${entry.link})` + : ""; + case "$URL": + return entry.link || ""; + case "$AUTHOR": + return entry.author || ""; + case "$DATE": + return entry.pubdate || ""; + case "$SUMMARY": + return entry.summary || ""; + default: + return token; + } + }); + } + + private hasError = false; + private readonly lastResults = new Array(); + + public get feedUrl(): string { + return this.state.url; + } + + constructor( + roomId: string, + stateKey: string, + private state: FeedConnectionState, + private readonly intent: Intent, + ) { + super(roomId, stateKey, FeedConnection.CanonicalEventType); + log.info( + `Connection ${this.connectionId} created for ${roomId}, ${JSON.stringify(state)}`, + ); + } + + public isInterestedInStateEvent( + eventType: string, + stateKey: string, + ): boolean { + return ( + !!FeedConnection.EventTypes.find((e) => e === eventType) && + stateKey === this.feedUrl + ); + } + + public async handleFeedEntry(entry: FeedEntry): Promise { + // This might be massive and cause us to fail to send the message + // so confine to a maximum size. + + if (entry.summary) { + if (entry.summary.length > MAX_SUMMARY_LENGTH) { + entry.summary = entry.summary.substring(0, MAX_SUMMARY_LENGTH) + "…"; + } + entry.summary = sanitizeHtml(entry.summary); } - public handleFeedSuccess() { - this.hasError = false; - this.lastResults.unshift({ - ok: true, - timestamp: Date.now(), - }); - this.lastResults.splice(MAX_LAST_RESULT_ITEMS-1, 1); + let message: string; + if (this.state.template) { + message = this.templateFeedEntry(this.state.template, entry); + } else if (entry.link) { + message = this.templateFeedEntry(DEFAULT_TEMPLATE_WITH_CONTENT, entry); + } else if (entry.title) { + message = this.templateFeedEntry(DEFAULT_TEMPLATE_WITH_ONLY_TITLE, entry); + } else { + message = this.templateFeedEntry(DEFAULT_TEMPLATE, entry); } - public async handleFeedError(error: FeedError): Promise { - this.lastResults.unshift({ - ok: false, - timestamp: Date.now(), - error: error.message, - }); - this.lastResults.splice(MAX_LAST_RESULT_ITEMS-1, 1); - const wasLastResultSuccessful = this.lastResults[0]?.ok !== false; - if (wasLastResultSuccessful && error.shouldErrorBeSilent) { - // To avoid short term failures bubbling up, if the error is serious, we still bubble. - return; - } - if (!this.state.notifyOnFailure) { - // User hasn't opted into notifications on failure - return; - } - if (!this.hasError) { - await this.intent.sendEvent(this.roomId, { - msgtype: 'm.notice', - format: 'm.text', - body: `Error fetching ${this.feedUrl}: ${error.cause.message}` - }); - this.hasError = true; - } + // We want to retry these sends, because sometimes the network / HS + // craps out. + const content = { + msgtype: "m.notice", + format: "org.matrix.custom.html", + formatted_body: md.renderInline(message), + body: message, + external_url: entry.link ?? undefined, + "uk.half-shot.matrix-hookshot.feeds.item": entry, + }; + await retry( + () => this.intent.sendEvent(this.roomId, content), + SEND_EVENT_MAX_ATTEMPTS, + SEND_EVENT_INTERVAL_MS, + // Filter for showstopper errors like 4XX errors, but otherwise + // retry until we hit the attempt limit. + retryMatrixErrorFilter, + ); + } + + public handleFeedSuccess() { + this.hasError = false; + this.lastResults.unshift({ + ok: true, + timestamp: Date.now(), + }); + this.lastResults.splice(MAX_LAST_RESULT_ITEMS - 1, 1); + } + + public async handleFeedError(error: FeedError): Promise { + this.lastResults.unshift({ + ok: false, + timestamp: Date.now(), + error: error.message, + }); + this.lastResults.splice(MAX_LAST_RESULT_ITEMS - 1, 1); + const wasLastResultSuccessful = this.lastResults[0]?.ok !== false; + if (wasLastResultSuccessful && error.shouldErrorBeSilent) { + // To avoid short term failures bubbling up, if the error is serious, we still bubble. + return; } - - public async provisionerUpdateConfig(userId: string, config: Record) { - // Apply previous state to the current config, as provisioners might not return "unknown" keys. - config = { ...this.state, ...config }; - const validatedConfig = FeedConnection.validateState(config); - if (validatedConfig.url !== this.feedUrl) { - throw new ApiError('Cannot alter url of existing feed. Please create a new one.', ErrCode.BadValue); - } - await this.intent.underlyingClient.sendStateEvent(this.roomId, FeedConnection.CanonicalEventType, this.stateKey, validatedConfig); - this.state = validatedConfig; + if (!this.state.notifyOnFailure) { + // User hasn't opted into notifications on failure + return; } - - // needed to ensure that the connection is removable - public async onRemove(): Promise { - log.info(`Removing connection ${this.connectionId}`); - await this.intent.underlyingClient.sendStateEvent(this.roomId, FeedConnection.CanonicalEventType, this.feedUrl, {}); + if (!this.hasError) { + await this.intent.sendEvent(this.roomId, { + msgtype: "m.notice", + format: "m.text", + body: `Error fetching ${this.feedUrl}: ${error.cause.message}`, + }); + this.hasError = true; } - - toString(): string { - return `FeedConnection ${this.state.url}`; + } + + public async provisionerUpdateConfig( + userId: string, + config: Record, + ) { + // Apply previous state to the current config, as provisioners might not return "unknown" keys. + config = { ...this.state, ...config }; + const validatedConfig = FeedConnection.validateState(config); + if (validatedConfig.url !== this.feedUrl) { + throw new ApiError( + "Cannot alter url of existing feed. Please create a new one.", + ErrCode.BadValue, + ); } + await this.intent.underlyingClient.sendStateEvent( + this.roomId, + FeedConnection.CanonicalEventType, + this.stateKey, + validatedConfig, + ); + this.state = validatedConfig; + } + + // needed to ensure that the connection is removable + public async onRemove(): Promise { + log.info(`Removing connection ${this.connectionId}`); + await this.intent.underlyingClient.sendStateEvent( + this.roomId, + FeedConnection.CanonicalEventType, + this.feedUrl, + {}, + ); + } + + toString(): string { + return `FeedConnection ${this.state.url}`; + } } diff --git a/src/Connections/FigmaFileConnection.ts b/src/Connections/FigmaFileConnection.ts index 918fc7360..fccb74226 100644 --- a/src/Connections/FigmaFileConnection.ts +++ b/src/Connections/FigmaFileConnection.ts @@ -6,14 +6,18 @@ import { IConnection, IConnectionState } from "."; import { Logger } from "matrix-appservice-bridge"; import { IBridgeStorageProvider } from "../Stores/StorageProvider"; import { BridgeConfig } from "../config/Config"; -import { Connection, InstantiateConnectionOpts, ProvisionConnectionOpts } from "./IConnection"; +import { + Connection, + InstantiateConnectionOpts, + ProvisionConnectionOpts, +} from "./IConnection"; import { ConfigGrantChecker, GrantChecker } from "../grants/GrantCheck"; const log = new Logger("FigmaFileConnection"); export interface FigmaFileConnectionState extends IConnectionState { - fileId: string; - instanceName?: string; + fileId: string; + instanceName?: string; } const THREAD_RELATION_TYPE = "m.thread"; @@ -21,146 +25,213 @@ const THREAD_RELATION_TYPE = "m.thread"; const md = markdownit(); @Connection export class FigmaFileConnection extends BaseConnection implements IConnection { - static readonly CanonicalEventType = "uk.half-shot.matrix-hookshot.figma.file"; - static readonly LegacyEventType = "uk.half-shot.matrix-figma.file"; // Magically import from matrix-figma - - static readonly EventTypes = [ - FigmaFileConnection.CanonicalEventType, - FigmaFileConnection.LegacyEventType, - ]; - static readonly ServiceCategory = "figma"; - - - public static validateState(data: Record): FigmaFileConnectionState { - if (!data.fileId || typeof data.fileId !== "string") { - throw Error('Missing or invalid fileId'); - } - if (data.instanceName && typeof data.instanceName !== "string") { - throw Error('Invalid instanceName'); - } - return { - instanceName: typeof data.instanceName === "string" ? data.instanceName : undefined, - fileId: data.fileId, - } - } - - public static createConnectionForState(roomId: string, event: StateEvent, {config, as, intent, storage}: InstantiateConnectionOpts) { - if (!config.figma) { - throw Error('Figma is not configured'); - } - return new FigmaFileConnection(roomId, event.stateKey, event.content, config, as, intent, storage); - } - - static async provisionConnection(roomId: string, userId: string, data: Record = {}, {as, intent, config, storage}: ProvisionConnectionOpts) { - if (!config.figma) { - throw Error('Figma is not configured'); - } - const validState = this.validateState(data); - const connection = new FigmaFileConnection(roomId, validState.fileId, validState, config, as, intent, storage); - await new GrantChecker(as.botIntent, "figma").grantConnection(roomId, { fileId: validState.fileId, instanceName: validState.instanceName || "none"}); - await intent.underlyingClient.sendStateEvent(roomId, FigmaFileConnection.CanonicalEventType, validState.fileId, validState); - return { - connection, - stateEventContent: validState, - } - } - - private readonly grantChecker: GrantChecker<{fileId: string, instanceName: string}>; - - constructor( - roomId: string, - stateKey: string, - private state: FigmaFileConnectionState, - private readonly config: BridgeConfig, - private readonly as: Appservice, - private readonly intent: Intent, - private readonly storage: IBridgeStorageProvider) { - super(roomId, stateKey, FigmaFileConnection.CanonicalEventType) - this.grantChecker = new ConfigGrantChecker("figma", this.as, this.config); - } - - public isInterestedInStateEvent() { - return false; // We don't support state-updates...yet. - } - - public get fileId() { - return this.state.fileId; + static readonly CanonicalEventType = + "uk.half-shot.matrix-hookshot.figma.file"; + static readonly LegacyEventType = "uk.half-shot.matrix-figma.file"; // Magically import from matrix-figma + + static readonly EventTypes = [ + FigmaFileConnection.CanonicalEventType, + FigmaFileConnection.LegacyEventType, + ]; + static readonly ServiceCategory = "figma"; + + public static validateState( + data: Record, + ): FigmaFileConnectionState { + if (!data.fileId || typeof data.fileId !== "string") { + throw Error("Missing or invalid fileId"); } - - public get instanceName() { - return this.state.instanceName; + if (data.instanceName && typeof data.instanceName !== "string") { + throw Error("Invalid instanceName"); } - - public get priority(): number { - return this.state.priority || super.priority; + return { + instanceName: + typeof data.instanceName === "string" ? data.instanceName : undefined, + fileId: data.fileId, + }; + } + + public static createConnectionForState( + roomId: string, + event: StateEvent, + { config, as, intent, storage }: InstantiateConnectionOpts, + ) { + if (!config.figma) { + throw Error("Figma is not configured"); } - - public async ensureGrant(sender?: string) { - return this.grantChecker.assertConnectionGranted(this.roomId, { fileId: this.state.fileId, instanceName: this.state.instanceName || "none"}, sender); + return new FigmaFileConnection( + roomId, + event.stateKey, + event.content, + config, + as, + intent, + storage, + ); + } + + static async provisionConnection( + roomId: string, + userId: string, + data: Record = {}, + { as, intent, config, storage }: ProvisionConnectionOpts, + ) { + if (!config.figma) { + throw Error("Figma is not configured"); } - - public async onRemove() { - return this.grantChecker.ungrantConnection(this.roomId, { fileId: this.state.fileId, instanceName: this.state.instanceName || "none"}); + const validState = this.validateState(data); + const connection = new FigmaFileConnection( + roomId, + validState.fileId, + validState, + config, + as, + intent, + storage, + ); + await new GrantChecker(as.botIntent, "figma").grantConnection(roomId, { + fileId: validState.fileId, + instanceName: validState.instanceName || "none", + }); + await intent.underlyingClient.sendStateEvent( + roomId, + FigmaFileConnection.CanonicalEventType, + validState.fileId, + validState, + ); + return { + connection, + stateEventContent: validState, + }; + } + + private readonly grantChecker: GrantChecker<{ + fileId: string; + instanceName: string; + }>; + + constructor( + roomId: string, + stateKey: string, + private state: FigmaFileConnectionState, + private readonly config: BridgeConfig, + private readonly as: Appservice, + private readonly intent: Intent, + private readonly storage: IBridgeStorageProvider, + ) { + super(roomId, stateKey, FigmaFileConnection.CanonicalEventType); + this.grantChecker = new ConfigGrantChecker("figma", this.as, this.config); + } + + public isInterestedInStateEvent() { + return false; // We don't support state-updates...yet. + } + + public get fileId() { + return this.state.fileId; + } + + public get instanceName() { + return this.state.instanceName; + } + + public get priority(): number { + return this.state.priority || super.priority; + } + + public async ensureGrant(sender?: string) { + return this.grantChecker.assertConnectionGranted( + this.roomId, + { + fileId: this.state.fileId, + instanceName: this.state.instanceName || "none", + }, + sender, + ); + } + + public async onRemove() { + return this.grantChecker.ungrantConnection(this.roomId, { + fileId: this.state.fileId, + instanceName: this.state.instanceName || "none", + }); + } + + public async handleNewComment(payload: FigmaPayload) { + // We need to check if the comment was actually new. + // There isn't a way to tell how the comment has changed, so for now check the timestamps + const age = Date.now() - Date.parse(payload.created_at); + if (age > 5000) { + // Comment was created at least 5 seconds before the webhook, ignore it. + log.warn( + `Comment ${payload.comment_id} is stale, ignoring (${age}ms old)`, + ); + return; } - public async handleNewComment(payload: FigmaPayload) { - // We need to check if the comment was actually new. - // There isn't a way to tell how the comment has changed, so for now check the timestamps - const age = Date.now() - Date.parse(payload.created_at); - if (age > 5000) { - // Comment was created at least 5 seconds before the webhook, ignore it. - log.warn(`Comment ${payload.comment_id} is stale, ignoring (${age}ms old)`); - return; - } - - let intent; - if (this.config.figma?.overrideUserId) { - intent = this.as.getIntentForUserId(this.config.figma.overrideUserId); - } else { - intent = this.intent; - } - - const permalink = `https://www.figma.com/file/${payload.file_key}#${payload.comment_id}`; - const comment = payload.comment.map(({text}) => text).join("\n"); - const empty = "‎"; // This contains an empty character to thwart the notification matcher. - const name = payload.triggered_by.handle.split(' ').map(p => p[0] + empty + p.slice(1)).join(' '); - let content: Record|undefined = undefined; - const parentEventId = payload.parent_id && await this.storage.getFigmaCommentEventId(this.roomId, payload.parent_id); - if (parentEventId) { - content = { - "m.relates_to": { - rel_type: THREAD_RELATION_TYPE, - event_id: parentEventId, - // Needed to prevent clients from showing these as actual replies - is_falling_back: true, - "m.in_reply_to": { - event_id: parentEventId, - } - }, - body: `**${name}**: ${comment}`, - formatted_body: `${name}: ${comment}`, - format: "org.matrix.custom.html", - msgtype: "m.notice", - "uk.half-shot.matrix-hookshot.figma.comment_id": payload.comment_id, - } - } else { - // Root event. - const body = `**${name}** [commented](${permalink}) on [${payload.file_name}](https://www.figma.com/file/${payload.file_key}): ${comment}`; - content = { - msgtype: "m.notice", - body: body, - formatted_body: md.renderInline(body), - format: "org.matrix.custom.html", - "uk.half-shot.matrix-hookshot.figma.comment_id": payload.comment_id, - }; - } - content["uk.half-shot.matrix-hookshot.figma.comment_id"] = payload.comment_id; - const eventId = await intent.sendEvent(this.roomId, content); - log.info(`New figma comment ${payload.comment_id} -> ${this.roomId}/${eventId}`) - await this.storage.setFigmaCommentEventId(this.roomId, payload.comment_id, eventId); + let intent; + if (this.config.figma?.overrideUserId) { + intent = this.as.getIntentForUserId(this.config.figma.overrideUserId); + } else { + intent = this.intent; } - public toString() { - return `FigmaFileConnection ${this.instanceName}/${this.fileId || "*"}`; + const permalink = `https://www.figma.com/file/${payload.file_key}#${payload.comment_id}`; + const comment = payload.comment.map(({ text }) => text).join("\n"); + const empty = "‎"; // This contains an empty character to thwart the notification matcher. + const name = payload.triggered_by.handle + .split(" ") + .map((p) => p[0] + empty + p.slice(1)) + .join(" "); + let content: Record | undefined = undefined; + const parentEventId = + payload.parent_id && + (await this.storage.getFigmaCommentEventId( + this.roomId, + payload.parent_id, + )); + if (parentEventId) { + content = { + "m.relates_to": { + rel_type: THREAD_RELATION_TYPE, + event_id: parentEventId, + // Needed to prevent clients from showing these as actual replies + is_falling_back: true, + "m.in_reply_to": { + event_id: parentEventId, + }, + }, + body: `**${name}**: ${comment}`, + formatted_body: `${name}: ${comment}`, + format: "org.matrix.custom.html", + msgtype: "m.notice", + "uk.half-shot.matrix-hookshot.figma.comment_id": payload.comment_id, + }; + } else { + // Root event. + const body = `**${name}** [commented](${permalink}) on [${payload.file_name}](https://www.figma.com/file/${payload.file_key}): ${comment}`; + content = { + msgtype: "m.notice", + body: body, + formatted_body: md.renderInline(body), + format: "org.matrix.custom.html", + "uk.half-shot.matrix-hookshot.figma.comment_id": payload.comment_id, + }; } + content["uk.half-shot.matrix-hookshot.figma.comment_id"] = + payload.comment_id; + const eventId = await intent.sendEvent(this.roomId, content); + log.info( + `New figma comment ${payload.comment_id} -> ${this.roomId}/${eventId}`, + ); + await this.storage.setFigmaCommentEventId( + this.roomId, + payload.comment_id, + eventId, + ); + } + + public toString() { + return `FigmaFileConnection ${this.instanceName}/${this.fileId || "*"}`; + } } diff --git a/src/Connections/GenericHook.ts b/src/Connections/GenericHook.ts index 76b6bf1a6..81ac51851 100644 --- a/src/Connections/GenericHook.ts +++ b/src/Connections/GenericHook.ts @@ -1,6 +1,12 @@ -import { Connection, IConnection, IConnectionState, InstantiateConnectionOpts, ProvisionConnectionOpts } from "./IConnection"; +import { + Connection, + IConnection, + IConnectionState, + InstantiateConnectionOpts, + ProvisionConnectionOpts, +} from "./IConnection"; import { Logger } from "matrix-appservice-bridge"; -import { MessageSenderClient } from "../MatrixSender" +import { MessageSenderClient } from "../MatrixSender"; import markdownit from "markdown-it"; import { MatrixEvent } from "../MatrixEvent"; import { Appservice, Intent, StateEvent } from "matrix-bot-sdk"; @@ -8,67 +14,74 @@ import { ApiError, ErrCode } from "../api"; import { BaseConnection } from "./BaseConnection"; import { BridgeConfigGenericWebhooks } from "../config/sections"; import { ensureUserIsInRoom } from "../IntentUtils"; -import { randomUUID } from 'node:crypto'; +import { randomUUID } from "node:crypto"; import { GenericWebhookEventResult } from "../generic/types"; import { StatusCodes } from "http-status-codes"; import { IBridgeStorageProvider } from "../Stores/StorageProvider"; import { formatDuration, isMatch, millisecondsToHours } from "date-fns"; -import { ExecuteResultContent, ExecuteResultWebhookResponse, WebhookTransformer } from "../generic/transformer"; +import { + ExecuteResultContent, + ExecuteResultWebhookResponse, + WebhookTransformer, +} from "../generic/transformer"; import { GetConnectionsResponseItem } from "../Widgets/api"; export interface GenericHookConnectionState extends IConnectionState { - /** - * This is ONLY used for display purposes, but the account data value is used to prevent misuse. - */ - hookId?: string; - /** - * The name given in the provisioning UI and displaynames. - */ - name: string; - transformationFunction?: string; - /** - * Should the webhook only respond on completion. - */ - waitForComplete?: boolean|undefined; - - /** - * If the webhook has an expriation date, then the date at which the webhook is no longer value - * (in UTC) time. - */ - expirationDate?: string; + /** + * This is ONLY used for display purposes, but the account data value is used to prevent misuse. + */ + hookId?: string; + /** + * The name given in the provisioning UI and displaynames. + */ + name: string; + transformationFunction?: string; + /** + * Should the webhook only respond on completion. + */ + waitForComplete?: boolean | undefined; + + /** + * If the webhook has an expriation date, then the date at which the webhook is no longer value + * (in UTC) time. + */ + expirationDate?: string; } export interface GenericHookSecrets { - /** - * The public URL for the webhook. - */ - url: URL; - /** - * The hookId of the webhook. - */ - hookId: string; - /** - * How long remains until the webhook expires. - */ - timeRemainingMs?: number + /** + * The public URL for the webhook. + */ + url: URL; + /** + * The hookId of the webhook. + */ + hookId: string; + /** + * How long remains until the webhook expires. + */ + timeRemainingMs?: number; } -export type GenericHookResponseItem = GetConnectionsResponseItem; +export type GenericHookResponseItem = GetConnectionsResponseItem< + GenericHookConnectionState, + GenericHookSecrets +>; /** */ export interface GenericHookAccountData { - /** - * This is where the true hook ID is kept. Each hook ID maps to a state_key. - */ - [hookId: string]: string; + /** + * This is where the true hook ID is kept. Each hook ID maps to a state_key. + */ + [hookId: string]: string; } export interface GenericHookServiceConfig { - userIdPrefix?: string; - allowJsTransformationFunctions?: boolean, - waitForComplete?: boolean, - maxExpiryTime?: number, - requireExpiryTime: boolean, + userIdPrefix?: string; + allowJsTransformationFunctions?: boolean; + waitForComplete?: boolean; + maxExpiryTime?: number; + requireExpiryTime: boolean; } const log = new Logger("GenericHookConnection"); @@ -81,513 +94,712 @@ const WARN_AT_EXPIRY_MS = 3 * 24 * 60 * 60 * 1000; const MIN_EXPIRY_MS = 60 * 60 * 1000; const CHECK_EXPIRY_MS = 15 * 60 * 1000; -const EXPIRY_NOTICE_MESSAGE = "The webhook **%NAME** will be expiring in %TIME." +const EXPIRY_NOTICE_MESSAGE = + "The webhook **%NAME** will be expiring in %TIME."; /** * Handles rooms connected to a generic webhook. */ @Connection -export class GenericHookConnection extends BaseConnection implements IConnection { - /** - * Ensures a JSON payload is compatible with Matrix JSON requirements, such - * as disallowing floating point values. - * - * If the `depth` exceeds `SANITIZE_MAX_DEPTH`, the value of `data` will be immediately returned. - * If the object contains more than `SANITIZE_MAX_BREADTH` entries, the remaining entries will not be checked. - * - * @param data The data to santise - * @param depth The depth of the `data` relative to the root. - * @param breadth The breadth of the `data` in the parent object. - * @returns - */ - static sanitiseObjectForMatrixJSON(data: unknown, depth = 0, breadth = 0): unknown { - // Floats - if (typeof data === "number" && !Number.isInteger(data)) { - return data.toString(); - } - // Primitive types - if (typeof data !== "object" || data === null) { - return data; - } - - // Over processing limit, return string. - if (depth > SANITIZE_MAX_DEPTH || breadth > SANITIZE_MAX_BREADTH) { - return JSON.stringify(data); - } - - const newDepth = depth + 1; - if (Array.isArray(data)) { - return data.map((d, innerBreadth) => this.sanitiseObjectForMatrixJSON(d, newDepth, innerBreadth)); - } - - let objBreadth = 0; - const obj: Record = { ...data }; - for (const [key, value] of Object.entries(data)) { - obj[key] = this.sanitiseObjectForMatrixJSON(value, newDepth, ++objBreadth); - } - - return obj; +export class GenericHookConnection + extends BaseConnection + implements IConnection +{ + /** + * Ensures a JSON payload is compatible with Matrix JSON requirements, such + * as disallowing floating point values. + * + * If the `depth` exceeds `SANITIZE_MAX_DEPTH`, the value of `data` will be immediately returned. + * If the object contains more than `SANITIZE_MAX_BREADTH` entries, the remaining entries will not be checked. + * + * @param data The data to santise + * @param depth The depth of the `data` relative to the root. + * @param breadth The breadth of the `data` in the parent object. + * @returns + */ + static sanitiseObjectForMatrixJSON( + data: unknown, + depth = 0, + breadth = 0, + ): unknown { + // Floats + if (typeof data === "number" && !Number.isInteger(data)) { + return data.toString(); + } + // Primitive types + if (typeof data !== "object" || data === null) { + return data; } - static validateState(state: Partial>): GenericHookConnectionState { - const {name, transformationFunction, waitForComplete, expirationDate: expirationDateStr} = 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 (!WebhookTransformer.canTransform) { - 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); - } - } - let expirationDate: string|undefined; - if (expirationDateStr != undefined) { - if (typeof expirationDateStr !== "string" || !expirationDateStr) { - throw new ApiError("'expirationDate' must be a non-empty string", ErrCode.BadValue); - } - if (!isMatch(expirationDateStr, "yyyy-MM-dd'T'HH:mm:ss.SSSXX")) { - throw new ApiError("'expirationDate' must be a valid date", ErrCode.BadValue); - } - expirationDate = expirationDateStr; - } + // Over processing limit, return string. + if (depth > SANITIZE_MAX_DEPTH || breadth > SANITIZE_MAX_BREADTH) { + return JSON.stringify(data); + } - return { - name, - transformationFunction: transformationFunction || undefined, - waitForComplete, - expirationDate, - }; + const newDepth = depth + 1; + if (Array.isArray(data)) { + return data.map((d, innerBreadth) => + this.sanitiseObjectForMatrixJSON(d, newDepth, innerBreadth), + ); } - static async createConnectionForState(roomId: string, event: StateEvent>, {as, intent, config, messageClient, storage}: InstantiateConnectionOpts) { - if (!config.generic) { - throw Error('Generic webhooks are not configured'); - } - // Generic hooks store the hookId in the account data - const acctData = await intent.underlyingClient.getSafeRoomAccountData(GenericHookConnection.CanonicalEventType, roomId, {}); - const state = this.validateState(event.content); - // hookId => stateKey - let hookId = Object.entries(acctData).find(([, v]) => v === event.stateKey)?.[0]; - if (!hookId) { - hookId = randomUUID(); - log.warn(`hookId for ${roomId} not set in accountData, setting to ${hookId}`); - // If this is a new hook... - if (config.generic.requireExpiryTime && !state.expirationDate) { - throw new Error('Expiration date must be set'); - } - await GenericHookConnection.ensureRoomAccountData(roomId, intent, hookId, event.stateKey); - } + let objBreadth = 0; + const obj: Record = { ...data }; + for (const [key, value] of Object.entries(data)) { + obj[key] = this.sanitiseObjectForMatrixJSON( + value, + newDepth, + ++objBreadth, + ); + } - return new GenericHookConnection( - roomId, - state, - hookId, - event.stateKey, - messageClient, - config.generic, - as, - intent, - storage, + return obj; + } + + static validateState( + state: Partial>, + ): GenericHookConnectionState { + const { + name, + transformationFunction, + waitForComplete, + expirationDate: expirationDateStr, + } = 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 (!WebhookTransformer.canTransform) { + 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, + ); + } } - - static async provisionConnection(roomId: string, userId: string, data: Partial> = {}, {as, intent, config, messageClient, storage}: ProvisionConnectionOpts) { - if (!config.generic) { - throw Error('Generic Webhooks are not configured'); - } - const hookId = randomUUID(); - const validState = GenericHookConnection.validateState(data); - const expiryTime = await config.generic.maxExpiryTimeMs; - if (validState.expirationDate) { - const durationRemaining = new Date(validState.expirationDate).getTime() - Date.now(); - if (expiryTime) { - if (durationRemaining > expiryTime) { - throw new ApiError('Expiration date cannot exceed the configured max expiry time', ErrCode.BadValue); - } - } - if (durationRemaining < MIN_EXPIRY_MS) { - // If the webhook is actually created with a shorter expiry time than - // our warning period, then just mark it as warned. - throw new ApiError('Expiration date must at least be a hour in the future', ErrCode.BadValue); - } - if (durationRemaining < WARN_AT_EXPIRY_MS) { - // If the webhook is actually created with a shorter expiry time than - // our warning period, then just mark it as warned. - await storage.setHasGenericHookWarnedExpiry(hookId, true); - } - } else if (config.generic.requireExpiryTime) { - throw new ApiError('Expiration date must be set', ErrCode.BadValue); - } - - - await GenericHookConnection.ensureRoomAccountData(roomId, intent, hookId, validState.name); - await intent.underlyingClient.sendStateEvent(roomId, this.CanonicalEventType, validState.name, validState); - const connection = new GenericHookConnection(roomId, validState, hookId, validState.name, messageClient, config.generic, as, intent, storage); - return { - connection, - stateEventContent: validState, - } + let expirationDate: string | undefined; + if (expirationDateStr != undefined) { + if (typeof expirationDateStr !== "string" || !expirationDateStr) { + throw new ApiError( + "'expirationDate' must be a non-empty string", + ErrCode.BadValue, + ); + } + if (!isMatch(expirationDateStr, "yyyy-MM-dd'T'HH:mm:ss.SSSXX")) { + throw new ApiError( + "'expirationDate' must be a valid date", + ErrCode.BadValue, + ); + } + expirationDate = expirationDateStr; } - /** - * This function ensures the account data for a room contains all the hookIds for the various state events. - */ - static async ensureRoomAccountData(roomId: string, intent: Intent, hookId: string, stateKey: string, remove = false) { - const data = await intent.underlyingClient.getSafeRoomAccountData(GenericHookConnection.CanonicalEventType, roomId, {}); - if (remove && data[hookId] === stateKey) { - delete data[hookId]; - await intent.underlyingClient.setRoomAccountData(GenericHookConnection.CanonicalEventType, roomId, data); - } - if (!remove && data[hookId] !== stateKey) { - data[hookId] = stateKey; - await intent.underlyingClient.setRoomAccountData(GenericHookConnection.CanonicalEventType, roomId, data); - } + return { + name, + transformationFunction: transformationFunction || undefined, + waitForComplete, + expirationDate, + }; + } + + static async createConnectionForState( + roomId: string, + event: StateEvent>, + { as, intent, config, messageClient, storage }: InstantiateConnectionOpts, + ) { + if (!config.generic) { + throw Error("Generic webhooks are not configured"); + } + // Generic hooks store the hookId in the account data + const acctData = + await intent.underlyingClient.getSafeRoomAccountData( + GenericHookConnection.CanonicalEventType, + roomId, + {}, + ); + const state = this.validateState(event.content); + // hookId => stateKey + let hookId = Object.entries(acctData).find( + ([, v]) => v === event.stateKey, + )?.[0]; + if (!hookId) { + hookId = randomUUID(); + log.warn( + `hookId for ${roomId} not set in accountData, setting to ${hookId}`, + ); + // If this is a new hook... + if (config.generic.requireExpiryTime && !state.expirationDate) { + throw new Error("Expiration date must be set"); + } + await GenericHookConnection.ensureRoomAccountData( + roomId, + intent, + hookId, + event.stateKey, + ); } - static readonly CanonicalEventType = "uk.half-shot.matrix-hookshot.generic.hook"; - static readonly LegacyCanonicalEventType = "uk.half-shot.matrix-github.generic.hook"; - static readonly ServiceCategory = "generic"; + return new GenericHookConnection( + roomId, + state, + hookId, + event.stateKey, + messageClient, + config.generic, + as, + intent, + storage, + ); + } + + static async provisionConnection( + roomId: string, + userId: string, + data: Partial> = {}, + { as, intent, config, messageClient, storage }: ProvisionConnectionOpts, + ) { + if (!config.generic) { + throw Error("Generic Webhooks are not configured"); + } + const hookId = randomUUID(); + const validState = GenericHookConnection.validateState(data); + const expiryTime = await config.generic.maxExpiryTimeMs; + if (validState.expirationDate) { + const durationRemaining = + new Date(validState.expirationDate).getTime() - Date.now(); + if (expiryTime) { + if (durationRemaining > expiryTime) { + throw new ApiError( + "Expiration date cannot exceed the configured max expiry time", + ErrCode.BadValue, + ); + } + } + if (durationRemaining < MIN_EXPIRY_MS) { + // If the webhook is actually created with a shorter expiry time than + // our warning period, then just mark it as warned. + throw new ApiError( + "Expiration date must at least be a hour in the future", + ErrCode.BadValue, + ); + } + if (durationRemaining < WARN_AT_EXPIRY_MS) { + // If the webhook is actually created with a shorter expiry time than + // our warning period, then just mark it as warned. + await storage.setHasGenericHookWarnedExpiry(hookId, true); + } + } else if (config.generic.requireExpiryTime) { + throw new ApiError("Expiration date must be set", ErrCode.BadValue); + } - static readonly EventTypes = [ + 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, + storage, + ); + return { + connection, + stateEventContent: validState, + }; + } + + /** + * This function ensures the account data for a room contains all the hookIds for the various state events. + */ + static async ensureRoomAccountData( + roomId: string, + intent: Intent, + hookId: string, + stateKey: string, + remove = false, + ) { + const data = + await intent.underlyingClient.getSafeRoomAccountData( GenericHookConnection.CanonicalEventType, - GenericHookConnection.LegacyCanonicalEventType, - ]; - - private webhookTransformer?: WebhookTransformer; - private cachedDisplayname?: string; - private warnOnExpiryInterval?: NodeJS.Timeout; - - /** - * @param state Should be a pre-validated state object returned by {@link validateState} - */ - constructor( - roomId: string, - private state: GenericHookConnectionState, - public readonly hookId: string, - stateKey: string, - private readonly messageClient: MessageSenderClient, - private readonly config: BridgeConfigGenericWebhooks, - private readonly as: Appservice, - private readonly intent: Intent, - private readonly storage: IBridgeStorageProvider, + roomId, + {}, + ); + if (remove && data[hookId] === stateKey) { + delete data[hookId]; + await intent.underlyingClient.setRoomAccountData( + GenericHookConnection.CanonicalEventType, + roomId, + data, + ); + } + if (!remove && data[hookId] !== stateKey) { + data[hookId] = stateKey; + await intent.underlyingClient.setRoomAccountData( + GenericHookConnection.CanonicalEventType, + roomId, + data, + ); + } + } + + static readonly CanonicalEventType = + "uk.half-shot.matrix-hookshot.generic.hook"; + static readonly LegacyCanonicalEventType = + "uk.half-shot.matrix-github.generic.hook"; + static readonly ServiceCategory = "generic"; + + static readonly EventTypes = [ + GenericHookConnection.CanonicalEventType, + GenericHookConnection.LegacyCanonicalEventType, + ]; + + private webhookTransformer?: WebhookTransformer; + private cachedDisplayname?: string; + private warnOnExpiryInterval?: NodeJS.Timeout; + + /** + * @param state Should be a pre-validated state object returned by {@link validateState} + */ + constructor( + roomId: string, + private state: GenericHookConnectionState, + public readonly hookId: string, + stateKey: string, + private readonly messageClient: MessageSenderClient, + private readonly config: BridgeConfigGenericWebhooks, + private readonly as: Appservice, + private readonly intent: Intent, + private readonly storage: IBridgeStorageProvider, + ) { + super(roomId, stateKey, GenericHookConnection.CanonicalEventType); + if (state.transformationFunction && WebhookTransformer.canTransform) { + this.webhookTransformer = new WebhookTransformer( + state.transformationFunction, + ); + } + this.handleExpiryTimeUpdate(false).catch((ex) => { + log.warn("Failed to configure expiry time warning for hook", ex); + }); + } + + public get expiresAt(): Date | undefined { + return this.state.expirationDate + ? new Date(this.state.expirationDate) + : undefined; + } + + /** + * 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; + } + + public isInterestedInStateEvent(eventType: string, stateKey: string) { + return ( + GenericHookConnection.EventTypes.includes(eventType) && + this.stateKey === stateKey + ); + } + + public getUserId() { + if (!this.config.userIdPrefix) { + return this.intent.userId; + } + const [, domain] = this.intent.userId.split(":"); + const name = + this.state.name && + this.state.name + .replace(/[A-Z]/g, (s) => s.toLowerCase()) + .replace(/([^a-z0-9\-.=_]+)/g, ""); + return `@${this.config.userIdPrefix}${name || "bot"}:${domain}`; + } + + public async ensureDisplayname(intent: Intent) { + if (!this.state.name) { + return; + } + if (this.intent.userId === intent.userId) { + // Don't set a displayname on the root bot user. + return; + } + await intent.ensureRegistered(); + if ( + (await intent.underlyingClient.getCapabilities())["m.set_displayname"] + ?.enabled === false ) { - super(roomId, stateKey, GenericHookConnection.CanonicalEventType); - if (state.transformationFunction && WebhookTransformer.canTransform) { - this.webhookTransformer = new WebhookTransformer(state.transformationFunction); - } - this.handleExpiryTimeUpdate(false).catch(ex => { - log.warn("Failed to configure expiry time warning for hook", ex); + return; + } + const expectedDisplayname = `${this.state.name} (Webhook)`; + + try { + if (this.cachedDisplayname !== expectedDisplayname) { + this.cachedDisplayname = ( + await intent.underlyingClient.getUserProfile(this.intent.userId) + ).displayname; + } + } catch (ex) { + // Couldn't fetch, probably not set. + this.cachedDisplayname = undefined; + } + if (this.cachedDisplayname !== expectedDisplayname) { + await intent.underlyingClient.setDisplayName( + `${this.state.name} (Webhook)`, + ); + this.cachedDisplayname = expectedDisplayname; + } + } + + public async onStateUpdate(stateEv: MatrixEvent) { + const validatedConfig = GenericHookConnection.validateState( + stateEv.content as Record, + ); + if (validatedConfig.transformationFunction) { + const error = WebhookTransformer.validateScript( + validatedConfig.transformationFunction, + ); + if (error) { + const errorPrefix = "Could not compile transformation function:"; + await this.intent.sendEvent(this.roomId, { + msgtype: "m.text", + body: errorPrefix + "\n\n```json\n\n" + error + "\n\n```", + formatted_body: `

${errorPrefix}

${error}

`, + format: "org.matrix.custom.html", }); + } else { + this.webhookTransformer = new WebhookTransformer( + validatedConfig.transformationFunction, + ); + } + } else { + this.webhookTransformer = undefined; } - public get expiresAt(): Date|undefined { - return this.state.expirationDate ? new Date(this.state.expirationDate) : undefined; + const prevDate = this.state.expirationDate; + this.state = validatedConfig; + if (prevDate !== validatedConfig.expirationDate) { + await this.handleExpiryTimeUpdate(true); } - - /** - * Should the webhook handler wait for this to finish before - * sending a response back. - */ - public get waitForComplete(): boolean { - return this.state.waitForComplete ?? false; + } + + /** + * Called when the expiry time has been updated for the connection. If the connection + * no longer has an expiry time. This voids the interval. + * @returns + */ + private async handleExpiryTimeUpdate(shouldWrite: boolean) { + if (!this.config.sendExpiryNotice) { + return; } - - public get priority(): number { - return this.state.priority || super.priority; + if (this.warnOnExpiryInterval) { + clearInterval(this.warnOnExpiryInterval); + this.warnOnExpiryInterval = undefined; } - - public isInterestedInStateEvent(eventType: string, stateKey: string) { - return GenericHookConnection.EventTypes.includes(eventType) && this.stateKey === stateKey; + if (!this.state.expirationDate) { + return; } - public getUserId() { - if (!this.config.userIdPrefix) { - return this.intent.userId; - } - const [, domain] = this.intent.userId.split(':'); - const name = this.state.name && - this.state.name.replace(/[A-Z]/g, (s) => s.toLowerCase()).replace(/([^a-z0-9\-.=_]+)/g, ''); - return `@${this.config.userIdPrefix}${name || 'bot'}:${domain}`; + const durationRemaining = + new Date(this.state.expirationDate).getTime() - Date.now(); + if (durationRemaining < WARN_AT_EXPIRY_MS) { + // If the webhook is actually created with a shorter expiry time than + // our warning period, then just mark it as warned. + if (shouldWrite) { + await this.storage.setHasGenericHookWarnedExpiry(this.hookId, true); + } + } else { + const fuzzCheckTimeMs = Math.round(Math.random() * CHECK_EXPIRY_MS); + this.warnOnExpiryInterval = setInterval(() => { + this.checkAndWarnExpiry().catch((ex) => { + log.warn("Failed to check expiry time for hook", ex); + }); + }, CHECK_EXPIRY_MS + fuzzCheckTimeMs); + if (shouldWrite) { + await this.storage.setHasGenericHookWarnedExpiry(this.hookId, false); + } } - - public async ensureDisplayname(intent: Intent) { - if (!this.state.name) { - return; - } - if (this.intent.userId === intent.userId) { - // Don't set a displayname on the root bot user. - return; - } - await intent.ensureRegistered(); - if ((await intent.underlyingClient.getCapabilities())["m.set_displayname"]?.enabled === false) { - return; - } - const expectedDisplayname = `${this.state.name} (Webhook)`; - - try { - if (this.cachedDisplayname !== expectedDisplayname) { - this.cachedDisplayname = (await intent.underlyingClient.getUserProfile(this.intent.userId)).displayname; - } - } catch (ex) { - // Couldn't fetch, probably not set. - this.cachedDisplayname = undefined; - } - if (this.cachedDisplayname !== expectedDisplayname) { - await intent.underlyingClient.setDisplayName(`${this.state.name} (Webhook)`); - this.cachedDisplayname = expectedDisplayname; - } + } + + public transformHookData(data: unknown): { plain: string; html?: string } { + // Supported parameters https://developers.mattermost.com/integrate/incoming-webhooks/#parameters + const msg: { plain: string; html?: string } = { plain: "" }; + const safeData = + typeof data === "object" && data !== null + ? (data as Record) + : undefined; + if (typeof data === "string") { + return { plain: `Received webhook data: ${data}` }; + } else if (typeof safeData?.text === "string") { + msg.plain = safeData.text; + } else { + 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 = `

${dataPrefix}

${dataString}

`; } - public async onStateUpdate(stateEv: MatrixEvent) { - const validatedConfig = GenericHookConnection.validateState(stateEv.content as Record); - if (validatedConfig.transformationFunction) { - const error = WebhookTransformer.validateScript(validatedConfig.transformationFunction); - if (error) { - const errorPrefix = "Could not compile transformation function:"; - await this.intent.sendEvent(this.roomId, { - msgtype: "m.text", - body: errorPrefix + "\n\n```json\n\n" + error + "\n\n```", - formatted_body: `

${errorPrefix}

${error}

`, - format: "org.matrix.custom.html", - }); - } else { - this.webhookTransformer = new WebhookTransformer(validatedConfig.transformationFunction); ; - } - } else { - this.webhookTransformer = undefined; - } - - const prevDate = this.state.expirationDate; - this.state = validatedConfig; - if (prevDate !== validatedConfig.expirationDate) { - await this.handleExpiryTimeUpdate(true); - } + if (typeof safeData?.html === "string") { + msg.html = safeData.html; } - /** - * Called when the expiry time has been updated for the connection. If the connection - * no longer has an expiry time. This voids the interval. - * @returns - */ - private async handleExpiryTimeUpdate(shouldWrite: boolean) { - if (!this.config.sendExpiryNotice) { - return; - } - if (this.warnOnExpiryInterval) { - clearInterval(this.warnOnExpiryInterval); - this.warnOnExpiryInterval = undefined; - } - if (!this.state.expirationDate) { - return; - } - - const durationRemaining = new Date(this.state.expirationDate).getTime() - Date.now(); - if (durationRemaining < WARN_AT_EXPIRY_MS) { - // If the webhook is actually created with a shorter expiry time than - // our warning period, then just mark it as warned. - if (shouldWrite) { - await this.storage.setHasGenericHookWarnedExpiry(this.hookId, true); - } - } else { - const fuzzCheckTimeMs = Math.round((Math.random() * CHECK_EXPIRY_MS)); - this.warnOnExpiryInterval = setInterval(() => { - this.checkAndWarnExpiry().catch(ex => { - log.warn("Failed to check expiry time for hook", ex); - }) - }, CHECK_EXPIRY_MS + fuzzCheckTimeMs); - if (shouldWrite) { - await this.storage.setHasGenericHookWarnedExpiry(this.hookId, false); - } - } + if (typeof safeData?.username === "string") { + // Create a matrix user for this person + msg.plain = `**${safeData.username}**: ${msg.plain}`; + if (msg.html) { + msg.html = `${safeData.username}: ${msg.html}`; + } + } + // TODO: Transform Slackdown into markdown. + return msg; + } + + /** + * Processes an incoming generic hook + * @param data Structured data. This may either be a string, or an object. + * @returns `true` if the webhook completed, or `false` if it failed to complete + */ + public async onGenericHook( + data: unknown, + ): Promise { + log.info(`onGenericHook ${this.roomId} ${this.hookId}`); + + if (this.expiresAt && new Date() >= this.expiresAt) { + log.warn("Ignoring incoming webhook. This hook has expired"); + return { + successful: false, + statusCode: StatusCodes.NOT_FOUND, + error: "This hook has expired", + }; } - public transformHookData(data: unknown): {plain: string, html?: string} { - // Supported parameters https://developers.mattermost.com/integrate/incoming-webhooks/#parameters - const msg: {plain: string, html?: string} = {plain: ""}; - const safeData = typeof data === "object" && data !== null ? data as Record : undefined; - if (typeof data === "string") { - return {plain: `Received webhook data: ${data}`}; - } else if (typeof safeData?.text === "string") { - msg.plain = safeData.text; - } else { - 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 = `

${dataPrefix}

${dataString}

` - } - - if (typeof safeData?.html === "string") { - msg.html = safeData.html; - } - - if (typeof safeData?.username === "string") { - // Create a matrix user for this person - msg.plain = `**${safeData.username}**: ${msg.plain}` - if (msg.html) { - msg.html = `${safeData.username}: ${msg.html}`; - } - } - // TODO: Transform Slackdown into markdown. - return msg; - } - - - /** - * Processes an incoming generic hook - * @param data Structured data. This may either be a string, or an object. - * @returns `true` if the webhook completed, or `false` if it failed to complete - */ - public async onGenericHook(data: unknown): Promise { - log.info(`onGenericHook ${this.roomId} ${this.hookId}`); - - if (this.expiresAt && new Date() >= this.expiresAt) { - log.warn("Ignoring incoming webhook. This hook has expired"); - return { - successful: false, - statusCode: StatusCodes.NOT_FOUND, - error: 'This hook has expired', - }; - } - - let content: ExecuteResultContent|undefined; - let webhookResponse: ExecuteResultWebhookResponse|undefined; - let successful = true; - if (this.webhookTransformer) { - try { - const result = this.webhookTransformer.execute(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`}; - successful = false; - } - } else { - content = this.transformHookData(data); - } - - if (content) { - const sender = this.getUserId(); - const senderIntent = this.as.getIntentForUserId(sender); - await this.ensureDisplayname(senderIntent); - - await ensureUserIsInRoom(senderIntent, this.intent.underlyingClient, this.roomId); - - // Matrix cannot handle float data, so make sure we parse out any floats. - const safeData = GenericHookConnection.sanitiseObjectForMatrixJSON(data); - - await this.messageClient.sendMatrixMessage(this.roomId, { - msgtype: content.msgtype || "m.notice", - body: content.plain, - // render can output redundant trailing newlines, so trim it. - formatted_body: content.html || md.render(content.plain).trim(), - ...(content.mentions ? {"m.mentions": content.mentions} : undefined), - format: "org.matrix.custom.html", - "uk.half-shot.hookshot.webhook_data": safeData, - }, 'm.room.message', sender); - } - - return { - successful, - response: webhookResponse, + let content: ExecuteResultContent | undefined; + let webhookResponse: ExecuteResultWebhookResponse | undefined; + let successful = true; + if (this.webhookTransformer) { + try { + const result = this.webhookTransformer.execute(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`, }; + successful = false; + } + } else { + content = this.transformHookData(data); } - public static getProvisionerDetails(botUserId: string) { - return { - service: "generic", - eventType: GenericHookConnection.CanonicalEventType, - type: "Webhook", - // TODO: Add ability to configure the bot per connnection type. - botUserId: botUserId, - } + if (content) { + const sender = this.getUserId(); + const senderIntent = this.as.getIntentForUserId(sender); + await this.ensureDisplayname(senderIntent); + + await ensureUserIsInRoom( + senderIntent, + this.intent.underlyingClient, + this.roomId, + ); + + // Matrix cannot handle float data, so make sure we parse out any floats. + const safeData = GenericHookConnection.sanitiseObjectForMatrixJSON(data); + + await this.messageClient.sendMatrixMessage( + this.roomId, + { + msgtype: content.msgtype || "m.notice", + body: content.plain, + // render can output redundant trailing newlines, so trim it. + formatted_body: content.html || md.render(content.plain).trim(), + ...(content.mentions + ? { "m.mentions": content.mentions } + : undefined), + format: "org.matrix.custom.html", + "uk.half-shot.hookshot.webhook_data": safeData, + }, + "m.room.message", + sender, + ); } - public getProvisionerDetails(showSecrets = false): GenericHookResponseItem { - return { - ...GenericHookConnection.getProvisionerDetails(this.intent.userId), - id: this.connectionId, - config: { - transformationFunction: this.state.transformationFunction, - waitForComplete: this.waitForComplete, - name: this.state.name, - expirationDate: this.state.expirationDate, - }, - ...(showSecrets ? { secrets: { - url: new URL(this.hookId, this.config.parsedUrlPrefix), - hookId: this.hookId, - timeRemainingMs: this.expiresAt ? this.expiresAt.getTime() - Date.now() : undefined, - } satisfies GenericHookSecrets} : undefined) - } + return { + successful, + response: webhookResponse, + }; + } + + public static getProvisionerDetails(botUserId: string) { + return { + service: "generic", + eventType: GenericHookConnection.CanonicalEventType, + type: "Webhook", + // TODO: Add ability to configure the bot per connnection type. + botUserId: botUserId, + }; + } + + public getProvisionerDetails(showSecrets = false): GenericHookResponseItem { + return { + ...GenericHookConnection.getProvisionerDetails(this.intent.userId), + id: this.connectionId, + config: { + transformationFunction: this.state.transformationFunction, + waitForComplete: this.waitForComplete, + name: this.state.name, + expirationDate: this.state.expirationDate, + }, + ...(showSecrets + ? { + secrets: { + url: new URL(this.hookId, this.config.parsedUrlPrefix), + hookId: this.hookId, + timeRemainingMs: this.expiresAt + ? this.expiresAt.getTime() - Date.now() + : undefined, + } satisfies GenericHookSecrets, + } + : undefined), + }; + } + + public async onRemove() { + log.info(`Removing ${this.toString()} for ${this.roomId}`); + clearInterval(this.warnOnExpiryInterval); + // Do a sanity check that the event exists. + try { + await this.intent.underlyingClient.getRoomStateEvent( + this.roomId, + GenericHookConnection.CanonicalEventType, + this.stateKey, + ); + await this.intent.underlyingClient.sendStateEvent( + this.roomId, + GenericHookConnection.CanonicalEventType, + this.stateKey, + { disabled: true }, + ); + } catch (ex) { + await this.intent.underlyingClient.getRoomStateEvent( + this.roomId, + GenericHookConnection.LegacyCanonicalEventType, + this.stateKey, + ); + await this.intent.underlyingClient.sendStateEvent( + this.roomId, + GenericHookConnection.LegacyCanonicalEventType, + this.stateKey, + { disabled: true }, + ); } - - public async onRemove() { - log.info(`Removing ${this.toString()} for ${this.roomId}`); - clearInterval(this.warnOnExpiryInterval); - // Do a sanity check that the event exists. - try { - await this.intent.underlyingClient.getRoomStateEvent(this.roomId, GenericHookConnection.CanonicalEventType, this.stateKey); - await this.intent.underlyingClient.sendStateEvent(this.roomId, GenericHookConnection.CanonicalEventType, this.stateKey, { disabled: true }); - } catch (ex) { - await this.intent.underlyingClient.getRoomStateEvent(this.roomId, GenericHookConnection.LegacyCanonicalEventType, this.stateKey); - await this.intent.underlyingClient.sendStateEvent(this.roomId, GenericHookConnection.LegacyCanonicalEventType, this.stateKey, { disabled: true }); - } - await GenericHookConnection.ensureRoomAccountData(this.roomId, this.intent, this.hookId, this.stateKey, true); - } - - public async provisionerUpdateConfig(_userId: string, config: Record) { - // Apply previous state to the current config, as provisioners might not return "unknown" keys. - config.expirationDate = config.expirationDate ?? undefined; - config = { ...this.state, ...config }; - const validatedConfig = GenericHookConnection.validateState(config); - await this.intent.underlyingClient.sendStateEvent(this.roomId, GenericHookConnection.CanonicalEventType, this.stateKey, - { - ...validatedConfig, - hookId: this.hookId - } - ); - this.state = validatedConfig; + await GenericHookConnection.ensureRoomAccountData( + this.roomId, + this.intent, + this.hookId, + this.stateKey, + true, + ); + } + + public async provisionerUpdateConfig( + _userId: string, + config: Record, + ) { + // Apply previous state to the current config, as provisioners might not return "unknown" keys. + config.expirationDate = config.expirationDate ?? undefined; + config = { ...this.state, ...config }; + const validatedConfig = GenericHookConnection.validateState(config); + await this.intent.underlyingClient.sendStateEvent( + this.roomId, + GenericHookConnection.CanonicalEventType, + this.stateKey, + { + ...validatedConfig, + hookId: this.hookId, + }, + ); + this.state = validatedConfig; + } + + private async checkAndWarnExpiry() { + const remainingMs = this.expiresAt + ? this.expiresAt.getTime() - Date.now() + : undefined; + if (!remainingMs) { + return; } - - private async checkAndWarnExpiry() { - const remainingMs = this.expiresAt ? this.expiresAt.getTime() - Date.now() : undefined; - if (!remainingMs) { - return; - } - if (remainingMs < CHECK_EXPIRY_MS) { - // Nearly expired - return; - } - if (remainingMs > WARN_AT_EXPIRY_MS) { - return; - } - if (await this.storage.getHasGenericHookWarnedExpiry(this.hookId)) { - return; - } - // Warn - const markdownStr = EXPIRY_NOTICE_MESSAGE.replace('%NAME', this.state.name).replace('%TIME', formatDuration({ - hours: millisecondsToHours(remainingMs) - })); - await this.messageClient.sendMatrixMessage(this.roomId, { - msgtype: "m.notice", - body: markdownStr, - // render can output redundant trailing newlines, so trim it. - formatted_body: md.render(markdownStr).trim(), - format: "org.matrix.custom.html", - }, 'm.room.message', this.getUserId()); - await this.storage.setHasGenericHookWarnedExpiry(this.hookId, true); + if (remainingMs < CHECK_EXPIRY_MS) { + // Nearly expired + return; + } + if (remainingMs > WARN_AT_EXPIRY_MS) { + return; } - - public toString() { - return `GenericHookConnection ${this.hookId}`; + if (await this.storage.getHasGenericHookWarnedExpiry(this.hookId)) { + return; } + // Warn + const markdownStr = EXPIRY_NOTICE_MESSAGE.replace( + "%NAME", + this.state.name, + ).replace( + "%TIME", + formatDuration({ + hours: millisecondsToHours(remainingMs), + }), + ); + await this.messageClient.sendMatrixMessage( + this.roomId, + { + msgtype: "m.notice", + body: markdownStr, + // render can output redundant trailing newlines, so trim it. + formatted_body: md.render(markdownStr).trim(), + format: "org.matrix.custom.html", + }, + "m.room.message", + this.getUserId(), + ); + await this.storage.setHasGenericHookWarnedExpiry(this.hookId, true); + } + + public toString() { + return `GenericHookConnection ${this.hookId}`; + } } diff --git a/src/Connections/GithubDiscussion.ts b/src/Connections/GithubDiscussion.ts index d3787d1d0..4d411bafd 100644 --- a/src/Connections/GithubDiscussion.ts +++ b/src/Connections/GithubDiscussion.ts @@ -1,4 +1,8 @@ -import { Connection, IConnection, InstantiateConnectionOpts } from "./IConnection"; +import { + Connection, + IConnection, + InstantiateConnectionOpts, +} from "./IConnection"; import { Appservice, Intent, StateEvent } from "matrix-bot-sdk"; import { UserTokenStore } from "../tokens/UserTokenStore"; import { CommentProcessor } from "../CommentProcessor"; @@ -16,12 +20,12 @@ import { BridgeConfig, BridgeConfigGitHub } from "../config/Config"; import { ConfigGrantChecker, GrantChecker } from "../grants/GrantCheck"; import QuickLRU from "@alloc/quick-lru"; export interface GitHubDiscussionConnectionState { - owner: string; - repo: string; - id: number; - internalId: string; - discussion: number; - category: number; + owner: string; + repo: string; + id: number; + internalId: string; + discussion: number; + category: number; } const log = new Logger("GitHubDiscussion"); @@ -31,165 +35,258 @@ const md = new markdown(); * Handles rooms connected to a GitHub discussion. */ @Connection -export class GitHubDiscussionConnection extends BaseConnection implements IConnection { - static readonly CanonicalEventType = "uk.half-shot.matrix-hookshot.github.discussion"; - static readonly LegacyCanonicalEventType = "uk.half-shot.matrix-github.discussion"; +export class GitHubDiscussionConnection + extends BaseConnection + implements IConnection +{ + static readonly CanonicalEventType = + "uk.half-shot.matrix-hookshot.github.discussion"; + static readonly LegacyCanonicalEventType = + "uk.half-shot.matrix-github.discussion"; - static readonly EventTypes = [ - GitHubDiscussionConnection.CanonicalEventType, - GitHubDiscussionConnection.LegacyCanonicalEventType, - ]; - - static readonly QueryRoomRegex = /#github_disc_(.+)_(.+)_(\d+):.*/; - static readonly ServiceCategory = "github"; - - public static createConnectionForState(roomId: string, event: StateEvent, { - github, config, as, intent, tokenStore, commentProcessor, messageClient}: InstantiateConnectionOpts) { - if (!github || !config.github) { - throw Error('GitHub is not configured'); - } - return new GitHubDiscussionConnection( - roomId, as, intent, event.content, event.stateKey, tokenStore, commentProcessor, - messageClient, config, - ); - } + static readonly EventTypes = [ + GitHubDiscussionConnection.CanonicalEventType, + GitHubDiscussionConnection.LegacyCanonicalEventType, + ]; + static readonly QueryRoomRegex = /#github_disc_(.+)_(.+)_(\d+):.*/; + static readonly ServiceCategory = "github"; - public static async createDiscussionRoom( - as: Appservice, intent: Intent, userId: string|null, owner: string, repo: string, discussion: Discussion, - tokenStore: UserTokenStore, commentProcessor: CommentProcessor, messageClient: MessageSenderClient, - config: BridgeConfig, - ) { - const commentIntent = await getIntentForUser({ - login: discussion.user.login, - avatarUrl: discussion.user.avatar_url, - }, as, config.github?.userIdPrefix); - const state: GitHubDiscussionConnectionState = { - owner, - repo, - id: discussion.id, - internalId: discussion.node_id, - discussion: discussion.number, - category: discussion.category.id, - }; - const invite = [intent.userId]; - if (userId) { - invite.push(userId); - } - const roomId = await commentIntent.underlyingClient.createRoom({ - invite, - preset: 'public_chat', - name: `${discussion.title} (${owner}/${repo})`, - topic: emoji.emojify(`Under ${discussion.category.emoji} ${discussion.category.name}`), - room_alias_name: `github_disc_${owner.toLowerCase()}_${repo.toLowerCase()}_${discussion.number}`, - initial_state: [{ - content: state, - state_key: '', - type: GitHubDiscussionConnection.CanonicalEventType, - }], - }); - await commentIntent.sendEvent(roomId, { - msgtype: 'm.text', - body: discussion.body, - formatted_body: md.render(discussion.body), - format: 'org.matrix.custom.html', - }); - await intent.ensureJoined(roomId); - - return new GitHubDiscussionConnection(roomId, as, intent, state, '', tokenStore, commentProcessor, messageClient, config); + public static createConnectionForState( + roomId: string, + event: StateEvent, + { + github, + config, + as, + intent, + tokenStore, + commentProcessor, + messageClient, + }: InstantiateConnectionOpts, + ) { + if (!github || !config.github) { + throw Error("GitHub is not configured"); } + return new GitHubDiscussionConnection( + roomId, + as, + intent, + event.content, + event.stateKey, + tokenStore, + commentProcessor, + messageClient, + config, + ); + } - private static grantKey(state: GitHubDiscussionConnectionState) { - return `${this.CanonicalEventType}/${state.owner}/${state.repo}`; + public static async createDiscussionRoom( + as: Appservice, + intent: Intent, + userId: string | null, + owner: string, + repo: string, + discussion: Discussion, + tokenStore: UserTokenStore, + commentProcessor: CommentProcessor, + messageClient: MessageSenderClient, + config: BridgeConfig, + ) { + const commentIntent = await getIntentForUser( + { + login: discussion.user.login, + avatarUrl: discussion.user.avatar_url, + }, + as, + config.github?.userIdPrefix, + ); + const state: GitHubDiscussionConnectionState = { + owner, + repo, + id: discussion.id, + internalId: discussion.node_id, + discussion: discussion.number, + category: discussion.category.id, + }; + const invite = [intent.userId]; + if (userId) { + invite.push(userId); } + const roomId = await commentIntent.underlyingClient.createRoom({ + invite, + preset: "public_chat", + name: `${discussion.title} (${owner}/${repo})`, + topic: emoji.emojify( + `Under ${discussion.category.emoji} ${discussion.category.name}`, + ), + room_alias_name: `github_disc_${owner.toLowerCase()}_${repo.toLowerCase()}_${discussion.number}`, + initial_state: [ + { + content: state, + state_key: "", + type: GitHubDiscussionConnection.CanonicalEventType, + }, + ], + }); + await commentIntent.sendEvent(roomId, { + msgtype: "m.text", + body: discussion.body, + formatted_body: md.render(discussion.body), + format: "org.matrix.custom.html", + }); + await intent.ensureJoined(roomId); - private readonly sentEvents = new QuickLRU({ maxSize: 128 }); - private readonly grantChecker: GrantChecker; - - private readonly config: BridgeConfigGitHub; - - constructor( - roomId: string, - private readonly as: Appservice, - private readonly intent: Intent, - private readonly state: GitHubDiscussionConnectionState, - stateKey: string, - private readonly tokenStore: UserTokenStore, - private readonly commentProcessor: CommentProcessor, - private readonly messageClient: MessageSenderClient, - bridgeConfig: BridgeConfig, - ) { - super(roomId, stateKey, GitHubDiscussionConnection.CanonicalEventType); - if (!bridgeConfig.github) { - throw Error('Expected github to be enabled in config'); - } - this.config = bridgeConfig.github; - this.grantChecker = new ConfigGrantChecker("github", this.as, bridgeConfig); - } + return new GitHubDiscussionConnection( + roomId, + as, + intent, + state, + "", + tokenStore, + commentProcessor, + messageClient, + config, + ); + } - public isInterestedInStateEvent(eventType: string, stateKey: string) { - return GitHubDiscussionConnection.EventTypes.includes(eventType) && this.stateKey === stateKey; - } + private static grantKey(state: GitHubDiscussionConnectionState) { + return `${this.CanonicalEventType}/${state.owner}/${state.repo}`; + } - public async onMessageEvent(ev: MatrixEvent) { - const octokit = await this.tokenStore.getOctokitForUser(ev.sender); - if (octokit === null) { - // TODO: Use Reply - Also mention user. - await this.intent.underlyingClient.sendNotice(this.roomId, `${ev.sender}: Cannot send comment, you are not logged into GitHub`); - return true; - } - const qlClient = new GithubGraphQLClient(octokit); - const commentId = await qlClient.addDiscussionComment(this.state.internalId, ev.content.body); - log.info(`Sent ${commentId} for ${ev.event_id} (${ev.sender})`); - this.sentEvents.set(commentId, undefined); - return true; - } + private readonly sentEvents = new QuickLRU({ + maxSize: 128, + }); + private readonly grantChecker: GrantChecker; - public get discussionNumber() { - return this.state.discussion; - } + private readonly config: BridgeConfigGitHub; - public get repo() { - return this.state.repo.toLowerCase(); + constructor( + roomId: string, + private readonly as: Appservice, + private readonly intent: Intent, + private readonly state: GitHubDiscussionConnectionState, + stateKey: string, + private readonly tokenStore: UserTokenStore, + private readonly commentProcessor: CommentProcessor, + private readonly messageClient: MessageSenderClient, + bridgeConfig: BridgeConfig, + ) { + super(roomId, stateKey, GitHubDiscussionConnection.CanonicalEventType); + if (!bridgeConfig.github) { + throw Error("Expected github to be enabled in config"); } + this.config = bridgeConfig.github; + this.grantChecker = new ConfigGrantChecker("github", this.as, bridgeConfig); + } - public get owner() { - return this.state.owner.toLowerCase(); - } + public isInterestedInStateEvent(eventType: string, stateKey: string) { + return ( + GitHubDiscussionConnection.EventTypes.includes(eventType) && + this.stateKey === stateKey + ); + } - public toString() { - return `GitHubDiscussion ${this.owner}/${this.repo}#${this.state.discussion}`; + public async onMessageEvent(ev: MatrixEvent) { + const octokit = await this.tokenStore.getOctokitForUser(ev.sender); + if (octokit === null) { + // TODO: Use Reply - Also mention user. + await this.intent.underlyingClient.sendNotice( + this.roomId, + `${ev.sender}: Cannot send comment, you are not logged into GitHub`, + ); + return true; } + const qlClient = new GithubGraphQLClient(octokit); + const commentId = await qlClient.addDiscussionComment( + this.state.internalId, + ev.content.body, + ); + log.info(`Sent ${commentId} for ${ev.event_id} (${ev.sender})`); + this.sentEvents.set(commentId, undefined); + return true; + } - public async onDiscussionCommentCreated(data: DiscussionCommentCreatedEvent) { - if (this.sentEvents.has(data.comment.node_id)) { - return; - } - const intent = await getIntentForUser(data.comment.user, this.as, this.config.userIdPrefix); - await ensureUserIsInRoom(intent, this.intent.underlyingClient, this.roomId); - await this.messageClient.sendMatrixMessage(this.roomId, { - body: data.comment.body, - formatted_body: md.render(data.comment.body), - msgtype: 'm.text', - external_url: data.comment.html_url, - 'uk.half-shot.matrix-hookshot.github.discussion.comment_id': data.comment.id, - }, 'm.room.message', intent.userId); - } + public get discussionNumber() { + return this.state.discussion; + } - public async onRemove() { - log.info(`Removing ${this.toString()} for ${this.roomId}`); - await this.grantChecker.ungrantConnection(this.roomId, GitHubDiscussionConnection.grantKey(this.state)); - // Do a sanity check that the event exists. - try { - await this.intent.underlyingClient.getRoomStateEvent(this.roomId, GitHubDiscussionConnection.CanonicalEventType, this.stateKey); - await this.intent.underlyingClient.sendStateEvent(this.roomId, GitHubDiscussionConnection.CanonicalEventType, this.stateKey, { disabled: true }); - } catch (ex) { - await this.intent.underlyingClient.getRoomStateEvent(this.roomId, GitHubDiscussionConnection.LegacyCanonicalEventType, this.stateKey); - await this.intent.underlyingClient.sendStateEvent(this.roomId, GitHubDiscussionConnection.LegacyCanonicalEventType, this.stateKey, { disabled: true }); - } + public get repo() { + return this.state.repo.toLowerCase(); + } + + public get owner() { + return this.state.owner.toLowerCase(); + } + + public toString() { + return `GitHubDiscussion ${this.owner}/${this.repo}#${this.state.discussion}`; + } + + public async onDiscussionCommentCreated(data: DiscussionCommentCreatedEvent) { + if (this.sentEvents.has(data.comment.node_id)) { + return; } + const intent = await getIntentForUser( + data.comment.user, + this.as, + this.config.userIdPrefix, + ); + await ensureUserIsInRoom(intent, this.intent.underlyingClient, this.roomId); + await this.messageClient.sendMatrixMessage( + this.roomId, + { + body: data.comment.body, + formatted_body: md.render(data.comment.body), + msgtype: "m.text", + external_url: data.comment.html_url, + "uk.half-shot.matrix-hookshot.github.discussion.comment_id": + data.comment.id, + }, + "m.room.message", + intent.userId, + ); + } - public async ensureGrant(sender?: string) { - await this.grantChecker.assertConnectionGranted(this.roomId, GitHubDiscussionConnection.grantKey(this.state), sender); + public async onRemove() { + log.info(`Removing ${this.toString()} for ${this.roomId}`); + await this.grantChecker.ungrantConnection( + this.roomId, + GitHubDiscussionConnection.grantKey(this.state), + ); + // Do a sanity check that the event exists. + try { + await this.intent.underlyingClient.getRoomStateEvent( + this.roomId, + GitHubDiscussionConnection.CanonicalEventType, + this.stateKey, + ); + await this.intent.underlyingClient.sendStateEvent( + this.roomId, + GitHubDiscussionConnection.CanonicalEventType, + this.stateKey, + { disabled: true }, + ); + } catch (ex) { + await this.intent.underlyingClient.getRoomStateEvent( + this.roomId, + GitHubDiscussionConnection.LegacyCanonicalEventType, + this.stateKey, + ); + await this.intent.underlyingClient.sendStateEvent( + this.roomId, + GitHubDiscussionConnection.LegacyCanonicalEventType, + this.stateKey, + { disabled: true }, + ); } + } + + public async ensureGrant(sender?: string) { + await this.grantChecker.assertConnectionGranted( + this.roomId, + GitHubDiscussionConnection.grantKey(this.state), + sender, + ); + } } diff --git a/src/Connections/GithubDiscussionSpace.ts b/src/Connections/GithubDiscussionSpace.ts index 590b19caa..f02243d3f 100644 --- a/src/Connections/GithubDiscussionSpace.ts +++ b/src/Connections/GithubDiscussionSpace.ts @@ -1,4 +1,8 @@ -import { Connection, IConnection, InstantiateConnectionOpts } from "./IConnection"; +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"; @@ -12,190 +16,237 @@ import { BridgeConfig } from "../config/Config"; const log = new Logger("GitHubDiscussionSpace"); export interface GitHubDiscussionSpaceConnectionState { - owner: string; - repo: string; + owner: string; + repo: string; } /** * Handles spaces connected to a GitHub discussion. */ @Connection -export class GitHubDiscussionSpace extends BaseConnection implements IConnection { - static readonly CanonicalEventType = "uk.half-shot.matrix-hookshot.github.discussion.space"; - static readonly LegacyCanonicalEventType = "uk.half-shot.matrix-github.discussion.space"; - - static readonly EventTypes = [ - GitHubDiscussionSpace.CanonicalEventType, - GitHubDiscussionSpace.LegacyCanonicalEventType, - ]; - - static readonly QueryRoomRegex = /#github_disc_(.+)_(.+):.*/; - static readonly ServiceCategory = "github"; - - public static async createConnectionForState(roomId: string, event: StateEvent, { - github, config, as, intent}: InstantiateConnectionOpts) { - if (!github || !config.github) { - throw Error('GitHub is not configured'); - } - await new GrantChecker(as.botIntent, 'github').grantConnection(roomId, this.grantKey(event.content)); - return new GitHubDiscussionSpace( - as, config, await intent.underlyingClient.getSpace(roomId), event.content, event.stateKey - ); - } - - public static async onQueryRoom(result: RegExpExecArray, opts: {githubInstance: GithubInstance, as: Appservice}): Promise> { - if (!result || result.length < 2) { - log.error(`Invalid alias pattern '${result}'`); - throw Error("Could not find issue"); - } - - const [ owner, repo ] = result.slice(1); - - log.info(`Fetching ${owner}/${repo}`); - let repoRes: ReposGetResponseData; - const octokit = opts.githubInstance.getOctokitForRepo(owner, repo); - try { - // TODO: Determine if the repo has discussions? - repoRes = (await octokit.repos.get({ - owner, - repo, - })).data; - if (!repoRes.owner) { - throw Error('Repo has no owner!'); - } - if (repoRes.private) { - throw Error('Refusing to bridge private repo'); - } - } catch (ex) { - log.error("Failed to get repo:", ex); - throw Error("Could not find repo"); - } - const state: GitHubDiscussionSpaceConnectionState = { - owner: repoRes.owner.login.toLowerCase(), - repo: repoRes.name.toLowerCase(), - }; - - // URL hack so we don't need to fetch the repo itself. - let avatarUrl = undefined; - try { - const profile = await octokit.users.getByUsername({ - username: owner, - }); - if (profile.data.avatar_url) { - const res = await axios.get(profile.data.avatar_url as string, { - responseType: 'arraybuffer', - }); - log.info(`uploading ${profile.data.avatar_url}`); - const contentType: string = res.headers["content-type"]; - const mxcUrl = await opts.as.botClient.uploadContent( - Buffer.from(res.data as ArrayBuffer), - contentType, - `avatar_${profile.data.id}.png`, - ); - avatarUrl = { - type: "m.room.avatar", - state_key: "", - content: { - url: mxcUrl, - }, - }; - } - } catch (ex) { - log.warn("Failed to get avatar for org:", ex); - } - - return { - visibility: "public", - name: `${state.owner}/${state.repo} Discussions`, - topic: `GitHub discussion index for ${state.owner}/${state.repo}`, - preset: 'public_chat', - room_alias_name: `github_disc_${owner.toLowerCase()}_${repo.toLowerCase()}`, - initial_state: [ - - { - type: this.CanonicalEventType, - content: state, - state_key: `${state.owner}/${state.repo}`, - }, - avatarUrl, - { - type: "m.room.history_visibility", - state_key: "", - content: { - history_visibility: 'world_readable', - }, - }, - ], - creation_content: { - type: "m.space", - }, - power_level_content_override: { - ban: 100, - events_default: 50, - invite: 50, - kick: 100, - notifications: { - room: 100, - }, - redact: 100, - state_default: 100, - users_default: 0, - }, - }; - } - - private static grantKey(state: GitHubDiscussionSpaceConnectionState) { - return `${this.CanonicalEventType}/${state.owner}/${state.repo}`; +export class GitHubDiscussionSpace + extends BaseConnection + implements IConnection +{ + static readonly CanonicalEventType = + "uk.half-shot.matrix-hookshot.github.discussion.space"; + static readonly LegacyCanonicalEventType = + "uk.half-shot.matrix-github.discussion.space"; + + static readonly EventTypes = [ + GitHubDiscussionSpace.CanonicalEventType, + GitHubDiscussionSpace.LegacyCanonicalEventType, + ]; + + static readonly QueryRoomRegex = /#github_disc_(.+)_(.+):.*/; + static readonly ServiceCategory = "github"; + + public static async createConnectionForState( + roomId: string, + event: StateEvent, + { github, config, as, intent }: InstantiateConnectionOpts, + ) { + if (!github || !config.github) { + throw Error("GitHub is not configured"); } - - private readonly grantChecker: GrantChecker; - - constructor(as: Appservice, - config: BridgeConfig, - public readonly space: Space, - private state: GitHubDiscussionSpaceConnectionState, - stateKey: string) { - super(space.roomId, stateKey, GitHubDiscussionSpace.CanonicalEventType) - this.grantChecker = new ConfigGrantChecker("github", as, config); - } - - public isInterestedInStateEvent(eventType: string, stateKey: string) { - return GitHubDiscussionSpace.EventTypes.includes(eventType) && this.stateKey === stateKey; + await new GrantChecker(as.botIntent, "github").grantConnection( + roomId, + this.grantKey(event.content), + ); + return new GitHubDiscussionSpace( + as, + config, + await intent.underlyingClient.getSpace(roomId), + event.content, + event.stateKey, + ); + } + + public static async onQueryRoom( + result: RegExpExecArray, + opts: { githubInstance: GithubInstance; as: Appservice }, + ): Promise> { + if (!result || result.length < 2) { + log.error(`Invalid alias pattern '${result}'`); + throw Error("Could not find issue"); } - public get repo() { - return this.state.repo.toLowerCase(); + const [owner, repo] = result.slice(1); + + log.info(`Fetching ${owner}/${repo}`); + let repoRes: ReposGetResponseData; + const octokit = opts.githubInstance.getOctokitForRepo(owner, repo); + try { + // TODO: Determine if the repo has discussions? + repoRes = ( + await octokit.repos.get({ + owner, + repo, + }) + ).data; + if (!repoRes.owner) { + throw Error("Repo has no owner!"); + } + if (repoRes.private) { + throw Error("Refusing to bridge private repo"); + } + } catch (ex) { + log.error("Failed to get repo:", ex); + throw Error("Could not find repo"); } - - public get owner() { - return this.state.owner.toLowerCase(); - } - - public toString() { - return `GitHubDiscussionSpace ${this.owner}/${this.repo}`; - } - - public async onDiscussionCreated(discussion: GitHubDiscussionConnection) { - log.info(`Adding connection to ${this.toString()}`); - await this.space.addChildRoom(discussion.roomId); - } - - - public async ensureGrant(sender?: string) { - await this.grantChecker.assertConnectionGranted(this.roomId, GitHubDiscussionSpace.grantKey(this.state), sender); + const state: GitHubDiscussionSpaceConnectionState = { + owner: repoRes.owner.login.toLowerCase(), + repo: repoRes.name.toLowerCase(), + }; + + // URL hack so we don't need to fetch the repo itself. + let avatarUrl = undefined; + try { + const profile = await octokit.users.getByUsername({ + username: owner, + }); + if (profile.data.avatar_url) { + const res = await axios.get(profile.data.avatar_url as string, { + responseType: "arraybuffer", + }); + log.info(`uploading ${profile.data.avatar_url}`); + const contentType: string = res.headers["content-type"]; + const mxcUrl = await opts.as.botClient.uploadContent( + Buffer.from(res.data as ArrayBuffer), + contentType, + `avatar_${profile.data.id}.png`, + ); + avatarUrl = { + type: "m.room.avatar", + state_key: "", + content: { + url: mxcUrl, + }, + }; + } + } catch (ex) { + log.warn("Failed to get avatar for org:", ex); } - public async onRemove() { - log.info(`Removing ${this.toString()} for ${this.roomId}`); - this.grantChecker.ungrantConnection(this.roomId, GitHubDiscussionSpace.grantKey(this.state)); - // Do a sanity check that the event exists. - try { - - await this.space.client.getRoomStateEvent(this.roomId, GitHubDiscussionSpace.CanonicalEventType, this.stateKey); - await this.space.client.sendStateEvent(this.roomId, GitHubDiscussionSpace.CanonicalEventType, this.stateKey, { disabled: true }); - } catch (ex) { - await this.space.client.getRoomStateEvent(this.roomId, GitHubDiscussionSpace.LegacyCanonicalEventType, this.stateKey); - await this.space.client.sendStateEvent(this.roomId, GitHubDiscussionSpace.LegacyCanonicalEventType, this.stateKey, { disabled: true }); - } + return { + visibility: "public", + name: `${state.owner}/${state.repo} Discussions`, + topic: `GitHub discussion index for ${state.owner}/${state.repo}`, + preset: "public_chat", + room_alias_name: `github_disc_${owner.toLowerCase()}_${repo.toLowerCase()}`, + initial_state: [ + { + type: this.CanonicalEventType, + content: state, + state_key: `${state.owner}/${state.repo}`, + }, + avatarUrl, + { + type: "m.room.history_visibility", + state_key: "", + content: { + history_visibility: "world_readable", + }, + }, + ], + creation_content: { + type: "m.space", + }, + power_level_content_override: { + ban: 100, + events_default: 50, + invite: 50, + kick: 100, + notifications: { + room: 100, + }, + redact: 100, + state_default: 100, + users_default: 0, + }, + }; + } + + private static grantKey(state: GitHubDiscussionSpaceConnectionState) { + return `${this.CanonicalEventType}/${state.owner}/${state.repo}`; + } + + private readonly grantChecker: GrantChecker; + + constructor( + as: Appservice, + config: BridgeConfig, + public readonly space: Space, + private state: GitHubDiscussionSpaceConnectionState, + stateKey: string, + ) { + super(space.roomId, stateKey, GitHubDiscussionSpace.CanonicalEventType); + this.grantChecker = new ConfigGrantChecker("github", as, config); + } + + public isInterestedInStateEvent(eventType: string, stateKey: string) { + return ( + GitHubDiscussionSpace.EventTypes.includes(eventType) && + this.stateKey === stateKey + ); + } + + public get repo() { + return this.state.repo.toLowerCase(); + } + + public get owner() { + return this.state.owner.toLowerCase(); + } + + public toString() { + return `GitHubDiscussionSpace ${this.owner}/${this.repo}`; + } + + public async onDiscussionCreated(discussion: GitHubDiscussionConnection) { + log.info(`Adding connection to ${this.toString()}`); + await this.space.addChildRoom(discussion.roomId); + } + + public async ensureGrant(sender?: string) { + await this.grantChecker.assertConnectionGranted( + this.roomId, + GitHubDiscussionSpace.grantKey(this.state), + sender, + ); + } + + public async onRemove() { + log.info(`Removing ${this.toString()} for ${this.roomId}`); + this.grantChecker.ungrantConnection( + this.roomId, + GitHubDiscussionSpace.grantKey(this.state), + ); + // Do a sanity check that the event exists. + try { + await this.space.client.getRoomStateEvent( + this.roomId, + GitHubDiscussionSpace.CanonicalEventType, + this.stateKey, + ); + await this.space.client.sendStateEvent( + this.roomId, + GitHubDiscussionSpace.CanonicalEventType, + this.stateKey, + { disabled: true }, + ); + } catch (ex) { + await this.space.client.getRoomStateEvent( + this.roomId, + GitHubDiscussionSpace.LegacyCanonicalEventType, + this.stateKey, + ); + await this.space.client.sendStateEvent( + this.roomId, + GitHubDiscussionSpace.LegacyCanonicalEventType, + this.stateKey, + { disabled: true }, + ); } + } } diff --git a/src/Connections/GithubIssue.ts b/src/Connections/GithubIssue.ts index f5656e49a..cf820ca30 100644 --- a/src/Connections/GithubIssue.ts +++ b/src/Connections/GithubIssue.ts @@ -1,4 +1,8 @@ -import { Connection, IConnection, InstantiateConnectionOpts } from "./IConnection"; +import { + Connection, + IConnection, + InstantiateConnectionOpts, +} from "./IConnection"; import { Appservice, Intent, StateEvent } from "matrix-bot-sdk"; import { MatrixMessageContent, MatrixEvent } from "../MatrixEvent"; import markdown from "markdown-it"; @@ -10,377 +14,514 @@ 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 { IssuesEditedEvent, IssueCommentCreatedEvent } from "@octokit/webhooks-types"; +import { + IssuesGetCommentResponseData, + IssuesGetResponseData, + ReposGetResponseData, +} from "../github/Types"; +import { + IssuesEditedEvent, + IssueCommentCreatedEvent, +} from "@octokit/webhooks-types"; import { BaseConnection } from "./BaseConnection"; import { BridgeConfigGitHub } from "../config/Config"; export interface GitHubIssueConnectionState { - org: string; - repo: string; - state: string; - issues: string[]; - comments_processed: number; + org: string; + repo: string; + state: string; + issues: string[]; + comments_processed: number; } const log = new Logger("GitHubIssueConnection"); const md = new markdown(); interface IQueryRoomOpts { - as: Appservice; - tokenStore: UserTokenStore; - commentProcessor: CommentProcessor; - messageClient: MessageSenderClient; - githubInstance: GithubInstance; + as: Appservice; + tokenStore: UserTokenStore; + commentProcessor: CommentProcessor; + messageClient: MessageSenderClient; + githubInstance: GithubInstance; } /** * Handles rooms connected to a GitHub issue. */ @Connection -export class GitHubIssueConnection extends BaseConnection implements IConnection { - static readonly CanonicalEventType = "uk.half-shot.matrix-hookshot.github.issue"; - static readonly LegacyCanonicalEventType = "uk.half-shot.matrix-github.bridge"; - - static readonly EventTypes = [ - GitHubIssueConnection.CanonicalEventType, - GitHubIssueConnection.LegacyCanonicalEventType, - ]; - - static readonly QueryRoomRegex = /#github_(.+)_(.+)_(\d+):.*/; - static readonly ServiceCategory = "github"; - - static generateAliasLocalpart(org: string, repo: string, issueNo: string|number) { - return `github_${org}_${repo}_${issueNo}`; +export class GitHubIssueConnection + extends BaseConnection + implements IConnection +{ + static readonly CanonicalEventType = + "uk.half-shot.matrix-hookshot.github.issue"; + static readonly LegacyCanonicalEventType = + "uk.half-shot.matrix-github.bridge"; + + static readonly EventTypes = [ + GitHubIssueConnection.CanonicalEventType, + GitHubIssueConnection.LegacyCanonicalEventType, + ]; + + static readonly QueryRoomRegex = /#github_(.+)_(.+)_(\d+):.*/; + static readonly ServiceCategory = "github"; + + static generateAliasLocalpart( + org: string, + repo: string, + issueNo: string | number, + ) { + return `github_${org}_${repo}_${issueNo}`; + } + + public static async createConnectionForState( + roomId: string, + event: StateEvent, + { + github, + config, + as, + intent, + tokenStore, + commentProcessor, + messageClient, + }: InstantiateConnectionOpts, + ) { + if (!github || !config.github) { + throw Error("GitHub is not configured"); } - - public static async createConnectionForState(roomId: string, event: StateEvent, { - github, config, as, intent, tokenStore, commentProcessor, messageClient}: InstantiateConnectionOpts) { - if (!github || !config.github) { - throw Error('GitHub is not configured'); - } - const issue = new GitHubIssueConnection( - roomId, as, intent, event.content, event.stateKey || "", tokenStore, - commentProcessor, messageClient, github, config.github, - ); - await issue.syncIssueState(); - return issue; - } - - static async onQueryRoom(result: RegExpExecArray, opts: IQueryRoomOpts): Promise { - const parts = result?.slice(1); - if (!parts) { - log.error("Invalid alias pattern"); - throw Error("Could not find issue"); - } - - const owner = parts[0]; - const repoName = parts[1]; - const issueNumber = parseInt(parts[2], 10); - - log.info(`Fetching ${owner}/${repoName}/${issueNumber}`); - let issue: IssuesGetResponseData; - let repo: ReposGetResponseData; - const octokit = opts.githubInstance.getOctokitForRepo(owner, repoName); - try { - issue = (await octokit.issues.get({ - owner, - repo: repoName, - issue_number: issueNumber, - })).data; - repo = (await octokit.repos.get({ - owner, - repo: repoName, - })).data; - if (repo.private) { - throw Error('Refusing to bridge private repo'); - } - } catch (ex) { - log.error("Failed to get issue:", ex); - throw Error("Could not find issue"); - } - - // URL hack so we don't need to fetch the repo itself. - const orgRepoName = issue.repository?.full_name; - let avatarUrl = undefined; - try { - const profile = await octokit.users.getByUsername({ - username: owner, - }); - if (profile.data.avatar_url) { - const res = await axios.get(profile.data.avatar_url as string, { - responseType: 'arraybuffer', - }); - log.info(`uploading ${profile.data.avatar_url}`); - const contentType: string = res.headers["content-type"]; - const mxcUrl = await opts.as.botClient.uploadContent( - Buffer.from(res.data as ArrayBuffer), - contentType, - `avatar_${profile.data.id}.png`, - ); - avatarUrl = { - type: "m.room.avatar", - state_key: "", - content: { - url: mxcUrl, - }, - }; - } - } catch (ex) { - log.info("Failed to get avatar for org:", ex); - throw ex; - } - - return { - visibility: "public", - name: FormatUtil.formatIssueRoomName(issue, repo), - topic: FormatUtil.formatRoomTopic(issue), - preset: "public_chat", - initial_state: [ - { - type: this.CanonicalEventType, - content: { - org: orgRepoName?.split("/")[0], - repo: orgRepoName?.split("/")[1], - issues: [String(issue.number)], - comments_processed: -1, - state: "open", - } as GitHubIssueConnectionState, - state_key: issue.url, - }, - avatarUrl, - ], - }; + const issue = new GitHubIssueConnection( + roomId, + as, + intent, + event.content, + event.stateKey || "", + tokenStore, + commentProcessor, + messageClient, + github, + config.github, + ); + await issue.syncIssueState(); + return issue; + } + + static async onQueryRoom( + result: RegExpExecArray, + opts: IQueryRoomOpts, + ): Promise { + const parts = result?.slice(1); + if (!parts) { + log.error("Invalid alias pattern"); + throw Error("Could not find issue"); } - constructor( - roomId: string, - private readonly as: Appservice, - private readonly intent: Intent, - private state: GitHubIssueConnectionState, - stateKey: string, - private tokenStore: UserTokenStore, - private commentProcessor: CommentProcessor, - private messageClient: MessageSenderClient, - private github: GithubInstance, - private config: BridgeConfigGitHub, - ) { - super(roomId, stateKey, GitHubIssueConnection.CanonicalEventType); + const owner = parts[0]; + const repoName = parts[1]; + const issueNumber = parseInt(parts[2], 10); + + log.info(`Fetching ${owner}/${repoName}/${issueNumber}`); + let issue: IssuesGetResponseData; + let repo: ReposGetResponseData; + const octokit = opts.githubInstance.getOctokitForRepo(owner, repoName); + try { + issue = ( + await octokit.issues.get({ + owner, + repo: repoName, + issue_number: issueNumber, + }) + ).data; + repo = ( + await octokit.repos.get({ + owner, + repo: repoName, + }) + ).data; + if (repo.private) { + throw Error("Refusing to bridge private repo"); + } + } catch (ex) { + log.error("Failed to get issue:", ex); + throw Error("Could not find issue"); } - public isInterestedInStateEvent(eventType: string, stateKey: string) { - return GitHubIssueConnection.EventTypes.includes(eventType) && this.stateKey === stateKey; + // URL hack so we don't need to fetch the repo itself. + const orgRepoName = issue.repository?.full_name; + let avatarUrl = undefined; + try { + const profile = await octokit.users.getByUsername({ + username: owner, + }); + if (profile.data.avatar_url) { + const res = await axios.get(profile.data.avatar_url as string, { + responseType: "arraybuffer", + }); + log.info(`uploading ${profile.data.avatar_url}`); + const contentType: string = res.headers["content-type"]; + const mxcUrl = await opts.as.botClient.uploadContent( + Buffer.from(res.data as ArrayBuffer), + contentType, + `avatar_${profile.data.id}.png`, + ); + avatarUrl = { + type: "m.room.avatar", + state_key: "", + content: { + url: mxcUrl, + }, + }; + } + } catch (ex) { + log.info("Failed to get avatar for org:", ex); + throw ex; } - public get issueNumber() { - return parseInt(this.state.issues[0], 10); + return { + visibility: "public", + name: FormatUtil.formatIssueRoomName(issue, repo), + topic: FormatUtil.formatRoomTopic(issue), + preset: "public_chat", + initial_state: [ + { + type: this.CanonicalEventType, + content: { + org: orgRepoName?.split("/")[0], + repo: orgRepoName?.split("/")[1], + issues: [String(issue.number)], + comments_processed: -1, + state: "open", + } as GitHubIssueConnectionState, + state_key: issue.url, + }, + avatarUrl, + ], + }; + } + + constructor( + roomId: string, + private readonly as: Appservice, + private readonly intent: Intent, + private state: GitHubIssueConnectionState, + stateKey: string, + private tokenStore: UserTokenStore, + private commentProcessor: CommentProcessor, + private messageClient: MessageSenderClient, + private github: GithubInstance, + private config: BridgeConfigGitHub, + ) { + super(roomId, stateKey, GitHubIssueConnection.CanonicalEventType); + } + + public isInterestedInStateEvent(eventType: string, stateKey: string) { + return ( + GitHubIssueConnection.EventTypes.includes(eventType) && + this.stateKey === stateKey + ); + } + + public get issueNumber() { + return parseInt(this.state.issues[0], 10); + } + + public get org() { + return this.state.org.toLowerCase(); + } + + public get repo() { + return this.state.repo.toLowerCase(); + } + + public async onIssueCommentCreated(event: IssueCommentCreatedEvent) { + return this.onCommentCreated({ + // TODO: Fix types, + comment: event.comment as any, + action: event.action, + }); + } + + private async onCommentCreated( + event: { + comment: IssuesGetCommentResponseData; + action: string; + repository?: ReposGetResponseData; + issue?: IssuesGetResponseData; + }, + updateState = true, + ) { + const comment = event.comment; + if (!comment || !comment.user) { + throw Error("Comment undefined"); } - - public get org() { - return this.state.org.toLowerCase(); + if (event.repository) { + // Delay to stop comments racing sends + await new Promise((resolve) => setTimeout(resolve, 500)); + if ( + this.commentProcessor.hasCommentBeenProcessed( + this.state.org, + this.state.repo, + this.state.issues[0], + comment.id, + ) + ) { + return; + } } - - public get repo() { - return this.state.repo.toLowerCase(); + const commentIntent = await getIntentForUser( + { + login: comment.user.login, + avatarUrl: comment.user.avatar_url, + }, + this.as, + this.config.userIdPrefix, + ); + const matrixEvent = + await this.commentProcessor.getEventBodyForGitHubComment( + comment, + event.repository, + event.issue, + ); + // Comment body may be blank + if (matrixEvent) { + await ensureUserIsInRoom( + commentIntent, + this.intent.underlyingClient, + this.roomId, + ); + await this.messageClient.sendMatrixMessage( + this.roomId, + matrixEvent, + "m.room.message", + commentIntent.userId, + ); } - - public async onIssueCommentCreated(event: IssueCommentCreatedEvent) { - return this.onCommentCreated({ - // TODO: Fix types, - comment: event.comment as any, - action: event.action, - }) + if (!updateState) { + return; } - - private async onCommentCreated(event: { - comment: IssuesGetCommentResponseData, - action: string, - repository?: ReposGetResponseData, - issue?: IssuesGetResponseData, - }, updateState = true) { - const comment = event.comment; - if (!comment || !comment.user) { - throw Error('Comment undefined'); - } - if (event.repository) { - // Delay to stop comments racing sends - await new Promise((resolve) => setTimeout(resolve, 500)); - if (this.commentProcessor.hasCommentBeenProcessed(this.state.org, this.state.repo, this.state.issues[0], comment.id)) { - return; - } - } - const commentIntent = await getIntentForUser({ - login: comment.user.login, - avatarUrl: comment.user.avatar_url, - }, this.as, this.config.userIdPrefix); - const matrixEvent = await this.commentProcessor.getEventBodyForGitHubComment(comment, event.repository, event.issue); - // Comment body may be blank - if (matrixEvent) { - await ensureUserIsInRoom(commentIntent, this.intent.underlyingClient, this.roomId); - await this.messageClient.sendMatrixMessage(this.roomId, matrixEvent, "m.room.message", commentIntent.userId); - } - if (!updateState) { - return; - } - this.state.comments_processed++; - await this.intent.underlyingClient.sendStateEvent( - this.roomId, - GitHubIssueConnection.CanonicalEventType, - this.stateKey, - this.state, + this.state.comments_processed++; + await this.intent.underlyingClient.sendStateEvent( + this.roomId, + GitHubIssueConnection.CanonicalEventType, + this.stateKey, + this.state, + ); + } + + public async syncIssueState() { + log.debug("Syncing issue state for", this.roomId); + const issue = await this.github + .getOctokitForRepo(this.org, this.repo) + .issues.get({ + owner: this.state.org, + repo: this.state.repo, + issue_number: this.issueNumber, + }); + + if (this.state.comments_processed === -1) { + // This has a side effect of creating a profile for the user. + const creator = await getIntentForUser( + { + // TODO: Fix + login: issue.data.user?.login as string, + avatarUrl: issue.data.user?.avatar_url || undefined, + }, + this.as, + this.config.userIdPrefix, + ); + // We've not sent any messages into the room yet, let's do it! + if (issue.data.body) { + await ensureUserIsInRoom( + creator, + this.intent.underlyingClient, + this.roomId, + ); + await this.messageClient.sendMatrixMessage( + this.roomId, + { + msgtype: "m.text", + external_url: issue.data.html_url, + body: `${issue.data.body} (${issue.data.updated_at})`, + format: "org.matrix.custom.html", + formatted_body: md.render(issue.data.body), + }, + "m.room.message", + creator.userId, ); + } + if (issue.data.pull_request) { + // Send a patch in + // ...was this intended as a request for code? + } + this.state.comments_processed = 0; } - public async syncIssueState() { - log.debug("Syncing issue state for", this.roomId); - const issue = await this.github.getOctokitForRepo(this.org, this.repo).issues.get({ + if (this.state.comments_processed !== issue.data.comments) { + const comments = ( + await this.github + .getOctokitForRepo(this.org, this.repo) + .issues.listComments({ owner: this.state.org, repo: this.state.repo, issue_number: this.issueNumber, - }); - - if (this.state.comments_processed === -1) { - // This has a side effect of creating a profile for the user. - const creator = await getIntentForUser({ - // TODO: Fix - login: issue.data.user?.login as string, - avatarUrl: issue.data.user?.avatar_url || undefined - }, this.as, this.config.userIdPrefix); - // We've not sent any messages into the room yet, let's do it! - if (issue.data.body) { - await ensureUserIsInRoom(creator, this.intent.underlyingClient, this.roomId); - await this.messageClient.sendMatrixMessage(this.roomId, { - msgtype: "m.text", - external_url: issue.data.html_url, - body: `${issue.data.body} (${issue.data.updated_at})`, - format: "org.matrix.custom.html", - formatted_body: md.render(issue.data.body), - }, "m.room.message", creator.userId); - } - if (issue.data.pull_request) { - // Send a patch in - // ...was this intended as a request for code? - } - this.state.comments_processed = 0; - } - - if (this.state.comments_processed !== issue.data.comments) { - const comments = (await this.github.getOctokitForRepo(this.org, this.repo).issues.listComments({ - owner: this.state.org, - repo: this.state.repo, - issue_number: this.issueNumber, - // TODO: Use since to get a subset - })).data.slice(this.state.comments_processed); - - for (const comment of comments) { - await this.onCommentCreated({ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - comment: comment as any, - action: "fake", - }, false); - this.state.comments_processed++; - } - } - - if (this.state.state !== issue.data.state) { - if (issue.data.state === "closed") { - // TODO: Fix - const closedUserId = this.as.getUserIdForSuffix(issue.data.closed_by?.login as string); - await ensureUserIsInRoom( - this.as.getIntentForUserId(closedUserId), - this.intent.underlyingClient, - this.roomId - ); - await this.messageClient.sendMatrixMessage(this.roomId, { - msgtype: "m.notice", - body: `closed the ${issue.data.pull_request ? "pull request" : "issue"} at ${issue.data.closed_at}`, - external_url: issue.data.closed_by?.html_url, - }, "m.room.message", closedUserId); - } - - await this.intent.underlyingClient.sendStateEvent(this.roomId, "m.room.topic", "", { - topic: FormatUtil.formatRoomTopic(issue.data), - }); - - this.state.state = issue.data.state; - } - - await this.intent.underlyingClient.sendStateEvent( - this.roomId, - GitHubIssueConnection.CanonicalEventType, - this.stateKey, - this.state, + // TODO: Use since to get a subset + }) + ).data.slice(this.state.comments_processed); + + for (const comment of comments) { + await this.onCommentCreated( + { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + comment: comment as any, + action: "fake", + }, + false, ); + this.state.comments_processed++; + } } - - public async onMatrixIssueComment(event: MatrixEvent, allowEcho = false) { - const clientKit = await this.tokenStore.getOctokitForUser(event.sender); - if (clientKit === null) { - await this.intent.underlyingClient.sendEvent(this.roomId, "m.reaction", { - "m.relates_to": { - rel_type: "m.annotation", - event_id: event.event_id, - key: "⚠️ Not bridged", - } - }) - log.info("Ignoring comment, user is not authenticated"); - return; - } - - const result = await clientKit.issues.createComment({ - repo: this.state.repo, - owner: this.state.org, - body: await this.commentProcessor.getCommentBodyForEvent(event, false), - issue_number: parseInt(this.state.issues[0], 10), - }); - - if (!allowEcho) { - this.commentProcessor.markCommentAsProcessed(this.state.org, this.state.repo, this.state.issues[0], result.data.id); - } + if (this.state.state !== issue.data.state) { + if (issue.data.state === "closed") { + // TODO: Fix + const closedUserId = this.as.getUserIdForSuffix( + issue.data.closed_by?.login as string, + ); + await ensureUserIsInRoom( + this.as.getIntentForUserId(closedUserId), + this.intent.underlyingClient, + this.roomId, + ); + await this.messageClient.sendMatrixMessage( + this.roomId, + { + msgtype: "m.notice", + body: `closed the ${issue.data.pull_request ? "pull request" : "issue"} at ${issue.data.closed_at}`, + external_url: issue.data.closed_by?.html_url, + }, + "m.room.message", + closedUserId, + ); + } + + await this.intent.underlyingClient.sendStateEvent( + this.roomId, + "m.room.topic", + "", + { + topic: FormatUtil.formatRoomTopic(issue.data), + }, + ); + + this.state.state = issue.data.state; } - public async onIssueEdited(event: IssuesEditedEvent) { - if (!event.changes) { - log.debug("No changes given"); - return; // No changes made. - } - - // TODO: Fix types - if (event.issue && event.changes.title) { - await this.intent.underlyingClient.sendStateEvent(this.roomId, "m.room.name", "", { - name: FormatUtil.formatIssueRoomName(event.issue, event.repository), - }); - } + await this.intent.underlyingClient.sendStateEvent( + this.roomId, + GitHubIssueConnection.CanonicalEventType, + this.stateKey, + this.state, + ); + } + + public async onMatrixIssueComment( + event: MatrixEvent, + allowEcho = false, + ) { + const clientKit = await this.tokenStore.getOctokitForUser(event.sender); + if (clientKit === null) { + await this.intent.underlyingClient.sendEvent(this.roomId, "m.reaction", { + "m.relates_to": { + rel_type: "m.annotation", + event_id: event.event_id, + key: "⚠️ Not bridged", + }, + }); + log.info("Ignoring comment, user is not authenticated"); + return; } - public async onRemove() { - log.info(`Removing ${this.toString()} for ${this.roomId}`); - // Do a sanity check that the event exists. - try { - await this.intent.underlyingClient.getRoomStateEvent(this.roomId, GitHubIssueConnection.CanonicalEventType, this.stateKey); - await this.intent.underlyingClient.sendStateEvent(this.roomId, GitHubIssueConnection.CanonicalEventType, this.stateKey, { disabled: true }); - } catch (ex) { - await this.intent.underlyingClient.getRoomStateEvent(this.roomId, GitHubIssueConnection.LegacyCanonicalEventType, this.stateKey); - await this.intent.underlyingClient.sendStateEvent(this.roomId, GitHubIssueConnection.LegacyCanonicalEventType, this.stateKey, { disabled: true }); - } + const result = await clientKit.issues.createComment({ + repo: this.state.repo, + owner: this.state.org, + body: await this.commentProcessor.getCommentBodyForEvent(event, false), + issue_number: parseInt(this.state.issues[0], 10), + }); + + if (!allowEcho) { + this.commentProcessor.markCommentAsProcessed( + this.state.org, + this.state.repo, + this.state.issues[0], + result.data.id, + ); } + } - public onIssueStateChange() { - return this.syncIssueState(); + public async onIssueEdited(event: IssuesEditedEvent) { + if (!event.changes) { + log.debug("No changes given"); + return; // No changes made. } - public async onMessageEvent(ev: MatrixEvent) { - if (ev.content.body === '!sync') { - // Sync data. - await this.syncIssueState(); - return true; - } - await this.onMatrixIssueComment(ev); - return true; + // TODO: Fix types + if (event.issue && event.changes.title) { + await this.intent.underlyingClient.sendStateEvent( + this.roomId, + "m.room.name", + "", + { + name: FormatUtil.formatIssueRoomName(event.issue, event.repository), + }, + ); + } + } + + public async onRemove() { + log.info(`Removing ${this.toString()} for ${this.roomId}`); + // Do a sanity check that the event exists. + try { + await this.intent.underlyingClient.getRoomStateEvent( + this.roomId, + GitHubIssueConnection.CanonicalEventType, + this.stateKey, + ); + await this.intent.underlyingClient.sendStateEvent( + this.roomId, + GitHubIssueConnection.CanonicalEventType, + this.stateKey, + { disabled: true }, + ); + } catch (ex) { + await this.intent.underlyingClient.getRoomStateEvent( + this.roomId, + GitHubIssueConnection.LegacyCanonicalEventType, + this.stateKey, + ); + await this.intent.underlyingClient.sendStateEvent( + this.roomId, + GitHubIssueConnection.LegacyCanonicalEventType, + this.stateKey, + { disabled: true }, + ); } + } - public toString() { - return `GitHubIssue ${this.state.org}/${this.state.repo}#${this.state.issues.join(",")}`; + public onIssueStateChange() { + return this.syncIssueState(); + } + + public async onMessageEvent(ev: MatrixEvent) { + if (ev.content.body === "!sync") { + // Sync data. + await this.syncIssueState(); + return true; } + await this.onMatrixIssueComment(ev); + return true; + } + + public toString() { + return `GitHubIssue ${this.state.org}/${this.state.repo}#${this.state.issues.join(",")}`; + } } diff --git a/src/Connections/GithubProject.ts b/src/Connections/GithubProject.ts index 6d21db51b..663112257 100644 --- a/src/Connections/GithubProject.ts +++ b/src/Connections/GithubProject.ts @@ -1,4 +1,8 @@ -import { Connection, IConnection, InstantiateConnectionOpts } from "./IConnection"; +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"; @@ -7,8 +11,8 @@ import { ConfigGrantChecker, GrantChecker } from "../grants/GrantCheck"; import { BridgeConfig } from "../config/Config"; export interface GitHubProjectConnectionState { - project_id: number; - state: "open"|"closed"; + project_id: number; + state: "open" | "closed"; } const log = new Logger("GitHubProjectConnection"); @@ -16,82 +20,121 @@ const log = new Logger("GitHubProjectConnection"); * Handles rooms connected to a GitHub project. */ @Connection -export class GitHubProjectConnection extends BaseConnection implements IConnection { - static readonly CanonicalEventType = "uk.half-shot.matrix-hookshot.github.project"; - static readonly LegacyCanonicalEventType = "uk.half-shot.matrix-github.project"; - static readonly ServiceCategory = "github"; - static readonly EventTypes = [ - GitHubProjectConnection.CanonicalEventType, - GitHubProjectConnection.LegacyCanonicalEventType, - ]; +export class GitHubProjectConnection + extends BaseConnection + implements IConnection +{ + static readonly CanonicalEventType = + "uk.half-shot.matrix-hookshot.github.project"; + static readonly LegacyCanonicalEventType = + "uk.half-shot.matrix-github.project"; + static readonly ServiceCategory = "github"; + static readonly EventTypes = [ + GitHubProjectConnection.CanonicalEventType, + GitHubProjectConnection.LegacyCanonicalEventType, + ]; - public static createConnectionForState(roomId: string, event: StateEvent, {config, as, intent}: InstantiateConnectionOpts) { - if (!config.github) { - throw Error('GitHub is not configured'); - } - return new GitHubProjectConnection(roomId, as, intent, config, event.content, event.stateKey); + public static createConnectionForState( + roomId: string, + event: StateEvent, + { config, as, intent }: InstantiateConnectionOpts, + ) { + if (!config.github) { + throw Error("GitHub is not configured"); } + return new GitHubProjectConnection( + roomId, + as, + intent, + config, + event.content, + event.stateKey, + ); + } - public static getGrantKey(projectId: number) { - return `${this.CanonicalEventType}/${projectId}`; - } + public static getGrantKey(projectId: number) { + return `${this.CanonicalEventType}/${projectId}`; + } - static async onOpenProject(project: ProjectsGetResponseData, as: Appservice, intent: Intent, config: BridgeConfig, inviteUser: string): Promise { - log.info(`Fetching ${project.name} ${project.id}`); + static async onOpenProject( + project: ProjectsGetResponseData, + as: Appservice, + intent: Intent, + config: BridgeConfig, + inviteUser: string, + ): Promise { + log.info(`Fetching ${project.name} ${project.id}`); - // URL hack so we don't need to fetch the repo itself. + // URL hack so we don't need to fetch the repo itself. - const state: GitHubProjectConnectionState = { - project_id: project.id, - state: project.state as "open"|"closed", - }; + const state: GitHubProjectConnectionState = { + project_id: project.id, + state: project.state as "open" | "closed", + }; - const roomId = await intent.underlyingClient.createRoom({ - visibility: "private", - name: `${project.name}`, - topic: project.body || undefined, - preset: "private_chat", - invite: [inviteUser], - initial_state: [ - { - type: this.CanonicalEventType, - content: state, - state_key: project.url, - }, - ], - }); - await new GrantChecker(as.botIntent, 'github').grantConnection(roomId, this.getGrantKey(project.id)); + const roomId = await intent.underlyingClient.createRoom({ + visibility: "private", + name: `${project.name}`, + topic: project.body || undefined, + preset: "private_chat", + invite: [inviteUser], + initial_state: [ + { + type: this.CanonicalEventType, + content: state, + state_key: project.url, + }, + ], + }); + await new GrantChecker(as.botIntent, "github").grantConnection( + roomId, + this.getGrantKey(project.id), + ); - return new GitHubProjectConnection(roomId, as, intent, config, state, project.url) - } + return new GitHubProjectConnection( + roomId, + as, + intent, + config, + state, + project.url, + ); + } - get projectId() { - return this.state.project_id; - } + get projectId() { + return this.state.project_id; + } - private readonly grantChecker: GrantChecker; + private readonly grantChecker: GrantChecker; - constructor( - public readonly roomId: string, - as: Appservice, - intent: Intent, - config: BridgeConfig, - private state: GitHubProjectConnectionState, - stateKey: string, - ) { - super(roomId, stateKey, GitHubProjectConnection.CanonicalEventType); - this.grantChecker = new ConfigGrantChecker("github", as, config); - } + constructor( + public readonly roomId: string, + as: Appservice, + intent: Intent, + config: BridgeConfig, + private state: GitHubProjectConnectionState, + stateKey: string, + ) { + super(roomId, stateKey, GitHubProjectConnection.CanonicalEventType); + this.grantChecker = new ConfigGrantChecker("github", as, config); + } - public ensureGrant(sender?: string) { - return this.grantChecker.assertConnectionGranted(this.roomId, GitHubProjectConnection.getGrantKey(this.state.project_id), sender); - } + public ensureGrant(sender?: string) { + return this.grantChecker.assertConnectionGranted( + this.roomId, + GitHubProjectConnection.getGrantKey(this.state.project_id), + sender, + ); + } - public isInterestedInStateEvent(eventType: string, stateKey: string) { - return GitHubProjectConnection.EventTypes.includes(eventType) && this.stateKey === stateKey; - } + public isInterestedInStateEvent(eventType: string, stateKey: string) { + return ( + GitHubProjectConnection.EventTypes.includes(eventType) && + this.stateKey === stateKey + ); + } - public toString() { - return `GitHubProjectConnection ${this.state.project_id}}`; - } + public toString() { + return `GitHubProjectConnection ${this.state.project_id}}`; + } } diff --git a/src/Connections/GithubRepo.ts b/src/Connections/GithubRepo.ts index 00b08e3c5..1cdb07780 100644 --- a/src/Connections/GithubRepo.ts +++ b/src/Connections/GithubRepo.ts @@ -1,15 +1,48 @@ -import { Appservice, Intent, IRichReplyMetadata, StateEvent } from "matrix-bot-sdk"; -import { BotCommands, botCommand, compileBotCommands, HelpFunction } from "../BotCommands"; +import { + Appservice, + Intent, + IRichReplyMetadata, + StateEvent, +} from "matrix-bot-sdk"; +import { + BotCommands, + botCommand, + compileBotCommands, + HelpFunction, +} from "../BotCommands"; import { CommentProcessor } from "../CommentProcessor"; import { FormatUtil, LooseMinimalGitHubRepo } from "../FormatUtil"; import { Octokit } from "@octokit/rest"; -import { Connection, IConnection, IConnectionState, InstantiateConnectionOpts, ProvisionConnectionOpts } from "./IConnection"; +import { + Connection, + IConnection, + IConnectionState, + InstantiateConnectionOpts, + ProvisionConnectionOpts, +} from "./IConnection"; import { GetConnectionsResponseItem } from "../Widgets/api"; -import { IssuesOpenedEvent, IssuesReopenedEvent, IssuesEditedEvent, PullRequestOpenedEvent, IssuesClosedEvent, PullRequestClosedEvent, - PullRequestReadyForReviewEvent, PullRequestReviewSubmittedEvent, ReleasePublishedEvent, ReleaseCreatedEvent, - IssuesLabeledEvent, IssuesUnlabeledEvent, WorkflowRunCompletedEvent, IssueCommentCreatedEvent, PushEvent +import { + IssuesOpenedEvent, + IssuesReopenedEvent, + IssuesEditedEvent, + PullRequestOpenedEvent, + IssuesClosedEvent, + PullRequestClosedEvent, + PullRequestReadyForReviewEvent, + PullRequestReviewSubmittedEvent, + ReleasePublishedEvent, + ReleaseCreatedEvent, + IssuesLabeledEvent, + IssuesUnlabeledEvent, + WorkflowRunCompletedEvent, + IssueCommentCreatedEvent, + PushEvent, } from "@octokit/webhooks-types"; -import { MatrixMessageContent, MatrixEvent, MatrixReactionContent } from "../MatrixEvent"; +import { + MatrixMessageContent, + MatrixEvent, + MatrixReactionContent, +} from "../MatrixEvent"; import { MessageSenderClient } from "../MatrixSender"; import { CommandError, NotLoggedInError } from "../errors"; import { ReposGetResponseData } from "../github/Types"; @@ -19,7 +52,10 @@ import { emojify } from "node-emoji"; import { Logger } from "matrix-appservice-bridge"; import markdown from "markdown-it"; import { CommandConnection } from "./CommandConnection"; -import { getNameForGitHubAccount, GithubInstance } from "../github/GithubInstance"; +import { + getNameForGitHubAccount, + GithubInstance, +} from "../github/GithubInstance"; import { GitHubIssueConnection } from "./GithubIssue"; import { BridgeConfigGitHub } from "../config/Config"; import { ApiError, ErrCode, ValidatorApiError } from "../api"; @@ -33,122 +69,124 @@ const log = new Logger("GitHubRepoConnection"); const md = new markdown(); interface IQueryRoomOpts { - as: Appservice; - tokenStore: UserTokenStore; - commentProcessor: CommentProcessor; - messageClient: MessageSenderClient; - githubInstance: GithubInstance; + as: Appservice; + tokenStore: UserTokenStore; + commentProcessor: CommentProcessor; + messageClient: MessageSenderClient; + githubInstance: GithubInstance; } export interface GitHubRepoConnectionOptions extends IConnectionState { - /** - * Do not use. Use `enableHooks`. - * @deprecated - */ - ignoreHooks?: AllowedEventsNames[], - enableHooks?: AllowedEventsNames[], - showIssueRoomLink?: boolean; - prDiff?: { - enabled: boolean; - maxLines: number; - }, - includingLabels?: string[]; - excludingLabels?: string[]; - hotlinkIssues?: boolean|{ + /** + * Do not use. Use `enableHooks`. + * @deprecated + */ + ignoreHooks?: AllowedEventsNames[]; + enableHooks?: AllowedEventsNames[]; + showIssueRoomLink?: boolean; + prDiff?: { + enabled: boolean; + maxLines: number; + }; + includingLabels?: string[]; + excludingLabels?: string[]; + hotlinkIssues?: + | boolean + | { prefix: string; - }; - newIssue?: { - labels: string[]; - }; - workflowRun?: { - matchingBranch?: string; - includingWorkflows?: string[]; - excludingWorkflows?: string[]; - } + }; + newIssue?: { + labels: string[]; + }; + workflowRun?: { + matchingBranch?: string; + includingWorkflows?: string[]; + excludingWorkflows?: string[]; + }; } export interface GitHubRepoConnectionState extends GitHubRepoConnectionOptions { - org: string; - repo: string; + org: string; + repo: string; } interface ConnectionValidatedState extends GitHubRepoConnectionState { - ignoreHooks: undefined, - enableHooks: AllowedEventsNames[], + ignoreHooks: undefined; + enableHooks: AllowedEventsNames[]; } - export interface GitHubRepoConnectionOrgTarget { - name: string; + name: string; } export interface GitHubRepoConnectionRepoTarget { - state: GitHubRepoConnectionState; - name: string; - description?: string; - avatar?: string; + state: GitHubRepoConnectionState; + name: string; + description?: string; + avatar?: string; } -export type GitHubRepoConnectionTarget = GitHubRepoConnectionOrgTarget|GitHubRepoConnectionRepoTarget; - - -export type GitHubRepoResponseItem = GetConnectionsResponseItem; +export type GitHubRepoConnectionTarget = + | GitHubRepoConnectionOrgTarget + | GitHubRepoConnectionRepoTarget; +export type GitHubRepoResponseItem = + GetConnectionsResponseItem; export type AllowedEventsNames = - "issue.changed" | - "issue.created" | - "issue.edited" | - "issue.labeled" | - "issue.comment" | - "issue.comment.created" | - "issue" | - "pull_request.closed" | - "pull_request.merged" | - "pull_request.opened" | - "pull_request.ready_for_review" | - "pull_request.reviewed" | - "pull_request" | - "push" | - "release.created" | - "release.drafted" | - "release" | - "workflow" | - "workflow.run" | - "workflow.run.success" | - "workflow.run.failure" | - "workflow.run.neutral" | - "workflow.run.cancelled" | - "workflow.run.timed_out" | - "workflow.run.action_required" | - "workflow.run.stale"; + | "issue.changed" + | "issue.created" + | "issue.edited" + | "issue.labeled" + | "issue.comment" + | "issue.comment.created" + | "issue" + | "pull_request.closed" + | "pull_request.merged" + | "pull_request.opened" + | "pull_request.ready_for_review" + | "pull_request.reviewed" + | "pull_request" + | "push" + | "release.created" + | "release.drafted" + | "release" + | "workflow" + | "workflow.run" + | "workflow.run.success" + | "workflow.run.failure" + | "workflow.run.neutral" + | "workflow.run.cancelled" + | "workflow.run.timed_out" + | "workflow.run.action_required" + | "workflow.run.stale"; export const AllowedEvents: AllowedEventsNames[] = [ - "issue.changed" , - "issue.created" , - "issue.edited" , - "issue.labeled" , - "issue.comment", - "issue.comment.created", - "issue", - "pull_request.closed" , - "pull_request.merged" , - "pull_request.opened" , - "pull_request.ready_for_review" , - "pull_request.reviewed" , - "pull_request" , - "push", - "release.created" , - "release.drafted" , - "release", - "workflow", - "workflow.run", - "workflow.run.success", - "workflow.run.failure", - "workflow.run.neutral", - "workflow.run.cancelled", - "workflow.run.timed_out", - "workflow.run.action_required", - "workflow.run.stale", + "issue.changed", + "issue.created", + "issue.edited", + "issue.labeled", + "issue.comment", + "issue.comment.created", + "issue", + "pull_request.closed", + "pull_request.merged", + "pull_request.opened", + "pull_request.ready_for_review", + "pull_request.reviewed", + "pull_request", + "push", + "release.created", + "release.drafted", + "release", + "workflow", + "workflow.run", + "workflow.run.success", + "workflow.run.failure", + "workflow.run.neutral", + "workflow.run.cancelled", + "workflow.run.timed_out", + "workflow.run.action_required", + "workflow.run.stale", ]; /** @@ -156,174 +194,176 @@ export const AllowedEvents: AllowedEventsNames[] = [ * specifed in the ignoreHooks option. */ const DefaultHooks: AllowedEventsNames[] = [ - "issue.changed", - "issue.created", - "issue.edited", - "issue.labeled", - "issue", - "pull_request.closed", - "pull_request.merged", - "pull_request.opened", - "pull_request.ready_for_review", - "pull_request.reviewed", - "pull_request", - "release.created" + "issue.changed", + "issue.created", + "issue.edited", + "issue.labeled", + "issue", + "pull_request.closed", + "pull_request.merged", + "pull_request.opened", + "pull_request.ready_for_review", + "pull_request.reviewed", + "pull_request", + "release.created", ]; const ConnectionStateSchema = { type: "object", properties: { priority: { - type: "number", - nullable: true, + type: "number", + nullable: true, }, - org: {type: "string"}, - repo: {type: "string"}, + org: { type: "string" }, + repo: { type: "string" }, /** * Legacy state. * @deprecated */ ignoreHooks: { - type: "array", - items: { - type: "string", - }, - nullable: true, + type: "array", + items: { + type: "string", + }, + nullable: true, }, enableHooks: { - type: "array", - items: { - type: "string", - }, - nullable: true, + type: "array", + items: { + type: "string", + }, + nullable: true, }, commandPrefix: { - type: "string", - minLength: 2, - nullable: true, - maxLength: 24, + type: "string", + minLength: 2, + nullable: true, + maxLength: 24, }, showIssueRoomLink: { - type: "boolean", - nullable: true, + type: "boolean", + nullable: true, }, prDiff: { - type: "object", - properties: { - enabled: {type: "boolean"}, - maxLines: { - type: "number", - minimum: 1, - }, + type: "object", + properties: { + enabled: { type: "boolean" }, + maxLines: { + type: "number", + minimum: 1, }, - nullable: true, - required: ["enabled"], + }, + nullable: true, + required: ["enabled"], }, newIssue: { - type: "object", - properties: { - labels: { - type: "array", - items: {type: "string"}, - }, + type: "object", + properties: { + labels: { + type: "array", + items: { type: "string" }, }, - required: ["labels"], - nullable: true, + }, + required: ["labels"], + nullable: true, }, includingLabels: { - type: "array", - nullable: true, - items: {type: "string"}, + type: "array", + nullable: true, + items: { type: "string" }, }, excludingLabels: { - type: "array", - nullable: true, - items: {type: "string"}, + type: "array", + nullable: true, + items: { type: "string" }, }, hotlinkIssues: { - type: ["object","boolean"], - nullable: true, - oneOf: [{ - type: "object", - required: ["prefix"], - properties: { - prefix: {type: "string"}, - }, - }, { - type: "boolean", - }], + type: ["object", "boolean"], + nullable: true, + oneOf: [ + { + type: "object", + required: ["prefix"], + properties: { + prefix: { type: "string" }, + }, + }, + { + type: "boolean", + }, + ], }, workflowRun: { - type: "object", - nullable: true, - properties: { - matchingBranch: { - nullable: true, - type: "string", - }, - includingWorkflows: { - nullable: true, - type: "array", - items: {type: "string"}, - }, - excludingWorkflows: { - nullable: true, - type: "array", - items: {type: "string"}, - }, + type: "object", + nullable: true, + properties: { + matchingBranch: { + nullable: true, + type: "string", }, - } + includingWorkflows: { + nullable: true, + type: "array", + items: { type: "string" }, + }, + excludingWorkflows: { + nullable: true, + type: "array", + items: { type: "string" }, + }, + }, + }, }, - required: [ - "org", - "repo" - ], - additionalProperties: true + required: ["org", "repo"], + additionalProperties: true, } as JSONSchemaType; type ReactionOptions = -| "+1" -| "-1" -| "laugh" -| "confused" -| "heart" -| "hooray" -| "rocket" -| "eyes"; - - -const GITHUB_REACTION_CONTENT: {[emoji: string]: ReactionOptions} = { - "👍": "+1", - "👎": "-1", - "😄": "laugh", - "🎉": "hooray", - "😕": "confused", - "❤️": "heart", - "🚀": "rocket", - "👀": "eyes", -} + | "+1" + | "-1" + | "laugh" + | "confused" + | "heart" + | "hooray" + | "rocket" + | "eyes"; + +const GITHUB_REACTION_CONTENT: { [emoji: string]: ReactionOptions } = { + "👍": "+1", + "👎": "-1", + "😄": "laugh", + "🎉": "hooray", + "😕": "confused", + "❤️": "heart", + "🚀": "rocket", + "👀": "eyes", +}; const ALLOWED_REACTIONS = { - "🗑️": "close", - "🚮": "close", - "👐": "open", -} + "🗑️": "close", + "🚮": "close", + "👐": "open", +}; const EMOJI_TO_REVIEW_STATE = { - '✅✔️☑️': 'APPROVE', - '🔴🚫⛔️': 'REQUEST_CHANGES', + "✅✔️☑️": "APPROVE", + "🔴🚫⛔️": "REQUEST_CHANGES", }; -const WORKFLOW_CONCLUSION_TO_NOTICE: Record = { - 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 🍞", - skipped: "skipped ⏭️" -} +const WORKFLOW_CONCLUSION_TO_NOTICE: Record< + WorkflowRunCompletedEvent["workflow_run"]["conclusion"], + string +> = { + 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 🍞", + skipped: "skipped ⏭️", +}; const TRUNCATE_COMMENT_SIZE = 256; const LABELED_DEBOUNCE_MS = 5000; @@ -332,1183 +372,1645 @@ const DEFAULT_HOTLINK_PREFIX = "#"; const MAX_RETURNED_TARGETS = 10; interface IPushEventContent { - body: string, - formatted_body: string, - msgtype: "m.notice", - format: "org.matrix.custom.html", - external_url: string, - "uk.half-shot.matrix-hookshot.github.push": { - commits: string[], - ref: string, - base_ref: string|null, - pusher: string, - }, - "uk.half-shot.matrix-hookshot.github.repo": GitHubRepoMessageBody["uk.half-shot.matrix-hookshot.github.repo"], + body: string; + formatted_body: string; + msgtype: "m.notice"; + format: "org.matrix.custom.html"; + external_url: string; + "uk.half-shot.matrix-hookshot.github.push": { + commits: string[]; + ref: string; + base_ref: string | null; + pusher: string; + }; + "uk.half-shot.matrix-hookshot.github.repo": GitHubRepoMessageBody["uk.half-shot.matrix-hookshot.github.repo"]; } function compareEmojiStrings(e0: string, e1: string, e0Index = 0) { - return e0.codePointAt(e0Index) === e1.codePointAt(0); + return e0.codePointAt(e0Index) === e1.codePointAt(0); } export interface GitHubTargetFilter { - search?: string; - orgName?: string; + search?: string; + orgName?: string; } /** * Handles rooms connected to a GitHub repo. */ @Connection -export class GitHubRepoConnection extends CommandConnection implements IConnection { - static validateState(state: unknown, isExistingState = false): ConnectionValidatedState { - const validator = new Ajv({ allowUnionTypes: true }).compile(ConnectionStateSchema); - if (validator(state)) { - const invalidHooks = !isExistingState && state.enableHooks && state.enableHooks.filter(h => !AllowedEvents.includes(h)); - if (invalidHooks && invalidHooks.length) { - throw new ApiError(`'enableHooks' must only contain allowed values. Found invalid values ${invalidHooks}`, ErrCode.BadValue); - } - if (state.ignoreHooks) { - if (!isExistingState) { - throw new ApiError('`ignoreHooks` cannot be used with new connections', ErrCode.BadValue); - } - log.warn(`Room has old state key 'ignoreHooks'. Converting to compatible enabledHooks filter`); - state.enableHooks = HookFilter.convertIgnoredHooksToEnabledHooks(state.enableHooks, state.ignoreHooks, DefaultHooks); - } - return { - ...state, - ignoreHooks: undefined, - enableHooks: state.enableHooks ?? [...DefaultHooks] - }; - } - throw new ValidatorApiError(validator.errors); +export class GitHubRepoConnection + extends CommandConnection + implements IConnection +{ + static validateState( + state: unknown, + isExistingState = false, + ): ConnectionValidatedState { + const validator = new Ajv({ allowUnionTypes: true }).compile( + ConnectionStateSchema, + ); + if (validator(state)) { + const invalidHooks = + !isExistingState && + state.enableHooks && + state.enableHooks.filter((h) => !AllowedEvents.includes(h)); + if (invalidHooks && invalidHooks.length) { + throw new ApiError( + `'enableHooks' must only contain allowed values. Found invalid values ${invalidHooks}`, + ErrCode.BadValue, + ); + } + if (state.ignoreHooks) { + if (!isExistingState) { + throw new ApiError( + "`ignoreHooks` cannot be used with new connections", + ErrCode.BadValue, + ); + } + log.warn( + `Room has old state key 'ignoreHooks'. Converting to compatible enabledHooks filter`, + ); + state.enableHooks = HookFilter.convertIgnoredHooksToEnabledHooks( + state.enableHooks, + state.ignoreHooks, + DefaultHooks, + ); + } + return { + ...state, + ignoreHooks: undefined, + enableHooks: state.enableHooks ?? [...DefaultHooks], + }; } - - static async assertUserHasAccessToRepo(userId: string, org: string, repo: string, tokenStore: UserTokenStore) { - const octokit = await tokenStore.getOctokitForUser(userId); - if (!octokit) { - throw new ApiError("User is not authenticated with GitHub", ErrCode.ForbiddenUser); - } - const me = await octokit.users.getAuthenticated(); - let permissionLevel; - try { - const githubRepo = await octokit.repos.getCollaboratorPermissionLevel({owner: org, repo, username: me.data.login }); - permissionLevel = githubRepo.data.permission; - } catch (ex) { - throw new ApiError("Could not determine if the user has access to this repository, does the repository exist?", ErrCode.ForbiddenUser); - } - - if (permissionLevel !== "admin" && permissionLevel !== "write") { - throw new ApiError("You must at least have write permissions to bridge this repository", ErrCode.ForbiddenUser); - } + throw new ValidatorApiError(validator.errors); + } + + static async assertUserHasAccessToRepo( + userId: string, + org: string, + repo: string, + tokenStore: UserTokenStore, + ) { + const octokit = await tokenStore.getOctokitForUser(userId); + if (!octokit) { + throw new ApiError( + "User is not authenticated with GitHub", + ErrCode.ForbiddenUser, + ); } - - static async provisionConnection(roomId: string, userId: string, data: Record, {as, intent, tokenStore, github, config}: ProvisionConnectionOpts) { - if (!github || !config.github) { - throw Error('GitHub is not configured'); - } - const validData = this.validateState(data); - await this.assertUserHasAccessToRepo(userId, validData.org, validData.repo, tokenStore); - const userOctokit = await tokenStore.getOctokitForUser(userId); - if (!userOctokit) { - // Given we assert the above, this is unlikely. - throw new ApiError("User is not authenticated with GitHub", ErrCode.ForbiddenUser); - } - const ownSelf = await userOctokit.users.getAuthenticated(); - - let installationId = 0; - - if (ownSelf.data.login.toLowerCase() === validData.org) { - installationId = (await github.appOctokit.apps.getUserInstallation({ username: ownSelf.data.login })).data.id; - } else { - // Github will error if the authed user tries to list repos of a disallowed installation, even - // if we got the installation ID from the app's instance. - installationId = (await github.appOctokit.apps.getOrgInstallation({ org: validData.org })).data.id; - } - - if (!installationId) { - throw new ApiError( - "You need to add a GitHub App to this organisation / repository before you can bridge it. Open the link to add the app, and then retry this request", - ErrCode.AdditionalActionRequired, - -1, - { - // E.g. https://github.com/apps/matrix-bridge/installations/new - installUrl: github.newInstallationUrl, - } - ); - } - const stateEventKey = `${validData.org}/${validData.repo}`; - await new GitHubGrantChecker(as, tokenStore).grantConnection(roomId, { org: validData.org, repo: validData.repo }); - await intent.underlyingClient.sendStateEvent(roomId, this.CanonicalEventType, stateEventKey, validData); - return { - stateEventContent: validData, - connection: new GitHubRepoConnection(roomId, as, intent, validData, tokenStore, stateEventKey, github, config.github), - } + const me = await octokit.users.getAuthenticated(); + let permissionLevel; + try { + const githubRepo = await octokit.repos.getCollaboratorPermissionLevel({ + owner: org, + repo, + username: me.data.login, + }); + permissionLevel = githubRepo.data.permission; + } catch (ex) { + throw new ApiError( + "Could not determine if the user has access to this repository, does the repository exist?", + ErrCode.ForbiddenUser, + ); } - static readonly CanonicalEventType = "uk.half-shot.matrix-hookshot.github.repository"; - static readonly LegacyCanonicalEventType = "uk.half-shot.matrix-github.repository"; - static readonly EventTypes = [ - GitHubRepoConnection.CanonicalEventType, - GitHubRepoConnection.LegacyCanonicalEventType, - ]; - static readonly ServiceCategory = "github"; - static readonly QueryRoomRegex = /#github_(.+)_(.+):.*/; - - static async createConnectionForState(roomId: string, state: StateEvent>, {as, intent, tokenStore, github, config}: InstantiateConnectionOpts) { - if (!github || !config.github) { - throw Error('GitHub is not configured'); - } - - const connectionState = this.validateState(state.content, true); - - return new GitHubRepoConnection(roomId, as, intent, connectionState, tokenStore, state.stateKey, github, config.github); + if (permissionLevel !== "admin" && permissionLevel !== "write") { + throw new ApiError( + "You must at least have write permissions to bridge this repository", + ErrCode.ForbiddenUser, + ); } - - static async onQueryRoom(result: RegExpExecArray, opts: IQueryRoomOpts): Promise { - const parts = result?.slice(1); - if (!parts) { - log.error("Invalid alias pattern"); - throw Error("Could not find repo"); - } - - const owner = parts[0]; - const repo = parts[1]; - const issueNumber = parseInt(parts[2], 10); - - log.info(`Fetching ${owner}/${repo}/${issueNumber}`); - let repoRes: ReposGetResponseData; - const octokit = opts.githubInstance.getOctokitForRepo(owner, repo); - try { - repoRes = (await octokit.repos.get({ - owner, - repo, - })).data; - if (repoRes.private) { - throw Error('Refusing to bridge private repo'); - } - } catch (ex) { - log.error("Failed to get repo:", ex); - throw Error("Could not find repo"); - } - - // URL hack so we don't need to fetch the repo itself. - const orgRepoName = repoRes.full_name; - let avatarUrl = undefined; - try { - const profile = await octokit.users.getByUsername({ - username: owner, - }); - if (profile.data.avatar_url) { - const res = await axios.get(profile.data.avatar_url as string, { - responseType: 'arraybuffer', - }); - log.info(`uploading ${profile.data.avatar_url}`); - const contentType: string = res.headers["content-type"]; - const mxcUrl = await opts.as.botClient.uploadContent( - Buffer.from(res.data as ArrayBuffer), - contentType, - `avatar_${profile.data.id}.png`, - ); - avatarUrl = { - type: "m.room.avatar", - state_key: "", - content: { - url: mxcUrl, - }, - }; - } - } catch (ex) { - log.info("Failed to get avatar for org:", ex); - throw ex; - } - - return { - visibility: "public", - name: FormatUtil.formatRepoRoomName(repoRes), - topic: FormatUtil.formatRepoRoomTeam(repoRes), - preset: "public_chat", - initial_state: [ - { - type: this.CanonicalEventType, - content: { - org: orgRepoName.split("/")[0], - repo: orgRepoName.split("/")[1], - state: "open", - } as GitHubRepoConnectionState, - state_key: repoRes.url, - }, - avatarUrl, - ], - }; + } + + static async provisionConnection( + roomId: string, + userId: string, + data: Record, + { as, intent, tokenStore, github, config }: ProvisionConnectionOpts, + ) { + if (!github || !config.github) { + throw Error("GitHub is not configured"); } - - static helpMessage: HelpFunction; - static botCommands: BotCommands; - - private readonly hookFilter: HookFilter; - - public debounceOnIssueLabeled = new Map, timeout: NodeJS.Timeout}>(); - - private readonly grantChecker; - - constructor( - roomId: string, - private readonly as: Appservice, - private readonly intent: Intent, - state: ConnectionValidatedState, - private readonly tokenStore: UserTokenStore, - stateKey: string, - private readonly githubInstance: GithubInstance, - private readonly config: BridgeConfigGitHub, - ) { - super( - roomId, - stateKey, - GitHubRepoConnection.CanonicalEventType, - state, - intent.underlyingClient, - GitHubRepoConnection.botCommands, - GitHubRepoConnection.helpMessage, - ["github"], - "!gh", - "github", - ); - this.hookFilter = new HookFilter( - state.enableHooks, - ) - this.grantChecker = new GitHubGrantChecker(this.as, this.tokenStore); + const validData = this.validateState(data); + await this.assertUserHasAccessToRepo( + userId, + validData.org, + validData.repo, + tokenStore, + ); + const userOctokit = await tokenStore.getOctokitForUser(userId); + if (!userOctokit) { + // Given we assert the above, this is unlikely. + throw new ApiError( + "User is not authenticated with GitHub", + ErrCode.ForbiddenUser, + ); } - - public get hotlinkIssues() { - const cfg = this.config.defaultOptions?.hotlinkIssues || this.state.hotlinkIssues; - if (cfg === false) { - return false; - } - if (cfg === true || cfg === undefined || cfg.prefix === undefined) { - return { - prefix: DEFAULT_HOTLINK_PREFIX, - } - } - return cfg; + const ownSelf = await userOctokit.users.getAuthenticated(); + + let installationId = 0; + + if (ownSelf.data.login.toLowerCase() === validData.org) { + installationId = ( + await github.appOctokit.apps.getUserInstallation({ + username: ownSelf.data.login, + }) + ).data.id; + } else { + // Github will error if the authed user tries to list repos of a disallowed installation, even + // if we got the installation ID from the app's instance. + installationId = ( + await github.appOctokit.apps.getOrgInstallation({ org: validData.org }) + ).data.id; } - public get org() { - return this.state.org.toLowerCase(); + if (!installationId) { + throw new ApiError( + "You need to add a GitHub App to this organisation / repository before you can bridge it. Open the link to add the app, and then retry this request", + ErrCode.AdditionalActionRequired, + -1, + { + // E.g. https://github.com/apps/matrix-bridge/installations/new + installUrl: github.newInstallationUrl, + }, + ); } - - private get showIssueRoomLink() { - return this.state.showIssueRoomLink === undefined ? (this.config.defaultOptions?.showIssueRoomLink || false) : this.state.showIssueRoomLink; + const stateEventKey = `${validData.org}/${validData.repo}`; + await new GitHubGrantChecker(as, tokenStore).grantConnection(roomId, { + org: validData.org, + repo: validData.repo, + }); + await intent.underlyingClient.sendStateEvent( + roomId, + this.CanonicalEventType, + stateEventKey, + validData, + ); + return { + stateEventContent: validData, + connection: new GitHubRepoConnection( + roomId, + as, + intent, + validData, + tokenStore, + stateEventKey, + github, + config.github, + ), + }; + } + + static readonly CanonicalEventType = + "uk.half-shot.matrix-hookshot.github.repository"; + static readonly LegacyCanonicalEventType = + "uk.half-shot.matrix-github.repository"; + static readonly EventTypes = [ + GitHubRepoConnection.CanonicalEventType, + GitHubRepoConnection.LegacyCanonicalEventType, + ]; + static readonly ServiceCategory = "github"; + static readonly QueryRoomRegex = /#github_(.+)_(.+):.*/; + + static async createConnectionForState( + roomId: string, + state: StateEvent>, + { as, intent, tokenStore, github, config }: InstantiateConnectionOpts, + ) { + if (!github || !config.github) { + throw Error("GitHub is not configured"); } - public get repo() { - return this.state.repo.toLowerCase(); + const connectionState = this.validateState(state.content, true); + + return new GitHubRepoConnection( + roomId, + as, + intent, + connectionState, + tokenStore, + state.stateKey, + github, + config.github, + ); + } + + static async onQueryRoom( + result: RegExpExecArray, + opts: IQueryRoomOpts, + ): Promise { + const parts = result?.slice(1); + if (!parts) { + log.error("Invalid alias pattern"); + throw Error("Could not find repo"); } - public get priority(): number { - return this.state.priority || super.priority; + const owner = parts[0]; + const repo = parts[1]; + const issueNumber = parseInt(parts[2], 10); + + log.info(`Fetching ${owner}/${repo}/${issueNumber}`); + let repoRes: ReposGetResponseData; + const octokit = opts.githubInstance.getOctokitForRepo(owner, repo); + try { + repoRes = ( + await octokit.repos.get({ + owner, + repo, + }) + ).data; + if (repoRes.private) { + throw Error("Refusing to bridge private repo"); + } + } catch (ex) { + log.error("Failed to get repo:", ex); + throw Error("Could not find repo"); } - public async ensureGrant(sender?: string, state = { org: this.org, repo: this.repo }) { - await this.grantChecker.assertConnectionGranted(this.roomId, state, sender); + // URL hack so we don't need to fetch the repo itself. + const orgRepoName = repoRes.full_name; + let avatarUrl = undefined; + try { + const profile = await octokit.users.getByUsername({ + username: owner, + }); + if (profile.data.avatar_url) { + const res = await axios.get(profile.data.avatar_url as string, { + responseType: "arraybuffer", + }); + log.info(`uploading ${profile.data.avatar_url}`); + const contentType: string = res.headers["content-type"]; + const mxcUrl = await opts.as.botClient.uploadContent( + Buffer.from(res.data as ArrayBuffer), + contentType, + `avatar_${profile.data.id}.png`, + ); + avatarUrl = { + type: "m.room.avatar", + state_key: "", + content: { + url: mxcUrl, + }, + }; + } + } catch (ex) { + log.info("Failed to get avatar for org:", ex); + throw ex; } - protected async validateConnectionState(content: unknown) { - const state = GitHubRepoConnection.validateState(content); - // Validate the permissions of this state - await this.ensureGrant(undefined, { org: this.org, repo: this.repo }); - return state; + return { + visibility: "public", + name: FormatUtil.formatRepoRoomName(repoRes), + topic: FormatUtil.formatRepoRoomTeam(repoRes), + preset: "public_chat", + initial_state: [ + { + type: this.CanonicalEventType, + content: { + org: orgRepoName.split("/")[0], + repo: orgRepoName.split("/")[1], + state: "open", + } as GitHubRepoConnectionState, + state_key: repoRes.url, + }, + avatarUrl, + ], + }; + } + + static helpMessage: HelpFunction; + static botCommands: BotCommands; + + private readonly hookFilter: HookFilter; + + public debounceOnIssueLabeled = new Map< + number, + { labels: Set; timeout: NodeJS.Timeout } + >(); + + private readonly grantChecker; + + constructor( + roomId: string, + private readonly as: Appservice, + private readonly intent: Intent, + state: ConnectionValidatedState, + private readonly tokenStore: UserTokenStore, + stateKey: string, + private readonly githubInstance: GithubInstance, + private readonly config: BridgeConfigGitHub, + ) { + super( + roomId, + stateKey, + GitHubRepoConnection.CanonicalEventType, + state, + intent.underlyingClient, + GitHubRepoConnection.botCommands, + GitHubRepoConnection.helpMessage, + ["github"], + "!gh", + "github", + ); + this.hookFilter = new HookFilter(state.enableHooks); + this.grantChecker = new GitHubGrantChecker(this.as, this.tokenStore); + } + + public get hotlinkIssues() { + const cfg = + this.config.defaultOptions?.hotlinkIssues || this.state.hotlinkIssues; + if (cfg === false) { + return false; } - - public async onStateUpdate(stateEv: MatrixEvent) { - await super.onStateUpdate(stateEv); - this.hookFilter.enabledHooks = this.state.enableHooks; + if (cfg === true || cfg === undefined || cfg.prefix === undefined) { + return { + prefix: DEFAULT_HOTLINK_PREFIX, + }; } - - public isInterestedInStateEvent(eventType: string, stateKey: string) { - return GitHubRepoConnection.EventTypes.includes(eventType) && this.stateKey === stateKey; + return cfg; + } + + public get org() { + return this.state.org.toLowerCase(); + } + + private get showIssueRoomLink() { + return this.state.showIssueRoomLink === undefined + ? this.config.defaultOptions?.showIssueRoomLink || false + : this.state.showIssueRoomLink; + } + + public get repo() { + return this.state.repo.toLowerCase(); + } + + public get priority(): number { + return this.state.priority || super.priority; + } + + public async ensureGrant( + sender?: string, + state = { org: this.org, repo: this.repo }, + ) { + await this.grantChecker.assertConnectionGranted(this.roomId, state, sender); + } + + protected async validateConnectionState(content: unknown) { + const state = GitHubRepoConnection.validateState(content); + // Validate the permissions of this state + await this.ensureGrant(undefined, { org: this.org, repo: this.repo }); + return state; + } + + public async onStateUpdate(stateEv: MatrixEvent) { + await super.onStateUpdate(stateEv); + this.hookFilter.enabledHooks = this.state.enableHooks; + } + + public isInterestedInStateEvent(eventType: string, stateKey: string) { + return ( + GitHubRepoConnection.EventTypes.includes(eventType) && + this.stateKey === stateKey + ); + } + + public async handleIssueHotlink( + ev: MatrixEvent, + ): Promise { + if ( + (ev.content.msgtype !== "m.text" && ev.content.msgtype !== "m.emote") || + this.hotlinkIssues === false + ) { + return false; } - - public async handleIssueHotlink(ev: MatrixEvent): Promise { - if (ev.content.msgtype !== "m.text" && ev.content.msgtype !== "m.emote" || this.hotlinkIssues === false) { - return false; - } - const octokit = this.githubInstance.getSafeOctokitForRepo(this.org, this.repo); - if (!octokit) { - // No octokit for this repo, ignoring - return false; - } - - let eventBody = ev.content.body.trim(); - if (!eventBody) { - return false; - } - // Strip code blocks - eventBody = eventBody.replace(/(?:```|`)[^`]+(?:```|`)/g, ""); - // Strip quotes - eventBody = eventBody.replace(/>.+/g, ""); - const prefix = this.hotlinkIssues.prefix; - - // Simple text search - const regex = new RegExp(`(?:^|\\s)${prefix}(\\d+)(?:$|\\s)`, "gm"); - const result = regex.exec(eventBody); - const issueNumber = result?.[1]; - - if (issueNumber) { - let issue: MinimalGitHubIssue & { repository?: LooseMinimalGitHubRepo, pull_request?: unknown, state: string }; - try { - issue = (await octokit.issues.get({ - repo: this.state.repo, - owner: this.state.org, - issue_number: parseInt(issueNumber), - })).data; - } catch (ex) { - // Failed to fetch the issue, don't handle. - return false; - } - - let message = `${issue.pull_request ? "Pull Request" : "Issue"} [#${issue.number}](${issue.html_url}): ${issue.title} (${issue.state})`; - if (this.showIssueRoomLink) { - message += ` [Issue Room](https://matrix.to/#/${this.as.getAlias(GitHubIssueConnection.generateAliasLocalpart(this.org, this.repo, issue.number))})`; - } - const content = emojify(message); - await this.intent.sendEvent(this.roomId, { - msgtype: "m.notice", - body: content , - formatted_body: md.renderInline(content), - format: "org.matrix.custom.html", - ...(issue.repository ? FormatUtil.getPartialBodyForGithubIssue(issue.repository, issue) : {}), - }); - return true; - } - return false; + const octokit = this.githubInstance.getSafeOctokitForRepo( + this.org, + this.repo, + ); + if (!octokit) { + // No octokit for this repo, ignoring + return false; } - - public async onMessageEvent(ev: MatrixEvent, checkPermission: PermissionCheckFn, reply?: IRichReplyMetadata): Promise { - if (await super.onMessageEvent(ev, checkPermission)) { - return true; - } - const body = ev.content.body?.trim(); - if (reply) { - const repoInfo = reply.realEvent.content["uk.half-shot.matrix-hookshot.github.repo"]; - const pullRequestId = reply.realEvent.content["uk.half-shot.matrix-hookshot.github.pull_request"]?.number; - // Emojis can be multi-byte, so make sure we split properly - const reviewKey = Object.keys(EMOJI_TO_REVIEW_STATE).find( - (k) => k.includes( - body.split(' ')[0] - ) - ); - // Typescript is dumb. - // @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}`); - const [org, owner] = repoInfo.name.split('/'); - const octokit = await this.tokenStore.getOctokitForUser(ev.sender); - try { - await octokit?.pulls.createReview({ - pull_number: pullRequestId, - owner: org, - repo: owner, - body: body.substr(1).trim(), - event: reviewEvent, - }); - } catch (ex) { - await this.intent.underlyingClient.sendEvent(this.roomId, "m.reaction", { - "m.relates_to": { - rel_type: "m.annotation", - event_id: ev.event_id, - key: "⛔", - } - }); - await this.intent.underlyingClient.sendEvent(this.roomId, 'm.room.message', { - msgtype: "m.notice", - body: `Failed to submit review: ${ex.message}`, - }); - } - return true; - } - } - // We might want to do a hotlink. - return await this.handleIssueHotlink(ev); + let eventBody = ev.content.body.trim(); + if (!eventBody) { + return false; } - - @botCommand("create", "Create an issue for this repo", ["title"], ["description", "labels"], true) - public async onCreateIssue(userId: string, title: string, description?: string, labels?: string) { - const octokit = await this.tokenStore.getOctokitForUser(userId); - if (!octokit) { - throw new NotLoggedInError(); - } - const labelsNames = new Set(labels?.split(",")); - if (this.state.newIssue?.labels) { - this.state.newIssue?.labels.forEach(l => labelsNames.add(l)); - } - const res = await octokit.issues.create({ + // Strip code blocks + eventBody = eventBody.replace(/(?:```|`)[^`]+(?:```|`)/g, ""); + // Strip quotes + eventBody = eventBody.replace(/>.+/g, ""); + const prefix = this.hotlinkIssues.prefix; + + // Simple text search + const regex = new RegExp(`(?:^|\\s)${prefix}(\\d+)(?:$|\\s)`, "gm"); + const result = regex.exec(eventBody); + const issueNumber = result?.[1]; + + if (issueNumber) { + let issue: MinimalGitHubIssue & { + repository?: LooseMinimalGitHubRepo; + pull_request?: unknown; + state: string; + }; + try { + issue = ( + await octokit.issues.get({ repo: this.state.repo, owner: this.state.org, - title: title, - body: description, - labels: [...labelsNames], - }); - - return { - reaction: `Issue #${res.data.number}`, + issue_number: parseInt(issueNumber), + }) + ).data; + } catch (ex) { + // Failed to fetch the issue, don't handle. + return false; + } + + let message = `${issue.pull_request ? "Pull Request" : "Issue"} [#${issue.number}](${issue.html_url}): ${issue.title} (${issue.state})`; + if (this.showIssueRoomLink) { + message += ` [Issue Room](https://matrix.to/#/${this.as.getAlias(GitHubIssueConnection.generateAliasLocalpart(this.org, this.repo, issue.number))})`; + } + const content = emojify(message); + await this.intent.sendEvent(this.roomId, { + msgtype: "m.notice", + body: content, + formatted_body: md.renderInline(content), + format: "org.matrix.custom.html", + ...(issue.repository + ? FormatUtil.getPartialBodyForGithubIssue(issue.repository, issue) + : {}), + }); + return true; + } + return false; + } + + public async onMessageEvent( + ev: MatrixEvent, + checkPermission: PermissionCheckFn, + reply?: IRichReplyMetadata, + ): Promise { + if (await super.onMessageEvent(ev, checkPermission)) { + return true; + } + const body = ev.content.body?.trim(); + if (reply) { + const repoInfo = + reply.realEvent.content["uk.half-shot.matrix-hookshot.github.repo"]; + const pullRequestId = + reply.realEvent.content[ + "uk.half-shot.matrix-hookshot.github.pull_request" + ]?.number; + // Emojis can be multi-byte, so make sure we split properly + const reviewKey = Object.keys(EMOJI_TO_REVIEW_STATE).find((k) => + k.includes(body.split(" ")[0]), + ); + // Typescript is dumb. + // @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}`); + const [org, owner] = repoInfo.name.split("/"); + const octokit = await this.tokenStore.getOctokitForUser(ev.sender); + try { + await octokit?.pulls.createReview({ + pull_number: pullRequestId, + owner: org, + repo: owner, + body: body.substr(1).trim(), + event: reviewEvent, + }); + } catch (ex) { + await this.intent.underlyingClient.sendEvent( + this.roomId, + "m.reaction", + { + "m.relates_to": { + rel_type: "m.annotation", + event_id: ev.event_id, + key: "⛔", + }, + }, + ); + await this.intent.underlyingClient.sendEvent( + this.roomId, + "m.room.message", + { + msgtype: "m.notice", + body: `Failed to submit review: ${ex.message}`, + }, + ); } + return true; + } + } + // We might want to do a hotlink. + return await this.handleIssueHotlink(ev); + } + + @botCommand( + "create", + "Create an issue for this repo", + ["title"], + ["description", "labels"], + true, + ) + public async onCreateIssue( + userId: string, + title: string, + description?: string, + labels?: string, + ) { + const octokit = await this.tokenStore.getOctokitForUser(userId); + if (!octokit) { + throw new NotLoggedInError(); + } + const labelsNames = new Set(labels?.split(",")); + if (this.state.newIssue?.labels) { + this.state.newIssue?.labels.forEach((l) => labelsNames.add(l)); + } + const res = await octokit.issues.create({ + repo: this.state.repo, + owner: this.state.org, + title: title, + body: description, + labels: [...labelsNames], + }); + + return { + reaction: `Issue #${res.data.number}`, + }; + } + + @botCommand( + "assign", + "Assign an issue to a user. If `number` is ommitted, the latest issue is used. If `users` is omitted, you are assigned.", + [], + ["number", "...users"], + true, + ) + public async onAssign(userId: string, number?: string, ...users: string[]) { + const octokit = await this.tokenStore.getOctokitForUser(userId); + if (!octokit) { + throw new NotLoggedInError(); } - @botCommand("assign", "Assign an issue to a user. If `number` is ommitted, the latest issue is used. If `users` is omitted, you are assigned.", [], ["number", "...users"], true) - public async onAssign(userId: string, number?: string, ...users: string[]) { - const octokit = await this.tokenStore.getOctokitForUser(userId); - if (!octokit) { - throw new NotLoggedInError(); - } + if (users.length === 1) { + users = users[0].split(","); + } - if (users.length === 1) { - users = users[0].split(","); - } + if (users.length === 0) { + // Assume self. + users = [(await octokit.users.getAuthenticated()).data.login]; + } - if (users.length === 0) { - // Assume self. - users = [(await octokit.users.getAuthenticated()).data.login]; - } + let issueNumber; + if (number === undefined) { + const topIssue = ( + await octokit.issues.listForRepo({ + owner: this.state.org, + repo: this.state.repo, + sort: "created", + direction: "desc", + per_page: 1, + }) + ).data[0]; + if (!topIssue) { + throw new CommandError( + "No issues found", + "There are no issues on this repository", + ); + } + issueNumber = topIssue.number; + } else { + issueNumber = parseInt(number, 10); + } - let issueNumber; - if (number === undefined) { - const topIssue = (await octokit.issues.listForRepo({ - owner: this.state.org, - repo: this.state.repo, - sort: "created", - direction: "desc", - per_page: 1, - })).data[0]; - if (!topIssue) { - throw new CommandError('No issues found', 'There are no issues on this repository'); - } - issueNumber = topIssue.number; - } else { - issueNumber = parseInt(number, 10); - } - - await octokit.issues.addAssignees({ - repo: this.state.repo, - owner: this.state.org, - issue_number: issueNumber, - assignees: users, - }); + await octokit.issues.addAssignees({ + repo: this.state.repo, + owner: this.state.org, + issue_number: issueNumber, + assignees: users, + }); + } + + @botCommand("close", "Close an issue", ["number"], ["comment"], true) + public async onClose(userId: string, number: string, comment?: string) { + const octokit = await this.tokenStore.getOctokitForUser(userId); + if (!octokit) { + throw new NotLoggedInError(); } - @botCommand("close", "Close an issue", ["number"], ["comment"], true) - public async onClose(userId: string, number: string, comment?: string) { - const octokit = await this.tokenStore.getOctokitForUser(userId); - if (!octokit) { - throw new NotLoggedInError(); - } + if (comment) { + await octokit.issues.createComment({ + repo: this.state.repo, + owner: this.state.org, + issue_number: parseInt(number, 10), + body: comment, + }); + } - if (comment) { - await octokit.issues.createComment({ - repo: this.state.repo, - owner: this.state.org, - issue_number: parseInt(number, 10), - body: comment, - }) - } + await octokit.issues.update({ + repo: this.state.repo, + owner: this.state.org, + issue_number: parseInt(number, 10), + state: "closed", + }); + } + + @botCommand( + "workflow run", + "Run a GitHub Actions workflow. Args should be specified in \"key=value,key2='value 2'\" format.", + ["name"], + ["args", "ref"], + true, + ) + public async onWorkflowRun( + userId: string, + name: string, + args?: string, + ref?: string, + ) { + const octokit = await this.tokenStore.getOctokitForUser(userId); + if (!octokit) { + throw new NotLoggedInError(); + } + const workflowArgs: Record = {}; + if (args) { + args.split(",").forEach((arg) => { + const [key, value] = arg.split("="); + workflowArgs[key] = value || ""; + }); + } - await octokit.issues.update({ + const workflows = await octokit.actions.listRepoWorkflows({ + repo: this.state.repo, + owner: this.state.org, + }); + + const workflow = workflows.data.workflows.find( + (w) => w.name.toLowerCase().trim() === name.toLowerCase().trim(), + ); + if (!workflow) { + const workflowNames = workflows.data.workflows + .map((w) => w.name) + .join(", "); + await this.intent.sendText( + this.roomId, + `Could not find a workflow by the name of "${name}". The workflows on this repository are ${workflowNames}.`, + "m.notice", + ); + return; + } + try { + if (!ref) { + ref = ( + await octokit.repos.get({ repo: this.state.repo, owner: this.state.org, - issue_number: parseInt(number, 10), - state: "closed", - }); + }) + ).data.default_branch; + } + } catch (ex) { + throw new CommandError( + ex.message, + `Could not determine default ref (maybe pass one in)`, + ); } - @botCommand("workflow run", "Run a GitHub Actions workflow. Args should be specified in \"key=value,key2='value 2'\" format.", ["name"], ["args", "ref"], true) - public async onWorkflowRun(userId: string, name: string, args?: string, ref?: string) { - const octokit = await this.tokenStore.getOctokitForUser(userId); - if (!octokit) { - throw new NotLoggedInError(); - } - const workflowArgs: Record = {}; - if (args) { - args.split(',').forEach((arg) => { const [key,value] = arg.split('='); workflowArgs[key] = value || "" }); - } + try { + await octokit.actions.createWorkflowDispatch({ + repo: this.state.repo, + owner: this.state.org, + workflow_id: workflow.id, + ref, + inputs: workflowArgs, + }); + } catch (ex) { + const httpError = ex as AxiosError<{ message: string }>; + if (httpError.response?.data) { + throw new CommandError( + httpError.response?.data.message, + httpError.response?.data.message, + ); + } + throw ex; + } - const workflows = await octokit.actions.listRepoWorkflows({ - repo: this.state.repo, - owner: this.state.org, - }); + await this.intent.sendText(this.roomId, `Workflow started.`, "m.notice"); + } - const workflow = workflows.data.workflows.find(w => w.name.toLowerCase().trim() === name.toLowerCase().trim()); - if (!workflow) { - const workflowNames = workflows.data.workflows.map(w => w.name).join(', '); - await this.intent.sendText(this.roomId, `Could not find a workflow by the name of "${name}". The workflows on this repository are ${workflowNames}.`, "m.notice"); - return; - } - try { - if (!ref) { - ref = (await octokit.repos.get({ - repo: this.state.repo, - owner: this.state.org, - })).data.default_branch; - } - } catch (ex) { - throw new CommandError(ex.message, `Could not determine default ref (maybe pass one in)`); - } + public async onIssueCreated(event: IssuesOpenedEvent) { + if ( + this.hookFilter.shouldSkip("issue.created", "issue") || + !this.matchesLabelFilter(event.issue) + ) { + return; + } + log.info( + `onIssueCreated ${this.roomId} ${this.org}/${this.repo} #${event.issue?.number}`, + ); + if (!event.issue) { + throw Error("No issue content!"); + } + if (!event.repository) { + throw Error("No repository content!"); + } + const orgRepoName = event.repository.full_name; + + 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.assignees.length + ? ` assigned to ${event.issue.assignees.map((a) => a.login).join(", ")}` + : ""; + if (this.showIssueRoomLink) { + const appInstance = await this.githubInstance.getSafeOctokitForRepo( + this.org, + this.repo, + ); + if (appInstance) { + message += ` [Issue Room](https://matrix.to/#/${this.as.getAlias(GitHubIssueConnection.generateAliasLocalpart(this.org, this.repo, event.issue.number))})`; + } else { + log.warn( + `Cannot show issue room link, no app install for ${orgRepoName}`, + ); + } + } + 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", + body: + content + + (labels.plain.length > 0 ? ` with labels ${labels.plain}` : ""), + formatted_body: + md.renderInline(content) + + (labels.html.length > 0 ? ` with labels ${labels.html}` : ""), + format: "org.matrix.custom.html", + ...FormatUtil.getPartialBodyForGithubIssue(event.repository, event.issue), + }); + } + + public async onIssueCommentCreated(event: IssueCommentCreatedEvent) { + if ( + this.hookFilter.shouldSkip("issue.comment.created", "issue.comment") || + !this.matchesLabelFilter(event.issue) + ) { + return; + } + 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", + body: message, + formatted_body: md.renderInline(message), + format: "org.matrix.custom.html", + ...FormatUtil.getPartialBodyForGithubIssue(event.repository, event.issue), + external_url: event.issue.html_url, + }); + } + + public async onIssueStateChange( + event: IssuesEditedEvent | IssuesReopenedEvent | IssuesClosedEvent, + ) { + if ( + this.hookFilter.shouldSkip("issue.changed", "issue") || + !this.matchesLabelFilter(event.issue) + ) { + return; + } + log.info( + `onIssueStateChange ${this.roomId} ${this.org}/${this.repo} #${event.issue?.number}`, + ); + if (!event.issue) { + throw Error("No issue content!"); + } + if (!event.repository) { + throw Error("No repository content!"); + } + const state = event.issue.state === "open" ? "reopened" : "closed"; + const orgRepoName = event.repository.full_name; + let withComment = ""; + if (state === "reopened" || state === "closed") { + const octokit = this.githubInstance.getSafeOctokitForRepo( + this.org, + this.repo, + ); + if (octokit) { try { - await octokit.actions.createWorkflowDispatch({ - repo: this.state.repo, - owner: this.state.org, - workflow_id: workflow.id, - ref, - inputs: workflowArgs, - }); + const comments = await octokit.issues.listComments({ + owner: this.org, + repo: this.repo, + issue_number: event.issue.number, + // Get comments from the 2 minutes. + since: new Date(Date.now() - 2 * 60000).toISOString(), + }); + const [comment] = comments.data + .filter((c) => c.user?.login === event.sender.login) + .sort( + (a, b) => Date.parse(b.created_at) - Date.parse(a.created_at), + ); + if (comment) { + withComment = ` with comment "${comment.body}"`; + } } catch (ex) { - const httpError = ex as AxiosError<{message: string}>; - if (httpError.response?.data) { - throw new CommandError(httpError.response?.data.message, httpError.response?.data.message); - } - throw ex; + log.warn( + `Failed to get previous comments for closed / reopened issue.`, + ex, + ); } - - await this.intent.sendText(this.roomId, `Workflow started.`, "m.notice"); + } } - - public async onIssueCreated(event: IssuesOpenedEvent) { - if (this.hookFilter.shouldSkip('issue.created', 'issue') || !this.matchesLabelFilter(event.issue)) { - return; - } - log.info(`onIssueCreated ${this.roomId} ${this.org}/${this.repo} #${event.issue?.number}`); - if (!event.issue) { - throw Error('No issue content!'); - } - if (!event.repository) { - throw Error('No repository content!'); - } - const orgRepoName = event.repository.full_name; - - 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.assignees.length ? ` assigned to ${event.issue.assignees.map(a => a.login).join(', ')}` : ''); - if (this.showIssueRoomLink) { - const appInstance = await this.githubInstance.getSafeOctokitForRepo(this.org, this.repo); - if (appInstance) { - message += ` [Issue Room](https://matrix.to/#/${this.as.getAlias(GitHubIssueConnection.generateAliasLocalpart(this.org, this.repo, event.issue.number))})`; - } else { - log.warn(`Cannot show issue room link, no app install for ${orgRepoName}`); - } - } - 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", - body: content + (labels.plain.length > 0 ? ` with labels ${labels.plain}`: ""), - formatted_body: md.renderInline(content) + (labels.html.length > 0 ? ` with labels ${labels.html}`: ""), - format: "org.matrix.custom.html", - ...FormatUtil.getPartialBodyForGithubIssue(event.repository, event.issue), - }); + 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, + formatted_body: md.renderInline(content), + format: "org.matrix.custom.html", + ...FormatUtil.getPartialBodyForGithubIssue(event.repository, event.issue), + }); + } + + public async onIssueEdited(event: IssuesEditedEvent) { + if ( + this.hookFilter.shouldSkip("issue.edited", "issue") || + !this.matchesLabelFilter(event.issue) + ) { + return; } - - public async onIssueCommentCreated(event: IssueCommentCreatedEvent) { - if (this.hookFilter.shouldSkip('issue.comment.created', 'issue.comment') || !this.matchesLabelFilter(event.issue)) { - return; - } - - 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", - body: message, - formatted_body: md.renderInline(message), - format: "org.matrix.custom.html", - ...FormatUtil.getPartialBodyForGithubIssue(event.repository, event.issue), - external_url: event.issue.html_url, - }); + if (!event.issue) { + throw Error("No issue content!"); + } + log.info( + `onIssueEdited ${this.roomId} ${this.org}/${this.repo} #${event.issue.number}`, + ); + const orgRepoName = event.repository.full_name; + 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, + formatted_body: md.renderInline(content), + format: "org.matrix.custom.html", + ...FormatUtil.getPartialBodyForGithubIssue(event.repository, event.issue), + }); + } + + public async onIssueLabeled(event: IssuesLabeledEvent) { + if ( + this.hookFilter.shouldSkip("issue.labeled", "issue") || + !event.label || + !this.state.includingLabels?.length + ) { + return; } - public async onIssueStateChange(event: IssuesEditedEvent|IssuesReopenedEvent|IssuesClosedEvent) { - if (this.hookFilter.shouldSkip('issue.changed', 'issue') || !this.matchesLabelFilter(event.issue)) { - return; - } - log.info(`onIssueStateChange ${this.roomId} ${this.org}/${this.repo} #${event.issue?.number}`); - if (!event.issue) { - throw Error('No issue content!'); - } - if (!event.repository) { - throw Error('No repository content!'); - } - const state = event.issue.state === 'open' ? 'reopened' : 'closed'; - const orgRepoName = event.repository.full_name; - let withComment = ""; - if (state === 'reopened' || state === 'closed') { - const octokit = this.githubInstance.getSafeOctokitForRepo(this.org, this.repo); - if (octokit) { - try { - const comments = await octokit.issues.listComments({ - owner: this.org, - repo: this.repo, - issue_number: event.issue.number, - // Get comments from the 2 minutes. - since: new Date(Date.now() - (2 * 60000)).toISOString(), - }); - const [comment] = comments.data.filter((c) => c.user?.login === event.sender.login).sort( - (a,b) => Date.parse(b.created_at) - Date.parse(a.created_at) - ); - if (comment) { - withComment = ` with comment "${comment.body}"`; - } - } catch (ex) { - log.warn(`Failed to get previous comments for closed / reopened issue.`, ex); - } - } - } - 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, - formatted_body: md.renderInline(content), - format: "org.matrix.custom.html", - ...FormatUtil.getPartialBodyForGithubIssue(event.repository, event.issue), - }); + // We don't want to send this message if we're also sending a created message + if ( + Date.now() - new Date(event.issue.created_at).getTime() < + CREATED_GRACE_PERIOD_MS + ) { + return; } - public async onIssueEdited(event: IssuesEditedEvent) { - if (this.hookFilter.shouldSkip('issue.edited', 'issue') || !this.matchesLabelFilter(event.issue)) { - return; - } - if (!event.issue) { - throw Error('No issue content!'); - } - log.info(`onIssueEdited ${this.roomId} ${this.org}/${this.repo} #${event.issue.number}`); - const orgRepoName = event.repository.full_name; - 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, - formatted_body: md.renderInline(content), - format: "org.matrix.custom.html", - ...FormatUtil.getPartialBodyForGithubIssue(event.repository, event.issue), + log.info( + `onIssueLabeled ${this.roomId} ${this.org}/${this.repo} #${event.issue.id} ${event.label.name}`, + ); + const renderFn = () => { + const { labels } = this.debounceOnIssueLabeled.get(event.issue.id) || { + labels: [], + }; + this.debounceOnIssueLabeled.delete(event.issue.id); + // Only render if we *explicitly* want it. + if ( + ![...labels.values()]?.find((l) => + this.state.includingLabels?.includes(l), + ) + ) { + // Not interested. + return; + } + 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 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}` : ""), + formatted_body: + md.renderInline(content) + + (html.length > 0 ? ` with labels ${html}` : ""), + format: "org.matrix.custom.html", + ...FormatUtil.getPartialBodyForGithubIssue( + event.repository, + event.issue, + ), + }) + .catch((ex) => { + log.error("Failed to send onIssueLabeled message", ex); }); + }; + const existing = this.debounceOnIssueLabeled.get(event.issue.id); + if (existing) { + clearTimeout(existing.timeout); + existing.labels.add(event.label.name); + existing.timeout = setTimeout(renderFn, LABELED_DEBOUNCE_MS); + } else { + this.debounceOnIssueLabeled.set(event.issue.id, { + labels: new Set([event.label.name]), + timeout: setTimeout(renderFn, LABELED_DEBOUNCE_MS), + }); } - - public async onIssueLabeled(event: IssuesLabeledEvent) { - if (this.hookFilter.shouldSkip('issue.labeled', 'issue') || !event.label || !this.state.includingLabels?.length) { - return; - } - - // We don't want to send this message if we're also sending a created message - if (Date.now() - new Date(event.issue.created_at).getTime() < CREATED_GRACE_PERIOD_MS) { - return; - } - - log.info(`onIssueLabeled ${this.roomId} ${this.org}/${this.repo} #${event.issue.id} ${event.label.name}`); - const renderFn = () => { - const {labels} = this.debounceOnIssueLabeled.get(event.issue.id) || { labels: [] }; - this.debounceOnIssueLabeled.delete(event.issue.id); - // Only render if we *explicitly* want it. - if (![...labels.values()]?.find(l => this.state.includingLabels?.includes(l))) { - // Not interested. - return; - } - 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 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}`: ""), - formatted_body: md.renderInline(content) + (html.length > 0 ? ` with labels ${html}`: ""), - format: "org.matrix.custom.html", - ...FormatUtil.getPartialBodyForGithubIssue(event.repository, event.issue), - }).catch(ex => { - log.error('Failed to send onIssueLabeled message', ex); - }); - - }; - const existing = this.debounceOnIssueLabeled.get(event.issue.id); - if (existing) { - clearTimeout(existing.timeout); - existing.labels.add(event.label.name); - existing.timeout = setTimeout(renderFn, LABELED_DEBOUNCE_MS); - } else { - this.debounceOnIssueLabeled.set(event.issue.id, { - labels: new Set([event.label.name]), - timeout: setTimeout(renderFn, LABELED_DEBOUNCE_MS), - }) - } + } + + public onIssueUnlabeled(data: IssuesUnlabeledEvent) { + log.info( + `onIssueUnlabeled ${this.roomId} ${this.org}/${this.repo} #${data.issue.id} ${data.label?.name}`, + ); + const existing = this.debounceOnIssueLabeled.get(data.issue.id); + if (existing && data.label) { + existing.labels.delete(data.label.name); } + } - public onIssueUnlabeled(data: IssuesUnlabeledEvent) { - log.info(`onIssueUnlabeled ${this.roomId} ${this.org}/${this.repo} #${data.issue.id} ${data.label?.name}`); - const existing = this.debounceOnIssueLabeled.get(data.issue.id); - if (existing && data.label) { - existing.labels.delete(data.label.name); - } + public async onPROpened(event: PullRequestOpenedEvent) { + if ( + this.hookFilter.shouldSkip("pull_request.opened", "pull_request") || + !this.matchesLabelFilter(event.pull_request) + ) { + return; } - - public async onPROpened(event: PullRequestOpenedEvent) { - if (this.hookFilter.shouldSkip('pull_request.opened', 'pull_request') || !this.matchesLabelFilter(event.pull_request)) { - return; - } - log.info(`onPROpened ${this.roomId} ${this.org}/${this.repo} #${event.pull_request.number}`); - if (!event.pull_request) { - throw Error('No pull_request content!'); - } - if (!event.repository) { - throw Error('No repository content!'); - } - const orgRepoName = event.repository.full_name; - const verb = event.pull_request.draft ? 'drafted' : 'opened'; - let diffContent = ''; - let diffContentHtml = ''; - if (this.state.prDiff?.enabled) { - const maxDiffLen = this.state.prDiff.maxLines || 30; - const diff = await axios.get(event.pull_request.diff_url, { responseType: 'text'}); - if (diff.data.split('/n').length <= maxDiffLen) { - // Markdown renderer wasn't handling this well, so for now hack around ourselves - diffContent = "\n``` diff\n" + diff.data + "\n```"; - diffContentHtml = `\n
${diff.data}\n
`; - } - } - 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", - body: content + (labels.plain.length > 0 ? ` with labels ${labels}`: "") + diffContent, - formatted_body: md.renderInline(content) + (labels.html.length > 0 ? ` with labels ${labels.html}`: "") + diffContentHtml, - format: "org.matrix.custom.html", - ...FormatUtil.getPartialBodyForGithubIssue(event.repository, event.pull_request), - ...FormatUtil.getPartialBodyForGitHubPR(event.repository, event.pull_request), - }); + log.info( + `onPROpened ${this.roomId} ${this.org}/${this.repo} #${event.pull_request.number}`, + ); + if (!event.pull_request) { + throw Error("No pull_request content!"); } - - public async onPRReadyForReview(event: PullRequestReadyForReviewEvent) { - if (this.hookFilter.shouldSkip('pull_request.ready_for_review', 'pull_request') || !this.matchesLabelFilter(event.pull_request)) { - return; - } - log.info(`onPRReadyForReview ${this.roomId} ${this.org}/${this.repo} #${event.pull_request.number}`); - if (!event.pull_request) { - throw Error('No pull_request content!'); - } - if (!event.repository) { - throw Error('No repository content!'); - } - const orgRepoName = event.repository.full_name; - 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, - formatted_body: md.renderInline(content), - format: "org.matrix.custom.html", - // TODO: Fix types. - ...FormatUtil.getPartialBodyForGithubIssue(event.repository, event.pull_request), - }); + if (!event.repository) { + throw Error("No repository content!"); } - - public async onPRReviewed(event: PullRequestReviewSubmittedEvent) { - if (this.hookFilter.shouldSkip('pull_request.reviewed', 'pull_request') || !this.matchesLabelFilter(event.pull_request)) { - return; - } - log.info(`onPRReadyForReview ${this.roomId} ${this.org}/${this.repo} #${event.pull_request.number}`); - if (!event.pull_request) { - throw Error('No pull_request content!'); - } - if (!event.repository) { - throw Error('No repository content!'); - } - const orgRepoName = event.repository.full_name; - const emojiForReview = { - 'approved': '✅', - // This apparently fires each time someone comments on the PR, which is not helpful. - // 'commented': '🗨️', - 'changes_requested': '🔴' - }[event.review.state.toLowerCase()]; - if (!emojiForReview) { - // We don't recongnise this state, run away! - return; - } - 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, - formatted_body: md.renderInline(content), - format: "org.matrix.custom.html", - // TODO: Fix types. - ...FormatUtil.getPartialBodyForGithubIssue(event.repository, event.pull_request), - }); + const orgRepoName = event.repository.full_name; + const verb = event.pull_request.draft ? "drafted" : "opened"; + let diffContent = ""; + let diffContentHtml = ""; + if (this.state.prDiff?.enabled) { + const maxDiffLen = this.state.prDiff.maxLines || 30; + const diff = await axios.get(event.pull_request.diff_url, { + responseType: "text", + }); + if (diff.data.split("/n").length <= maxDiffLen) { + // Markdown renderer wasn't handling this well, so for now hack around ourselves + diffContent = "\n``` diff\n" + diff.data + "\n```"; + diffContentHtml = `\n
${diff.data}\n
`; + } } - - public async onPRClosed(event: PullRequestClosedEvent) { - if (this.hookFilter.shouldSkip('pull_request.closed', 'pull_request') || !this.matchesLabelFilter(event.pull_request)) { - return; - } - log.info(`onPRClosed ${this.roomId} ${this.org}/${this.repo} #${event.pull_request.number}`); - if (!event.pull_request) { - throw Error('No pull_request content!'); - } - if (!event.repository) { - throw Error('No repository content!'); - } - const orgRepoName = event.repository.full_name; - const verb = event.pull_request.merged ? 'merged' : 'closed'; - let withComment = ""; - const octokit = this.githubInstance.getSafeOctokitForRepo(this.org, this.repo); - if (verb == "closed" && octokit) { - try { - const comments = await octokit.issues.listComments({ - owner: this.org, - repo: this.repo, - issue_number: event.pull_request.number, - // Get comments from the 2 minutes. - since: new Date(Date.now() - (2 * 60000)).toISOString(), - }); - const [comment] = comments.data.filter((c) => c.user?.login === event.sender.login).sort( - (a,b) => Date.parse(b.created_at) - Date.parse(a.created_at) - ); - if (comment) { - withComment = ` with comment "${comment.body}"`; - } - } catch (ex) { - log.warn(`Failed to get previous comments for closed / reopened issue.`, ex); - } - } - - 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, - formatted_body: md.renderInline(content), - format: "org.matrix.custom.html", - // TODO: Fix types. - ...FormatUtil.getPartialBodyForGithubIssue(event.repository, event.pull_request), - }); + 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", + body: + content + + (labels.plain.length > 0 ? ` with labels ${labels}` : "") + + diffContent, + formatted_body: + md.renderInline(content) + + (labels.html.length > 0 ? ` with labels ${labels.html}` : "") + + diffContentHtml, + format: "org.matrix.custom.html", + ...FormatUtil.getPartialBodyForGithubIssue( + event.repository, + event.pull_request, + ), + ...FormatUtil.getPartialBodyForGitHubPR( + event.repository, + event.pull_request, + ), + }); + } + + public async onPRReadyForReview(event: PullRequestReadyForReviewEvent) { + if ( + this.hookFilter.shouldSkip( + "pull_request.ready_for_review", + "pull_request", + ) || + !this.matchesLabelFilter(event.pull_request) + ) { + return; } - - public async onReleaseCreated(event: ReleasePublishedEvent) { - // This checks `release.created` despite the function being called onReleasePublished - // because historically release.created used to refer to all releases (rather than just published ones). - // This is now considered an *unsafe* default, so hookshot now treats release.created - // as published. - if (this.hookFilter.shouldSkip('release', 'release.created')) { - return; - } - log.info(`onReleaseCreated ${this.roomId} ${this.org}/${this.repo} #${event.release.tag_name}`); - if (!event.release) { - throw Error('No release content!'); - } - if (!event.repository) { - throw Error('No repository content!'); - } - const orgRepoName = event.repository.full_name; - 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}` - } - await this.intent.sendEvent(this.roomId, { - msgtype: "m.notice", - body: content, - formatted_body: md.render(content), - format: "org.matrix.custom.html", - }); + log.info( + `onPRReadyForReview ${this.roomId} ${this.org}/${this.repo} #${event.pull_request.number}`, + ); + if (!event.pull_request) { + throw Error("No pull_request content!"); } - - public async onReleaseDrafted(event: ReleaseCreatedEvent) { - // This function handles release.created events but published releases are handled by the above function, - // therefore this only handles drafted releases. - if (this.hookFilter.shouldSkip('release', 'release.drafted') || !event.release.draft) { - return; - } - log.info(`onReleaseDrafted ${this.roomId} ${this.org}/${this.repo} #${event.release.tag_name}`); - if (!event.release) { - throw Error('No release content!'); - } - if (!event.repository) { - throw Error('No repository content!'); - } - const icon = '📝'; - const orgRepoName = event.repository.full_name; - 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}` - } - await this.intent.sendEvent(this.roomId, { - msgtype: "m.notice", - body: content, - formatted_body: md.render(content), - format: "org.matrix.custom.html", - }); + if (!event.repository) { + throw Error("No repository content!"); } - - public async onWorkflowCompleted(event: WorkflowRunCompletedEvent) { - const workflowRun = event.workflow_run; - const workflowName = event.workflow_run.name; - const workflowRunType = `workflow.run.${workflowRun.conclusion}`; - // Type safety checked above. - if ( - this.hookFilter.shouldSkip('workflow', 'workflow.run', workflowRunType as AllowedEventsNames)) { - return; - } - - if (this.state.workflowRun?.matchingBranch && !workflowRun.head_branch.match(this.state.workflowRun?.matchingBranch)) { - return; - } - - // Workflow filtering - if (this.state.workflowRun?.excludingWorkflows?.includes(workflowName)) { - return; - } - if (this.state.workflowRun?.includingWorkflows && !this.state.workflowRun.includingWorkflows.includes(workflowName)) { - return; - } - - log.info(`onWorkflowCompleted ${this.roomId} ${this.org}/${this.repo} '${workflowRun.id}'`); - const orgRepoName = event.repository.full_name; - 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, - formatted_body: md.render(content), - format: "org.matrix.custom.html", - }); + const orgRepoName = event.repository.full_name; + 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, + formatted_body: md.renderInline(content), + format: "org.matrix.custom.html", + // TODO: Fix types. + ...FormatUtil.getPartialBodyForGithubIssue( + event.repository, + event.pull_request, + ), + }); + } + + public async onPRReviewed(event: PullRequestReviewSubmittedEvent) { + if ( + this.hookFilter.shouldSkip("pull_request.reviewed", "pull_request") || + !this.matchesLabelFilter(event.pull_request) + ) { + return; } - - public async onEvent(evt: MatrixEvent) { - const octokit = await this.tokenStore.getOctokitForUser(evt.sender); - if (!octokit) { - return; - } - if (evt.type === 'm.reaction') { - const {event_id, key} = (evt.content as MatrixReactionContent)["m.relates_to"]; - const ev = await this.intent.underlyingClient.getEvent(this.roomId, event_id); - const issueContent = ev.content["uk.half-shot.matrix-hookshot.github.issue"]; - if (!issueContent) { - log.debug('Reaction to event did not pertain to a issue'); - return; // Not our event. - } - - const [,reactionName] = Object.entries(GITHUB_REACTION_CONTENT).find(([emoji]) => compareEmojiStrings(emoji, key)) || []; - const [,action] = Object.entries(ALLOWED_REACTIONS).find(([emoji]) => compareEmojiStrings(emoji, key)) || []; - if (reactionName) { - log.info(`Sending reaction of ${reactionName} for ${this.org}${this.repo}#${issueContent.number}`) - await octokit.request('POST /repos/{owner}/{repo}/issues/{issue_number}/reactions', { - owner: this.org, - repo: this.repo, - issue_number: issueContent.number, - content: reactionName as ReactionOptions, - mediaType: { - previews: [ - // Needed as this is a preview - 'squirrel-girl' - ] - } - }); - } else if (action && action === "close") { - await octokit.issues.update({ - state: "closed", - owner: this.org, - repo: this.repo, - issue_number: issueContent.number, - }); - } else if (action && action === "open") { - await octokit.issues.update({ - state: "open", - owner: this.org, - repo: this.repo, - issue_number: issueContent.number, - }); - } + log.info( + `onPRReadyForReview ${this.roomId} ${this.org}/${this.repo} #${event.pull_request.number}`, + ); + if (!event.pull_request) { + throw Error("No pull_request content!"); + } + if (!event.repository) { + throw Error("No repository content!"); + } + const orgRepoName = event.repository.full_name; + const emojiForReview = { + approved: "✅", + // This apparently fires each time someone comments on the PR, which is not helpful. + // 'commented': '🗨️', + changes_requested: "🔴", + }[event.review.state.toLowerCase()]; + if (!emojiForReview) { + // We don't recongnise this state, run away! + return; + } + 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, + formatted_body: md.renderInline(content), + format: "org.matrix.custom.html", + // TODO: Fix types. + ...FormatUtil.getPartialBodyForGithubIssue( + event.repository, + event.pull_request, + ), + }); + } + + public async onPRClosed(event: PullRequestClosedEvent) { + if ( + this.hookFilter.shouldSkip("pull_request.closed", "pull_request") || + !this.matchesLabelFilter(event.pull_request) + ) { + return; + } + log.info( + `onPRClosed ${this.roomId} ${this.org}/${this.repo} #${event.pull_request.number}`, + ); + if (!event.pull_request) { + throw Error("No pull_request content!"); + } + if (!event.repository) { + throw Error("No repository content!"); + } + const orgRepoName = event.repository.full_name; + const verb = event.pull_request.merged ? "merged" : "closed"; + let withComment = ""; + const octokit = this.githubInstance.getSafeOctokitForRepo( + this.org, + this.repo, + ); + if (verb == "closed" && octokit) { + try { + const comments = await octokit.issues.listComments({ + owner: this.org, + repo: this.repo, + issue_number: event.pull_request.number, + // Get comments from the 2 minutes. + since: new Date(Date.now() - 2 * 60000).toISOString(), + }); + const [comment] = comments.data + .filter((c) => c.user?.login === event.sender.login) + .sort((a, b) => Date.parse(b.created_at) - Date.parse(a.created_at)); + if (comment) { + withComment = ` with comment "${comment.body}"`; } + } catch (ex) { + log.warn( + `Failed to get previous comments for closed / reopened issue.`, + ex, + ); + } } - public async onPush(event: PushEvent) { - if (this.hookFilter.shouldSkip('push')) { - return; - } - - const content = `**${event.sender.login}** pushed [${event.commits.length} commit${event.commits.length === 1 ? '' : 's'}](${event.compare}) to \`${event.ref}\` for ${event.repository.full_name}`; - const eventContent: IPushEventContent = { - ...FormatUtil.getPartialBodyForGithubRepo(event.repository), - external_url: event.compare, - "uk.half-shot.matrix-hookshot.github.push": { - commits: event.commits.map(c => c.id), - pusher: `${event.pusher.name} <${event.pusher.email}>`, - ref: event.ref, - base_ref: event.base_ref, - }, - msgtype: "m.notice", - body: content, - formatted_body: md.render(content), - format: "org.matrix.custom.html", - }; - await this.intent.sendEvent(this.roomId, eventContent); + 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, + formatted_body: md.renderInline(content), + format: "org.matrix.custom.html", + // TODO: Fix types. + ...FormatUtil.getPartialBodyForGithubIssue( + event.repository, + event.pull_request, + ), + }); + } + + public async onReleaseCreated(event: ReleasePublishedEvent) { + // This checks `release.created` despite the function being called onReleasePublished + // because historically release.created used to refer to all releases (rather than just published ones). + // This is now considered an *unsafe* default, so hookshot now treats release.created + // as published. + if (this.hookFilter.shouldSkip("release", "release.created")) { + return; + } + log.info( + `onReleaseCreated ${this.roomId} ${this.org}/${this.repo} #${event.release.tag_name}`, + ); + if (!event.release) { + throw Error("No release content!"); + } + if (!event.repository) { + throw Error("No repository content!"); + } + const orgRepoName = event.repository.full_name; + 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}`; + } + await this.intent.sendEvent(this.roomId, { + msgtype: "m.notice", + body: content, + formatted_body: md.render(content), + format: "org.matrix.custom.html", + }); + } + + public async onReleaseDrafted(event: ReleaseCreatedEvent) { + // This function handles release.created events but published releases are handled by the above function, + // therefore this only handles drafted releases. + if ( + this.hookFilter.shouldSkip("release", "release.drafted") || + !event.release.draft + ) { + return; + } + log.info( + `onReleaseDrafted ${this.roomId} ${this.org}/${this.repo} #${event.release.tag_name}`, + ); + if (!event.release) { + throw Error("No release content!"); + } + if (!event.repository) { + throw Error("No repository content!"); + } + const icon = "📝"; + const orgRepoName = event.repository.full_name; + 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}`; + } + await this.intent.sendEvent(this.roomId, { + msgtype: "m.notice", + body: content, + formatted_body: md.render(content), + format: "org.matrix.custom.html", + }); + } + + public async onWorkflowCompleted(event: WorkflowRunCompletedEvent) { + const workflowRun = event.workflow_run; + const workflowName = event.workflow_run.name; + const workflowRunType = `workflow.run.${workflowRun.conclusion}`; + // Type safety checked above. + if ( + this.hookFilter.shouldSkip( + "workflow", + "workflow.run", + workflowRunType as AllowedEventsNames, + ) + ) { + return; } - public toString() { - return `GitHubRepo ${this.org}/${this.repo}`; + if ( + this.state.workflowRun?.matchingBranch && + !workflowRun.head_branch.match(this.state.workflowRun?.matchingBranch) + ) { + return; } - public static getProvisionerDetails(botUserId: string) { - return { - service: "github", - eventType: GitHubRepoConnection.CanonicalEventType, - type: "GithubRepo", - // TODO: Add ability to configure the bot per connnection type. - botUserId: botUserId, - } + // Workflow filtering + if (this.state.workflowRun?.excludingWorkflows?.includes(workflowName)) { + return; + } + if ( + this.state.workflowRun?.includingWorkflows && + !this.state.workflowRun.includingWorkflows.includes(workflowName) + ) { + return; } - public getProvisionerDetails(): GitHubRepoResponseItem { - return { - ...GitHubRepoConnection.getProvisionerDetails(this.intent.userId), - id: this.connectionId, - config: { - ...this.state, + log.info( + `onWorkflowCompleted ${this.roomId} ${this.org}/${this.repo} '${workflowRun.id}'`, + ); + const orgRepoName = event.repository.full_name; + 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, + formatted_body: md.render(content), + format: "org.matrix.custom.html", + }); + } + + public async onEvent(evt: MatrixEvent) { + const octokit = await this.tokenStore.getOctokitForUser(evt.sender); + if (!octokit) { + return; + } + if (evt.type === "m.reaction") { + const { event_id, key } = (evt.content as MatrixReactionContent)[ + "m.relates_to" + ]; + const ev = await this.intent.underlyingClient.getEvent( + this.roomId, + event_id, + ); + const issueContent = + ev.content["uk.half-shot.matrix-hookshot.github.issue"]; + if (!issueContent) { + log.debug("Reaction to event did not pertain to a issue"); + return; // Not our event. + } + + const [, reactionName] = + Object.entries(GITHUB_REACTION_CONTENT).find(([emoji]) => + compareEmojiStrings(emoji, key), + ) || []; + const [, action] = + Object.entries(ALLOWED_REACTIONS).find(([emoji]) => + compareEmojiStrings(emoji, key), + ) || []; + if (reactionName) { + log.info( + `Sending reaction of ${reactionName} for ${this.org}${this.repo}#${issueContent.number}`, + ); + await octokit.request( + "POST /repos/{owner}/{repo}/issues/{issue_number}/reactions", + { + owner: this.org, + repo: this.repo, + issue_number: issueContent.number, + content: reactionName as ReactionOptions, + mediaType: { + previews: [ + // Needed as this is a preview + "squirrel-girl", + ], }, - } + }, + ); + } else if (action && action === "close") { + await octokit.issues.update({ + state: "closed", + owner: this.org, + repo: this.repo, + issue_number: issueContent.number, + }); + } else if (action && action === "open") { + await octokit.issues.update({ + state: "open", + owner: this.org, + repo: this.repo, + issue_number: issueContent.number, + }); + } } - - public static async searchInstallationForRepos(octokit: Octokit, orgName: string, installationId: number, searchTerms?: string) { - // First, do a search on GitHub for repos. This will use the user's context so it will find user repos. - let searchRepos: string[]|null = null; - if (searchTerms) { - const terms = encodeURIComponent(searchTerms); - const searchResultsData = (await octokit.search.repos({ - q: `${terms} org:${orgName} `, - per_page: MAX_RETURNED_TARGETS, - })).data; - if (searchResultsData.total_count === 0) { - return []; - } - searchRepos = searchResultsData.items.map(r => r.full_name); - } + } - // Now, find all the repos that we have the ability to install. - const foundRepos = []; - let installationsCount = 0; - let totalCount = 0; - let page = 1; - do { - const { data } = await octokit.apps.listInstallationReposForAuthenticatedUser({ - installation_id: installationId, - page, - per_page: 100, - }); - // No results, so stop trying. - if (data.repositories.length === 0) { - break; - } - page++; - installationsCount += data.repositories.length; - totalCount = data.total_count; - // Find any repos that were in our search results. If a search term isn't defined, just return it. - foundRepos.push(...data.repositories.filter((installRepo) => searchRepos?.includes(installRepo.full_name) ?? true)); - } while ( - installationsCount < totalCount && - foundRepos.length < (searchRepos?.length ?? MAX_RETURNED_TARGETS) - ) - return foundRepos; + public async onPush(event: PushEvent) { + if (this.hookFilter.shouldSkip("push")) { + return; } - public static async getConnectionTargets(userId: string, tokenStore: UserTokenStore, githubInstance: GithubInstance, filters: GitHubTargetFilter = {}): Promise { - // Search for all repos under the user's control. - const octokit = await tokenStore.getOctokitForUser(userId); - if (!octokit) { - throw new ApiError("User is not authenticated with GitHub", ErrCode.ForbiddenUser); - } - - if (!filters.orgName) { - const results: GitHubRepoConnectionOrgTarget[] = []; - try { - const installs = await octokit.apps.listInstallationsForAuthenticatedUser(); - for (const install of installs.data.installations) { - if (install.account) { - results.push({ - name: getNameForGitHubAccount(install.account), // org or user name - }); - } else { - log.debug(`Skipping install ${install.id}, has no attached account`); - } - } - } catch (ex) { - log.warn(`Failed to fetch orgs for GitHub user ${userId}`, ex); - throw new ApiError("Could not fetch orgs for GitHub user", ErrCode.AdditionalActionRequired); - } - return results; - } - // If we have an instance, search under it. - const ownSelf = await octokit.users.getAuthenticated(); - - try { - let installationId; - if (ownSelf.data.login === filters.orgName) { - installationId = (await githubInstance.appOctokit.apps.getUserInstallation({ username: ownSelf.data.login })).data.id; - } else { - installationId = (await githubInstance.appOctokit.apps.getOrgInstallation({ org: filters.orgName })).data.id; - // Github will error if the authed user tries to list repos of a disallowed installation, even - // if we got the installation ID from the app's instance. - } - const reposRes = await this.searchInstallationForRepos(octokit, filters.orgName, installationId, filters.search); - return reposRes - .map(r => ({ - state: { - org: filters.orgName, - repo: r.name, - }, - name: r.name, - description: r.description, - avatar: r.owner.avatar_url, - })) as GitHubRepoConnectionRepoTarget[]; - } catch (ex) { - log.warn(`Failed to fetch accessible repos for ${filters.orgName} / ${userId}`, ex); - throw new ApiError("Could not fetch accessible repos for GitHub org", ErrCode.AdditionalActionRequired); - } + const content = `**${event.sender.login}** pushed [${event.commits.length} commit${event.commits.length === 1 ? "" : "s"}](${event.compare}) to \`${event.ref}\` for ${event.repository.full_name}`; + const eventContent: IPushEventContent = { + ...FormatUtil.getPartialBodyForGithubRepo(event.repository), + external_url: event.compare, + "uk.half-shot.matrix-hookshot.github.push": { + commits: event.commits.map((c) => c.id), + pusher: `${event.pusher.name} <${event.pusher.email}>`, + ref: event.ref, + base_ref: event.base_ref, + }, + msgtype: "m.notice", + body: content, + formatted_body: md.render(content), + format: "org.matrix.custom.html", + }; + await this.intent.sendEvent(this.roomId, eventContent); + } + + public toString() { + return `GitHubRepo ${this.org}/${this.repo}`; + } + + public static getProvisionerDetails(botUserId: string) { + return { + service: "github", + eventType: GitHubRepoConnection.CanonicalEventType, + type: "GithubRepo", + // TODO: Add ability to configure the bot per connnection type. + botUserId: botUserId, + }; + } + + public getProvisionerDetails(): GitHubRepoResponseItem { + return { + ...GitHubRepoConnection.getProvisionerDetails(this.intent.userId), + id: this.connectionId, + config: { + ...this.state, + }, + }; + } + + public static async searchInstallationForRepos( + octokit: Octokit, + orgName: string, + installationId: number, + searchTerms?: string, + ) { + // First, do a search on GitHub for repos. This will use the user's context so it will find user repos. + let searchRepos: string[] | null = null; + if (searchTerms) { + const terms = encodeURIComponent(searchTerms); + const searchResultsData = ( + await octokit.search.repos({ + q: `${terms} org:${orgName} `, + per_page: MAX_RETURNED_TARGETS, + }) + ).data; + if (searchResultsData.total_count === 0) { + return []; + } + searchRepos = searchResultsData.items.map((r) => r.full_name); } - public async provisionerUpdateConfig(userId: string, config: Record) { - // Apply previous state to the current config, as provisioners might not return "unknown" keys. - const newState = { ...this.state, ...config }; - const validatedConfig = GitHubRepoConnection.validateState(newState); - await this.intent.underlyingClient.sendStateEvent(this.roomId, GitHubRepoConnection.CanonicalEventType, this.stateKey, validatedConfig); - this.state = validatedConfig; - this.hookFilter.enabledHooks = this.state.enableHooks ?? []; + // Now, find all the repos that we have the ability to install. + const foundRepos = []; + let installationsCount = 0; + let totalCount = 0; + let page = 1; + do { + const { data } = + await octokit.apps.listInstallationReposForAuthenticatedUser({ + installation_id: installationId, + page, + per_page: 100, + }); + // No results, so stop trying. + if (data.repositories.length === 0) { + break; + } + page++; + installationsCount += data.repositories.length; + totalCount = data.total_count; + // Find any repos that were in our search results. If a search term isn't defined, just return it. + foundRepos.push( + ...data.repositories.filter( + (installRepo) => searchRepos?.includes(installRepo.full_name) ?? true, + ), + ); + } while ( + installationsCount < totalCount && + foundRepos.length < (searchRepos?.length ?? MAX_RETURNED_TARGETS) + ); + return foundRepos; + } + + public static async getConnectionTargets( + userId: string, + tokenStore: UserTokenStore, + githubInstance: GithubInstance, + filters: GitHubTargetFilter = {}, + ): Promise { + // Search for all repos under the user's control. + const octokit = await tokenStore.getOctokitForUser(userId); + if (!octokit) { + throw new ApiError( + "User is not authenticated with GitHub", + ErrCode.ForbiddenUser, + ); } - public async onRemove() { - log.info(`Removing ${this.toString()} for ${this.roomId}`); - await this.grantChecker.ungrantConnection(this.roomId, { org: this.org, repo: this.repo }); - // Do a sanity check that the event exists. - try { - await this.intent.underlyingClient.getRoomStateEvent(this.roomId, GitHubRepoConnection.CanonicalEventType, this.stateKey); - await this.intent.underlyingClient.sendStateEvent(this.roomId, GitHubRepoConnection.CanonicalEventType, this.stateKey, { disabled: true }); - } catch (ex) { - await this.intent.underlyingClient.getRoomStateEvent(this.roomId, GitHubRepoConnection.LegacyCanonicalEventType, this.stateKey); - await this.intent.underlyingClient.sendStateEvent(this.roomId, GitHubRepoConnection.LegacyCanonicalEventType, this.stateKey, { disabled: true }); + if (!filters.orgName) { + const results: GitHubRepoConnectionOrgTarget[] = []; + try { + const installs = + await octokit.apps.listInstallationsForAuthenticatedUser(); + for (const install of installs.data.installations) { + if (install.account) { + results.push({ + name: getNameForGitHubAccount(install.account), // org or user name + }); + } else { + log.debug( + `Skipping install ${install.id}, has no attached account`, + ); + } } + } catch (ex) { + log.warn(`Failed to fetch orgs for GitHub user ${userId}`, ex); + throw new ApiError( + "Could not fetch orgs for GitHub user", + ErrCode.AdditionalActionRequired, + ); + } + return results; } - - private matchesLabelFilter(itemWithLabels: {labels?: {name: string}[]}): boolean { - const labels = itemWithLabels.labels?.map(l => l.name) || []; - if (this.state.excludingLabels?.length) { - if (this.state.excludingLabels.find(l => labels.includes(l))) { - return false; - } - } - if (this.state.includingLabels?.length) { - return !!this.state.includingLabels.find(l => labels.includes(l)); - } - return true; + // If we have an instance, search under it. + const ownSelf = await octokit.users.getAuthenticated(); + + try { + let installationId; + if (ownSelf.data.login === filters.orgName) { + installationId = ( + await githubInstance.appOctokit.apps.getUserInstallation({ + username: ownSelf.data.login, + }) + ).data.id; + } else { + installationId = ( + await githubInstance.appOctokit.apps.getOrgInstallation({ + org: filters.orgName, + }) + ).data.id; + // Github will error if the authed user tries to list repos of a disallowed installation, even + // if we got the installation ID from the app's instance. + } + const reposRes = await this.searchInstallationForRepos( + octokit, + filters.orgName, + installationId, + filters.search, + ); + return reposRes.map((r) => ({ + state: { + org: filters.orgName, + repo: r.name, + }, + name: r.name, + description: r.description, + avatar: r.owner.avatar_url, + })) as GitHubRepoConnectionRepoTarget[]; + } catch (ex) { + log.warn( + `Failed to fetch accessible repos for ${filters.orgName} / ${userId}`, + ex, + ); + throw new ApiError( + "Could not fetch accessible repos for GitHub org", + ErrCode.AdditionalActionRequired, + ); + } + } + + public async provisionerUpdateConfig( + userId: string, + config: Record, + ) { + // Apply previous state to the current config, as provisioners might not return "unknown" keys. + const newState = { ...this.state, ...config }; + const validatedConfig = GitHubRepoConnection.validateState(newState); + await this.intent.underlyingClient.sendStateEvent( + this.roomId, + GitHubRepoConnection.CanonicalEventType, + this.stateKey, + validatedConfig, + ); + this.state = validatedConfig; + this.hookFilter.enabledHooks = this.state.enableHooks ?? []; + } + + public async onRemove() { + log.info(`Removing ${this.toString()} for ${this.roomId}`); + await this.grantChecker.ungrantConnection(this.roomId, { + org: this.org, + repo: this.repo, + }); + // Do a sanity check that the event exists. + try { + await this.intent.underlyingClient.getRoomStateEvent( + this.roomId, + GitHubRepoConnection.CanonicalEventType, + this.stateKey, + ); + await this.intent.underlyingClient.sendStateEvent( + this.roomId, + GitHubRepoConnection.CanonicalEventType, + this.stateKey, + { disabled: true }, + ); + } catch (ex) { + await this.intent.underlyingClient.getRoomStateEvent( + this.roomId, + GitHubRepoConnection.LegacyCanonicalEventType, + this.stateKey, + ); + await this.intent.underlyingClient.sendStateEvent( + this.roomId, + GitHubRepoConnection.LegacyCanonicalEventType, + this.stateKey, + { disabled: true }, + ); + } + } + + private matchesLabelFilter(itemWithLabels: { + labels?: { name: string }[]; + }): boolean { + const labels = itemWithLabels.labels?.map((l) => l.name) || []; + if (this.state.excludingLabels?.length) { + if (this.state.excludingLabels.find((l) => labels.includes(l))) { + return false; + } + } + if (this.state.includingLabels?.length) { + return !!this.state.includingLabels.find((l) => labels.includes(l)); } + return true; + } } -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const res = compileBotCommands(GitHubRepoConnection.prototype as any, CommandConnection.prototype as any); + +const res = compileBotCommands( + GitHubRepoConnection.prototype as any, + CommandConnection.prototype as any, +); GitHubRepoConnection.helpMessage = res.helpMessage; GitHubRepoConnection.botCommands = res.botCommands; diff --git a/src/Connections/GithubUserSpace.ts b/src/Connections/GithubUserSpace.ts index 456acdc7d..f1a125223 100644 --- a/src/Connections/GithubUserSpace.ts +++ b/src/Connections/GithubUserSpace.ts @@ -1,4 +1,8 @@ -import { Connection, IConnection, InstantiateConnectionOpts } from "./IConnection"; +import { + Connection, + IConnection, + InstantiateConnectionOpts, +} from "./IConnection"; import { Appservice, Space, StateEvent } from "matrix-bot-sdk"; import { Logger } from "matrix-appservice-bridge"; import axios from "axios"; @@ -11,8 +15,8 @@ import { BridgeConfig } from "../config/Config"; const log = new Logger("GitHubOwnerSpace"); export interface GitHubUserSpaceConnectionState { - username: string; - nodeId: string; + username: string; + nodeId: string; } /** @@ -20,165 +24,189 @@ export interface GitHubUserSpaceConnectionState { */ @Connection export class GitHubUserSpace extends BaseConnection implements IConnection { - static readonly CanonicalEventType = "uk.half-shot.matrix-hookshot.github.user.space"; - static readonly LegacyCanonicalEventType = "uk.half-shot.matrix-github.user.space"; - - static readonly EventTypes = [ - GitHubUserSpace.CanonicalEventType, // Legacy event, with an awful name. - ]; - - static readonly QueryRoomRegex = /#github_(.+):.*/; - static readonly ServiceCategory = "github"; - - private static grantKey(state: GitHubUserSpaceConnectionState) { - return `${this.CanonicalEventType}/${state.username}`; - } - - public static async createConnectionForState(roomId: string, event: StateEvent, { - github, config, intent, as}: InstantiateConnectionOpts) { - if (!github || !config.github) { - throw Error('GitHub is not configured'); - } - return new GitHubUserSpace( - as, config, await intent.underlyingClient.getSpace(roomId), event.content, event.stateKey - ); - } - - static async onQueryRoom(result: RegExpExecArray, opts: {githubInstance: GithubInstance, as: Appservice}): Promise> { - if (!result || result.length < 1) { - log.error(`Invalid alias pattern '${result}'`); - throw Error("Could not find issue"); - } - - const [ username ] = result.slice(1); - - log.info(`Fetching ${username}`); - let state: GitHubUserSpaceConnectionState; - let avatarUrl: string|undefined; - let name: string; - const octokit = opts.githubInstance.getOctokitForRepo(username); - try { - // TODO: Determine if the repo has discussions? - const userRes = (await octokit.users.getByUsername({ - username, - })).data; - if (!userRes) { - throw Error('User does not exist!'); - } - name = userRes.name as string; - state = { - nodeId: userRes.node_id as string, - username: userRes.login as string, - } - avatarUrl = userRes.avatar_url as string; - } catch (ex) { - log.error("Failed to get repo:", ex); - throw Error("Could not find repo"); - } - - let avatarState: {type: "m.room.avatar", state_key: "", content: { url: string}}|undefined; - try { - if (avatarUrl) { - const res = await axios.get(avatarUrl, { - responseType: 'arraybuffer', - }); - log.info(`uploading ${avatarUrl}`); - // This does exist, but headers is silly and doesn't have content-type. - const contentType: string = res.headers["content-type"]; - const mxcUrl = await opts.as.botClient.uploadContent( - Buffer.from(res.data as ArrayBuffer), - contentType, - `avatar_${state.username}.png`, - ); - avatarState = { - type: "m.room.avatar", - state_key: "", - content: { - url: mxcUrl, - }, - }; - } - } catch (ex) { - log.warn("Failed to get avatar for org:", ex); - } - - return { - visibility: "public", - name: `GitHub - ${name} (${state.username.toLowerCase()})`, - topic: `GitHub page of ${state.username.toLowerCase()}`, - preset: 'public_chat', - room_alias_name: `github_${state.username.toLowerCase()}`, - initial_state: [ - - { - type: this.CanonicalEventType, - content: state, - state_key: state.username.toLowerCase(), - }, - avatarState, - { - type: "m.room.history_visibility", - state_key: "", - content: { - history_visibility: 'world_readable', - }, - }, - ], - creation_content: { - type: "m.space", - }, - power_level_content_override: { - ban: 100, - events_default: 50, - invite: 50, - kick: 100, - notifications: { - room: 100, - }, - redact: 100, - state_default: 100, - users_default: 0, - }, - }; + static readonly CanonicalEventType = + "uk.half-shot.matrix-hookshot.github.user.space"; + static readonly LegacyCanonicalEventType = + "uk.half-shot.matrix-github.user.space"; + + static readonly EventTypes = [ + GitHubUserSpace.CanonicalEventType, // Legacy event, with an awful name. + ]; + + static readonly QueryRoomRegex = /#github_(.+):.*/; + static readonly ServiceCategory = "github"; + + private static grantKey(state: GitHubUserSpaceConnectionState) { + return `${this.CanonicalEventType}/${state.username}`; + } + + public static async createConnectionForState( + roomId: string, + event: StateEvent, + { github, config, intent, as }: InstantiateConnectionOpts, + ) { + if (!github || !config.github) { + throw Error("GitHub is not configured"); } - - private readonly grantChecker: GrantChecker; - - constructor(as: Appservice, - config: BridgeConfig, - public readonly space: Space, - private state: GitHubUserSpaceConnectionState, - stateKey: string) { - super(space.roomId, stateKey, GitHubUserSpace.CanonicalEventType); - this.grantChecker = new ConfigGrantChecker("github", as, config); - } - - public ensureGrant(sender?: string) { - return this.grantChecker.assertConnectionGranted(this.roomId, GitHubUserSpace.grantKey(this.state), sender); - } - - public isInterestedInStateEvent(eventType: string, stateKey: string) { - return GitHubUserSpace.EventTypes.includes(eventType) && this.stateKey === stateKey; + return new GitHubUserSpace( + as, + config, + await intent.underlyingClient.getSpace(roomId), + event.content, + event.stateKey, + ); + } + + static async onQueryRoom( + result: RegExpExecArray, + opts: { githubInstance: GithubInstance; as: Appservice }, + ): Promise> { + if (!result || result.length < 1) { + log.error(`Invalid alias pattern '${result}'`); + throw Error("Could not find issue"); } - public get owner() { - return this.state.username.toLowerCase(); + const [username] = result.slice(1); + + log.info(`Fetching ${username}`); + let state: GitHubUserSpaceConnectionState; + let avatarUrl: string | undefined; + let name: string; + const octokit = opts.githubInstance.getOctokitForRepo(username); + try { + // TODO: Determine if the repo has discussions? + const userRes = ( + await octokit.users.getByUsername({ + username, + }) + ).data; + if (!userRes) { + throw Error("User does not exist!"); + } + name = userRes.name as string; + state = { + nodeId: userRes.node_id as string, + username: userRes.login as string, + }; + avatarUrl = userRes.avatar_url as string; + } catch (ex) { + log.error("Failed to get repo:", ex); + throw Error("Could not find repo"); } - public toString() { - return `GitHubUserSpace ${this.owner}`; - } - - public async onRepoConnectionCreated(discussion: GitHubDiscussionSpace) { - log.info(`Adding connection to ${this.toString()}`); - await this.space.addChildRoom(discussion.roomId); + let avatarState: + | { type: "m.room.avatar"; state_key: ""; content: { url: string } } + | undefined; + try { + if (avatarUrl) { + const res = await axios.get(avatarUrl, { + responseType: "arraybuffer", + }); + log.info(`uploading ${avatarUrl}`); + // This does exist, but headers is silly and doesn't have content-type. + const contentType: string = res.headers["content-type"]; + const mxcUrl = await opts.as.botClient.uploadContent( + Buffer.from(res.data as ArrayBuffer), + contentType, + `avatar_${state.username}.png`, + ); + avatarState = { + type: "m.room.avatar", + state_key: "", + content: { + url: mxcUrl, + }, + }; + } + } catch (ex) { + log.warn("Failed to get avatar for org:", ex); } - public async ensureDiscussionInSpace(discussion: GitHubDiscussionSpace) { - // TODO: Optimise - const children = await this.space.getChildEntities(); - if (!children[discussion.roomId]) { - await this.space.addChildRoom(discussion.roomId); - } + return { + visibility: "public", + name: `GitHub - ${name} (${state.username.toLowerCase()})`, + topic: `GitHub page of ${state.username.toLowerCase()}`, + preset: "public_chat", + room_alias_name: `github_${state.username.toLowerCase()}`, + initial_state: [ + { + type: this.CanonicalEventType, + content: state, + state_key: state.username.toLowerCase(), + }, + avatarState, + { + type: "m.room.history_visibility", + state_key: "", + content: { + history_visibility: "world_readable", + }, + }, + ], + creation_content: { + type: "m.space", + }, + power_level_content_override: { + ban: 100, + events_default: 50, + invite: 50, + kick: 100, + notifications: { + room: 100, + }, + redact: 100, + state_default: 100, + users_default: 0, + }, + }; + } + + private readonly grantChecker: GrantChecker; + + constructor( + as: Appservice, + config: BridgeConfig, + public readonly space: Space, + private state: GitHubUserSpaceConnectionState, + stateKey: string, + ) { + super(space.roomId, stateKey, GitHubUserSpace.CanonicalEventType); + this.grantChecker = new ConfigGrantChecker("github", as, config); + } + + public ensureGrant(sender?: string) { + return this.grantChecker.assertConnectionGranted( + this.roomId, + GitHubUserSpace.grantKey(this.state), + sender, + ); + } + + public isInterestedInStateEvent(eventType: string, stateKey: string) { + return ( + GitHubUserSpace.EventTypes.includes(eventType) && + this.stateKey === stateKey + ); + } + + public get owner() { + return this.state.username.toLowerCase(); + } + + public toString() { + return `GitHubUserSpace ${this.owner}`; + } + + public async onRepoConnectionCreated(discussion: GitHubDiscussionSpace) { + log.info(`Adding connection to ${this.toString()}`); + await this.space.addChildRoom(discussion.roomId); + } + + public async ensureDiscussionInSpace(discussion: GitHubDiscussionSpace) { + // TODO: Optimise + const children = await this.space.getChildEntities(); + if (!children[discussion.roomId]) { + await this.space.addChildRoom(discussion.roomId); } + } } diff --git a/src/Connections/GitlabIssue.ts b/src/Connections/GitlabIssue.ts index 7d2df87d7..9a6f299b6 100644 --- a/src/Connections/GitlabIssue.ts +++ b/src/Connections/GitlabIssue.ts @@ -1,11 +1,19 @@ -import { Connection, IConnection, InstantiateConnectionOpts } from "./IConnection"; +import { + Connection, + IConnection, + InstantiateConnectionOpts, +} from "./IConnection"; import { Appservice, Intent, StateEvent } from "matrix-bot-sdk"; import { MatrixMessageContent, MatrixEvent } from "../MatrixEvent"; import { UserTokenStore } from "../tokens/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"; @@ -13,12 +21,12 @@ import { BaseConnection } from "./BaseConnection"; import { ConfigGrantChecker, GrantChecker } from "../grants/GrantCheck"; export interface GitLabIssueConnectionState { - instance: string; - projects: string[]; - state: string; - iid: number; - id: number; - authorName: string; + instance: string; + projects: string[]; + state: string; + iid: number; + id: number; + authorName: string; } const log = new Logger("GitLabIssueConnection"); @@ -35,224 +43,311 @@ const log = new Logger("GitLabIssueConnection"); * Handles rooms connected to a GitLab issue. */ @Connection -export class GitLabIssueConnection extends BaseConnection implements IConnection { - static readonly CanonicalEventType = "uk.half-shot.matrix-hookshot.gitlab.issue"; - static readonly LegacyCanonicalEventType = "uk.half-shot.matrix-github.gitlab.issue"; - static readonly EventTypes = [ - GitLabIssueConnection.CanonicalEventType, - GitLabIssueConnection.LegacyCanonicalEventType, - ]; - static readonly QueryRoomRegex = /#gitlab_(.+)_(.+)_(\d+):.*/; - static readonly ServiceCategory = "gitlab"; +export class GitLabIssueConnection + extends BaseConnection + implements IConnection +{ + static readonly CanonicalEventType = + "uk.half-shot.matrix-hookshot.gitlab.issue"; + static readonly LegacyCanonicalEventType = + "uk.half-shot.matrix-github.gitlab.issue"; + static readonly EventTypes = [ + GitLabIssueConnection.CanonicalEventType, + GitLabIssueConnection.LegacyCanonicalEventType, + ]; + static readonly QueryRoomRegex = /#gitlab_(.+)_(.+)_(\d+):.*/; + static readonly ServiceCategory = "gitlab"; - static getTopicString(authorName: string, state: string): string { - return `Author: ${authorName} | State: ${state === "closed" ? "closed" : "open"}` - } + static getTopicString(authorName: string, state: string): string { + return `Author: ${authorName} | State: ${state === "closed" ? "closed" : "open"}`; + } - public static async createConnectionForState( - roomId: string, - event: StateEvent, - { config, as, intent, tokenStore, commentProcessor, messageClient}: InstantiateConnectionOpts, - ) { - if (!config.gitlab) { - throw Error('GitHub is not configured'); - } - const instance = config.gitlab.instances[event.content.instance]; - if (!instance) { - throw Error('Instance name not recognised'); - } - return new GitLabIssueConnection( - roomId, - as, - intent, - event.content, - event.stateKey || "", - tokenStore, - commentProcessor, - messageClient, - instance, - config, - ); + public static async createConnectionForState( + roomId: string, + event: StateEvent, + { + config, + as, + intent, + tokenStore, + commentProcessor, + messageClient, + }: InstantiateConnectionOpts, + ) { + if (!config.gitlab) { + throw Error("GitHub is not configured"); } + const instance = config.gitlab.instances[event.content.instance]; + if (!instance) { + throw Error("Instance name not recognised"); + } + return new GitLabIssueConnection( + roomId, + as, + intent, + event.content, + event.stateKey || "", + tokenStore, + commentProcessor, + messageClient, + instance, + config, + ); + } - public static async createRoomForIssue( - instanceName: string, - instance: GitLabInstance, - issue: GetIssueResponse, - projects: string[], - as: Appservice, - intent: Intent, - tokenStore: UserTokenStore, - commentProcessor: CommentProcessor, - messageSender: MessageSenderClient, - config: BridgeConfig, - ) { - const state: GitLabIssueConnectionState = { - projects, - state: issue.state, - iid: issue.iid, - id: issue.id, - instance: instanceName, - authorName: issue.author.name, - }; + public static async createRoomForIssue( + instanceName: string, + instance: GitLabInstance, + issue: GetIssueResponse, + projects: string[], + as: Appservice, + intent: Intent, + tokenStore: UserTokenStore, + commentProcessor: CommentProcessor, + messageSender: MessageSenderClient, + config: BridgeConfig, + ) { + const state: GitLabIssueConnectionState = { + projects, + state: issue.state, + iid: issue.iid, + id: issue.id, + instance: instanceName, + authorName: issue.author.name, + }; - const roomId = await intent.underlyingClient.createRoom({ - visibility: "private", - name: `${issue.references.full}`, - topic: GitLabIssueConnection.getTopicString(issue.author.name, issue.state), - preset: "private_chat", - invite: [], - initial_state: [ - { - type: this.CanonicalEventType, - content: state, - state_key: issue.web_url, - }, - ], - }); - await new GrantChecker(as.botIntent, "gitlab").grantConnection(roomId, { - instance: state.instance, - project: state.projects[0].toString(), - issue: state.iid.toString(), - }); + const roomId = await intent.underlyingClient.createRoom({ + visibility: "private", + name: `${issue.references.full}`, + topic: GitLabIssueConnection.getTopicString( + issue.author.name, + issue.state, + ), + preset: "private_chat", + invite: [], + initial_state: [ + { + type: this.CanonicalEventType, + content: state, + state_key: issue.web_url, + }, + ], + }); + await new GrantChecker(as.botIntent, "gitlab").grantConnection(roomId, { + instance: state.instance, + project: state.projects[0].toString(), + issue: state.iid.toString(), + }); - return new GitLabIssueConnection(roomId, as, intent, state, issue.web_url, tokenStore, commentProcessor, messageSender, instance, config); - } + return new GitLabIssueConnection( + roomId, + as, + intent, + state, + issue.web_url, + tokenStore, + commentProcessor, + messageSender, + instance, + config, + ); + } - public get projectPath() { - return this.state.projects.join("/"); - } + public get projectPath() { + return this.state.projects.join("/"); + } - public get instanceUrl() { - return this.instance.url; - } + public get instanceUrl() { + return this.instance.url; + } - private readonly grantChecker: GrantChecker<{instance: string, project: string, issue: string}>; - private readonly config: BridgeConfigGitLab; + private readonly grantChecker: GrantChecker<{ + instance: string; + project: string; + issue: string; + }>; + private readonly config: BridgeConfigGitLab; - constructor( - roomId: string, - private readonly as: Appservice, - private readonly intent: Intent, - private state: GitLabIssueConnectionState, - stateKey: string, - private tokenStore: UserTokenStore, - private commentProcessor: CommentProcessor, - private messageClient: MessageSenderClient, - private instance: GitLabInstance, - config: BridgeConfig, - ) { - super(roomId, stateKey, GitLabIssueConnection.CanonicalEventType); - this.grantChecker = new ConfigGrantChecker("gitlab", as, config); - if (!config.gitlab) { - throw Error('No gitlab config!'); - } - this.config = config.gitlab; + constructor( + roomId: string, + private readonly as: Appservice, + private readonly intent: Intent, + private state: GitLabIssueConnectionState, + stateKey: string, + private tokenStore: UserTokenStore, + private commentProcessor: CommentProcessor, + private messageClient: MessageSenderClient, + private instance: GitLabInstance, + config: BridgeConfig, + ) { + super(roomId, stateKey, GitLabIssueConnection.CanonicalEventType); + this.grantChecker = new ConfigGrantChecker("gitlab", as, config); + if (!config.gitlab) { + throw Error("No gitlab config!"); } + this.config = config.gitlab; + } - public ensureGrant(sender?: string) { - return this.grantChecker.assertConnectionGranted(this.roomId, { - instance: this.state.instance, - project: this.state.projects[0], - issue: this.state.iid.toString(), - }, sender); - } + public ensureGrant(sender?: string) { + return this.grantChecker.assertConnectionGranted( + this.roomId, + { + instance: this.state.instance, + project: this.state.projects[0], + issue: this.state.iid.toString(), + }, + sender, + ); + } - public isInterestedInStateEvent(eventType: string, stateKey: string) { - return GitLabIssueConnection.EventTypes.includes(eventType) && this.stateKey === stateKey; - } + public isInterestedInStateEvent(eventType: string, stateKey: string) { + return ( + GitLabIssueConnection.EventTypes.includes(eventType) && + this.stateKey === stateKey + ); + } - public get issueNumber() { - return this.state.iid; - } + public get issueNumber() { + return this.state.iid; + } - public async onCommentCreated(event: IGitLabWebhookNoteEvent) { - log.info(`${this.toString()} onCommentCreated ${event.object_attributes.noteable_id}`); - if (event.repository) { - // Delay to stop comments racing sends - await new Promise((resolve) => setTimeout(resolve, 500)); - if (this.commentProcessor.hasCommentBeenProcessed( - this.state.instance, - this.state.projects.join("/"), - this.state.iid.toString(), - event.object_attributes.id)) { - return; - } - } - const commentIntent = await getIntentForUser({ - login: event.user.name, - avatarUrl: event.user.avatar_url, - }, this.as, this.config.userIdPrefix); - const matrixEvent = await this.commentProcessor.getEventBodyForGitLabNote(event); - // Make sure ghost user is invited to the room - await ensureUserIsInRoom( - commentIntent, - this.intent.underlyingClient, - this.roomId - ); - await this.messageClient.sendMatrixMessage(this.roomId, matrixEvent, "m.room.message", commentIntent.userId); + public async onCommentCreated(event: IGitLabWebhookNoteEvent) { + log.info( + `${this.toString()} onCommentCreated ${event.object_attributes.noteable_id}`, + ); + if (event.repository) { + // Delay to stop comments racing sends + await new Promise((resolve) => setTimeout(resolve, 500)); + if ( + this.commentProcessor.hasCommentBeenProcessed( + this.state.instance, + this.state.projects.join("/"), + this.state.iid.toString(), + event.object_attributes.id, + ) + ) { + return; + } } + const commentIntent = await getIntentForUser( + { + login: event.user.name, + avatarUrl: event.user.avatar_url, + }, + this.as, + this.config.userIdPrefix, + ); + const matrixEvent = + await this.commentProcessor.getEventBodyForGitLabNote(event); + // Make sure ghost user is invited to the room + await ensureUserIsInRoom( + commentIntent, + this.intent.underlyingClient, + this.roomId, + ); + await this.messageClient.sendMatrixMessage( + this.roomId, + matrixEvent, + "m.room.message", + commentIntent.userId, + ); + } - public async onMatrixIssueComment(event: MatrixEvent, allowEcho = false) { - const clientKit = await this.tokenStore.getGitLabForUser(event.sender, this.instanceUrl); - if (clientKit === null) { - await this.intent.underlyingClient.sendEvent(this.roomId, "m.reaction", { - "m.relates_to": { - rel_type: "m.annotation", - event_id: event.event_id, - key: "⚠️ Not bridged", - } - }) - log.info("Ignoring comment, user is not authenticated"); - return; - } - const result = await clientKit.notes.createForIssue( - this.state.projects, - this.state.iid, { - body: await this.commentProcessor.getCommentBodyForEvent(event, false), - } - ); - log.info(`${this.toString()} created note ${result.noteable_id} for ${event.event_id} ${event.sender}`); - - if (!allowEcho) { - this.commentProcessor.markCommentAsProcessed( - this.state.instance, - this.state.projects.join("/"), - this.state.iid.toString(), - result.id, - ); - } + public async onMatrixIssueComment( + event: MatrixEvent, + allowEcho = false, + ) { + const clientKit = await this.tokenStore.getGitLabForUser( + event.sender, + this.instanceUrl, + ); + if (clientKit === null) { + await this.intent.underlyingClient.sendEvent(this.roomId, "m.reaction", { + "m.relates_to": { + rel_type: "m.annotation", + event_id: event.event_id, + key: "⚠️ Not bridged", + }, + }); + log.info("Ignoring comment, user is not authenticated"); + return; } + const result = await clientKit.notes.createForIssue( + this.state.projects, + this.state.iid, + { + body: await this.commentProcessor.getCommentBodyForEvent(event, false), + }, + ); + log.info( + `${this.toString()} created note ${result.noteable_id} for ${event.event_id} ${event.sender}`, + ); - public async onIssueReopened() { - // TODO: We don't store the author data. - this.state.state = "reopened"; - await this.intent.underlyingClient.sendStateEvent(this.roomId, GitLabIssueConnection.CanonicalEventType, this.stateKey, this.state); - return this.intent.underlyingClient.sendStateEvent(this.roomId, "m.room.topic", "", { - topic: GitLabIssueConnection.getTopicString(this.state.authorName, this.state.state), - }); + if (!allowEcho) { + this.commentProcessor.markCommentAsProcessed( + this.state.instance, + this.state.projects.join("/"), + this.state.iid.toString(), + result.id, + ); } + } - public async onIssueClosed() { - // TODO: We don't store the author data. - this.state.state = "closed"; - await this.intent.underlyingClient.sendStateEvent(this.roomId, GitLabIssueConnection.CanonicalEventType, this.stateKey , this.state); - return this.intent.underlyingClient.sendStateEvent(this.roomId, "m.room.topic", "", { - topic: GitLabIssueConnection.getTopicString(this.state.authorName, this.state.state), - }); - } + public async onIssueReopened() { + // TODO: We don't store the author data. + this.state.state = "reopened"; + await this.intent.underlyingClient.sendStateEvent( + this.roomId, + GitLabIssueConnection.CanonicalEventType, + this.stateKey, + this.state, + ); + return this.intent.underlyingClient.sendStateEvent( + this.roomId, + "m.room.topic", + "", + { + topic: GitLabIssueConnection.getTopicString( + this.state.authorName, + this.state.state, + ), + }, + ); + } - public async onMessageEvent(ev: MatrixEvent) { - if (ev.content.body === '!sync') { - // Sync data. - // return this.syncIssueState(); - return true; - } - await this.onMatrixIssueComment(ev); - return true; - } + public async onIssueClosed() { + // TODO: We don't store the author data. + this.state.state = "closed"; + await this.intent.underlyingClient.sendStateEvent( + this.roomId, + GitLabIssueConnection.CanonicalEventType, + this.stateKey, + this.state, + ); + return this.intent.underlyingClient.sendStateEvent( + this.roomId, + "m.room.topic", + "", + { + topic: GitLabIssueConnection.getTopicString( + this.state.authorName, + this.state.state, + ), + }, + ); + } - public toString() { - return `GitLabIssue ${this.instanceUrl}/${this.projectPath}#${this.issueNumber}`; + public async onMessageEvent(ev: MatrixEvent) { + if (ev.content.body === "!sync") { + // Sync data. + // return this.syncIssueState(); + return true; } + await this.onMatrixIssueComment(ev); + return true; + } + + public toString() { + return `GitLabIssue ${this.instanceUrl}/${this.projectPath}#${this.issueNumber}`; + } } diff --git a/src/Connections/GitlabRepo.ts b/src/Connections/GitlabRepo.ts index 95eb3ff79..357c0738f 100644 --- a/src/Connections/GitlabRepo.ts +++ b/src/Connections/GitlabRepo.ts @@ -5,12 +5,31 @@ import { MatrixEvent, MatrixMessageContent } from "../MatrixEvent"; import markdown from "markdown-it"; import { Logger } from "matrix-appservice-bridge"; import { BridgeConfigGitLab, GitLabInstance } from "../config/Config"; -import { IGitlabMergeRequest, IGitlabProject, IGitlabUser, IGitLabWebhookMREvent, IGitLabWebhookNoteEvent, IGitLabWebhookPushEvent, IGitLabWebhookReleaseEvent, IGitLabWebhookTagPushEvent, IGitLabWebhookWikiPageEvent } from "../Gitlab/WebhookTypes"; +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 { + Connection, + IConnection, + IConnectionState, + InstantiateConnectionOpts, + ProvisionConnectionOpts, +} from "./IConnection"; import { ConnectionWarning, GetConnectionsResponseItem } from "../Widgets/api"; -import { ErrCode, ApiError, ValidatorApiError } from "../api" -import { AccessLevel, SerializedGitlabDiscussionThreads } from "../Gitlab/Types"; +import { ErrCode, ApiError, ValidatorApiError } from "../api"; +import { + AccessLevel, + SerializedGitlabDiscussionThreads, +} from "../Gitlab/Types"; import Ajv, { JSONSchemaType } from "ajv"; import { CommandError } from "../errors"; import QuickLRU from "@alloc/quick-lru"; @@ -21,961 +40,1268 @@ import axios from "axios"; import { GitLabGrantChecker } from "../Gitlab/GrantChecker"; export interface GitLabRepoConnectionState extends IConnectionState { - instance: string; - path: string; - enableHooks?: AllowedEventsNames[], - /** - * Do not use. Use `enableHooks` - * @deprecated - */ - ignoreHooks?: AllowedEventsNames[], - includeCommentBody?: boolean; - pushTagsRegex?: string, - includingLabels?: string[]; - excludingLabels?: string[]; + instance: string; + path: string; + enableHooks?: AllowedEventsNames[]; + /** + * Do not use. Use `enableHooks` + * @deprecated + */ + ignoreHooks?: AllowedEventsNames[]; + includeCommentBody?: boolean; + pushTagsRegex?: string; + includingLabels?: string[]; + excludingLabels?: string[]; } interface ConnectionStateValidated extends GitLabRepoConnectionState { - ignoreHooks: undefined, - enableHooks: AllowedEventsNames[], + ignoreHooks: undefined; + enableHooks: AllowedEventsNames[]; } - export interface GitLabRepoConnectionInstanceTarget { - name: string; + name: string; } export interface GitLabRepoConnectionProjectTarget { - state: GitLabRepoConnectionState; - name: string; - avatar_url?: string; - description?: string; + state: GitLabRepoConnectionState; + name: string; + avatar_url?: string; + description?: string; } -export type GitLabRepoConnectionTarget = GitLabRepoConnectionInstanceTarget|GitLabRepoConnectionProjectTarget; +export type GitLabRepoConnectionTarget = + | GitLabRepoConnectionInstanceTarget + | GitLabRepoConnectionProjectTarget; const log = new Logger("GitLabRepoConnection"); const md = new markdown(); const PUSH_MAX_COMMITS = 5; -export type GitLabRepoResponseItem = GetConnectionsResponseItem; - +export type GitLabRepoResponseItem = + GetConnectionsResponseItem; type AllowedEventsNames = - "merge_request.open" | - "merge_request.reopen" | - "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}` | - "merge_request" | - "tag_push" | - "push" | - "wiki" | - `wiki.${string}` | - "release" | - "release.created"; + | "merge_request.open" + | "merge_request.reopen" + | "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}` + | "merge_request" + | "tag_push" + | "push" + | "wiki" + | `wiki.${string}` + | "release" + | "release.created"; const AllowedEvents: AllowedEventsNames[] = [ - "merge_request.open", - "merge_request.reopen", - "merge_request.close", - "merge_request.merge", - "merge_request.review", - "merge_request.review.individual", - "merge_request.ready_for_review", - "merge_request.review.comments", - "merge_request", - "tag_push", - "push", - "wiki", - "release", - "release.created", + "merge_request.open", + "merge_request.reopen", + "merge_request.close", + "merge_request.merge", + "merge_request.review", + "merge_request.review.individual", + "merge_request.ready_for_review", + "merge_request.review.comments", + "merge_request", + "tag_push", + "push", + "wiki", + "release", + "release.created", ]; const DefaultHooks = AllowedEvents; const ConnectionStateSchema = { - type: "object", - properties: { - priority: { - type: "number", - nullable: true, - }, - instance: { type: "string" }, - path: { type: "string" }, - /** - * Do not use. Use `enableHooks` - * @deprecated - */ - ignoreHooks: { - type: "array", - items: { - type: "string", - }, - nullable: true, - }, - enableHooks: { - type: "array", - items: { - type: "string", - }, - nullable: true, - }, - commandPrefix: { - type: "string", - minLength: 2, - nullable: true, - maxLength: 24, - }, - pushTagsRegex: { - type: "string", - nullable: true, - }, - includingLabels: { - type: "array", - nullable: true, - items: {type: "string"}, - }, - excludingLabels: { - type: "array", - nullable: true, - items: {type: "string"}, - }, - includeCommentBody: { - type: "boolean", - nullable: true, - }, + type: "object", + properties: { + priority: { + type: "number", + nullable: true, + }, + instance: { type: "string" }, + path: { type: "string" }, + /** + * Do not use. Use `enableHooks` + * @deprecated + */ + ignoreHooks: { + type: "array", + items: { + type: "string", + }, + nullable: true, + }, + enableHooks: { + type: "array", + items: { + type: "string", + }, + nullable: true, + }, + commandPrefix: { + type: "string", + minLength: 2, + nullable: true, + maxLength: 24, + }, + pushTagsRegex: { + type: "string", + nullable: true, + }, + includingLabels: { + type: "array", + nullable: true, + items: { type: "string" }, + }, + excludingLabels: { + type: "array", + nullable: true, + items: { type: "string" }, }, - required: [ - "instance", - "path" - ], - additionalProperties: true + includeCommentBody: { + type: "boolean", + nullable: true, + }, + }, + required: ["instance", "path"], + additionalProperties: true, } as JSONSchemaType; export interface GitLabTargetFilter { - instance?: string; - parent?: string; - search?: string; + instance?: string; + parent?: string; + search?: string; } /** * Handles rooms connected to a GitLab repo. */ @Connection -export class GitLabRepoConnection extends CommandConnection implements IConnection { - static readonly CanonicalEventType = "uk.half-shot.matrix-hookshot.gitlab.repository"; - static readonly LegacyCanonicalEventType = "uk.half-shot.matrix-github.gitlab.repository"; - - static readonly EventTypes = [ - GitLabRepoConnection.CanonicalEventType, - GitLabRepoConnection.LegacyCanonicalEventType, - ]; - - static botCommands: BotCommands; - static helpMessage: (cmdPrefix?: string | undefined) => MatrixMessageContent; - static ServiceCategory = "gitlab"; - - static validateState(state: unknown, isExistingState = false): ConnectionStateValidated { - const validator = new Ajv({ strict: false }).compile(ConnectionStateSchema); - if (validator(state)) { - // Validate enableHooks IF this is an incoming update (we can be less strict for existing state) - if (!isExistingState && state.enableHooks && !state.enableHooks.every(h => AllowedEvents.includes(h))) { - throw new ApiError('`enableHooks` must only contain allowed values', ErrCode.BadValue); - } - if (state.ignoreHooks) { - if (!isExistingState) { - throw new ApiError('`ignoreHooks` cannot be used with new connections', ErrCode.BadValue); - } - log.warn(`Room has old state key 'ignoreHooks'. Converting to compatible enabledHooks filter`); - state.enableHooks = HookFilter.convertIgnoredHooksToEnabledHooks(state.enableHooks, state.ignoreHooks, AllowedEvents); - } - return { - ...state, - enableHooks: state.enableHooks ?? AllowedEvents, - ignoreHooks: undefined, - }; +export class GitLabRepoConnection + extends CommandConnection + implements IConnection +{ + static readonly CanonicalEventType = + "uk.half-shot.matrix-hookshot.gitlab.repository"; + static readonly LegacyCanonicalEventType = + "uk.half-shot.matrix-github.gitlab.repository"; + + static readonly EventTypes = [ + GitLabRepoConnection.CanonicalEventType, + GitLabRepoConnection.LegacyCanonicalEventType, + ]; + + static botCommands: BotCommands; + static helpMessage: (cmdPrefix?: string | undefined) => MatrixMessageContent; + static ServiceCategory = "gitlab"; + + static validateState( + state: unknown, + isExistingState = false, + ): ConnectionStateValidated { + const validator = new Ajv({ strict: false }).compile(ConnectionStateSchema); + if (validator(state)) { + // Validate enableHooks IF this is an incoming update (we can be less strict for existing state) + if ( + !isExistingState && + state.enableHooks && + !state.enableHooks.every((h) => AllowedEvents.includes(h)) + ) { + throw new ApiError( + "`enableHooks` must only contain allowed values", + ErrCode.BadValue, + ); + } + if (state.ignoreHooks) { + if (!isExistingState) { + throw new ApiError( + "`ignoreHooks` cannot be used with new connections", + ErrCode.BadValue, + ); } - throw new ValidatorApiError(validator.errors); + log.warn( + `Room has old state key 'ignoreHooks'. Converting to compatible enabledHooks filter`, + ); + state.enableHooks = HookFilter.convertIgnoredHooksToEnabledHooks( + state.enableHooks, + state.ignoreHooks, + AllowedEvents, + ); + } + return { + ...state, + enableHooks: state.enableHooks ?? AllowedEvents, + ignoreHooks: undefined, + }; + } + throw new ValidatorApiError(validator.errors); + } + + static async createConnectionForState( + roomId: string, + event: StateEvent>, + { as, intent, storage, tokenStore, config }: InstantiateConnectionOpts, + ) { + if (!config.gitlab) { + throw Error("GitLab is not configured"); + } + const state = this.validateState(event.content, true); + const instance = config.gitlab.instances[state.instance]; + if (!instance) { + throw Error("Instance name not recognised"); } - static async createConnectionForState(roomId: string, event: StateEvent>, {as, intent, storage, tokenStore, config}: InstantiateConnectionOpts) { - if (!config.gitlab) { - throw Error('GitLab is not configured'); - } - const state = this.validateState(event.content, true); - const instance = config.gitlab.instances[state.instance]; - if (!instance) { - throw Error('Instance name not recognised'); - } - - 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; + 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( + instanceName: string, + path: string, + requester: string, + tokenStore: UserTokenStore, + config: BridgeConfigGitLab, + ) { + const instance = config.instances[instanceName]; + if (!instance) { + throw Error( + `provisionConnection provided an instanceName of ${instanceName} but the instance does not exist`, + ); + } + const client = await tokenStore.getGitLabForUser(requester, instance.url); + if (!client) { + throw new ApiError( + "User is not authenticated with GitLab", + ErrCode.ForbiddenUser, + ); + } + let permissionLevel; + try { + permissionLevel = await client.projects.getMyAccessLevel(path); + } catch (ex) { + throw new ApiError( + "Could not determine if the user has access to this project, does the project exist?", + ErrCode.ForbiddenUser, + ); } - public static async assertUserHasAccessToProject( - instanceName: string, path: string, requester: string, - tokenStore: UserTokenStore, config: BridgeConfigGitLab - ) { - const instance = config.instances[instanceName]; - if (!instance) { - throw Error(`provisionConnection provided an instanceName of ${instanceName} but the instance does not exist`); - } - const client = await tokenStore.getGitLabForUser(requester, instance.url); - if (!client) { - throw new ApiError("User is not authenticated with GitLab", ErrCode.ForbiddenUser); - } - let permissionLevel; - try { - permissionLevel = await client.projects.getMyAccessLevel(path); - } catch (ex) { - throw new ApiError("Could not determine if the user has access to this project, does the project exist?", ErrCode.ForbiddenUser); - } + if (permissionLevel < AccessLevel.Developer) { + throw new ApiError( + "You must at least have developer access to bridge this project", + ErrCode.ForbiddenUser, + ); + } + return permissionLevel; + } + + public static async provisionConnection( + roomId: string, + requester: string, + data: Record, + { + as, + config, + intent, + storage, + tokenStore, + getAllConnectionsOfType, + }: ProvisionConnectionOpts, + ) { + if (!config.gitlab) { + throw Error("GitLab is not configured"); + } + const validData = this.validateState(data); + const gitlabConfig = config.gitlab; + const instance = gitlabConfig.instances[validData.instance]; + if (!instance) { + throw Error( + `provisionConnection provided an instanceName of ${validData.instance} but the instance does not exist`, + ); + } + const permissionLevel = await this.assertUserHasAccessToProject( + validData.instance, + validData.path, + requester, + tokenStore, + gitlabConfig, + ); + const client = await tokenStore.getGitLabForUser(requester, instance.url); + if (!client) { + throw new ApiError( + "User is not authenticated with GitLab", + ErrCode.ForbiddenUser, + ); + } - if (permissionLevel < AccessLevel.Developer) { - throw new ApiError("You must at least have developer access to bridge this project", ErrCode.ForbiddenUser); - } - return permissionLevel; + 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, + storage, + ); + + const existingConnections = getAllConnectionsOfType(GitLabRepoConnection); + const existing = existingConnections.find( + (c) => + c.roomId === roomId && + c.instance.url === connection.instance.url && + c.path === connection.path, + ); + + if (existing) { + throw new ApiError( + "A GitLab repo connection for this project already exists", + ErrCode.ConflictingConnection, + -1, + { + existingConnection: existing.getProvisionerDetails(), + }, + ); } - public static async provisionConnection( - roomId: string, - requester: string, - data: Record, - { as, config, intent, storage, tokenStore, getAllConnectionsOfType }: ProvisionConnectionOpts + // Try to set up a webhook + // Requires at least a "Maintainer" role: https://docs.gitlab.com/ee/user/permissions.html + let warning: ConnectionWarning | undefined; + if ( + gitlabConfig.webhook.publicUrl && + permissionLevel >= AccessLevel.Maintainer ) { - if (!config.gitlab) { - throw Error('GitLab is not configured'); - } - const validData = this.validateState(data); - const gitlabConfig = config.gitlab; - const instance = gitlabConfig.instances[validData.instance]; - if (!instance) { - throw Error(`provisionConnection provided an instanceName of ${validData.instance} but the instance does not exist`); - } - const permissionLevel = await this.assertUserHasAccessToProject(validData.instance, validData.path, requester, tokenStore, gitlabConfig); - const client = await tokenStore.getGitLabForUser(requester, instance.url); - if (!client) { - throw new ApiError("User is not authenticated with GitLab", ErrCode.ForbiddenUser); - } - - const project = await client.projects.get(validData.path); - const stateEventKey = `${validData.instance}/${validData.path}`; - const connection = new GitLabRepoConnection(roomId, stateEventKey, as, 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); - - if (existing) { - throw new ApiError("A GitLab repo connection for this project already exists", ErrCode.ConflictingConnection, -1, { - existingConnection: existing.getProvisionerDetails() - }); - } - - // Try to set up a webhook - // Requires at least a "Maintainer" role: https://docs.gitlab.com/ee/user/permissions.html - let warning: ConnectionWarning | undefined; - if (gitlabConfig.webhook.publicUrl && permissionLevel >= AccessLevel.Maintainer) { - const hooks = await client.projects.hooks.list(project.id); - const hasHook = hooks.find(h => h.url === gitlabConfig.webhook.publicUrl); - if (!hasHook) { - log.info(`Creating webhook for ${validData.path}`); - await client.projects.hooks.add(project.id, { - url: gitlabConfig.webhook.publicUrl, - token: gitlabConfig.webhook.secret, - enable_ssl_verification: true, - // TODO: Determine which of these actually interests the user. - issues_events: true, - merge_requests_events: true, - push_events: true, - releases_events: true, - tag_push_events: true, - wiki_page_events: true, - }); - } - } else if (!gitlabConfig.webhook.publicUrl) { - log.info(`Not creating webhook, webhookUrl is not defined in config`); - } else { - warning = { - header: "Cannot create webhook", - message: "You have insufficient permissions on this project to provision a webhook for it. Ask a Maintainer or Owner of the project to add the webhook for you.", - }; - log.warn(`Not creating webhook, permission level is insufficient (${permissionLevel} < ${AccessLevel.Maintainer})`) - } - await new GitLabGrantChecker(as, gitlabConfig, tokenStore).grantConnection(roomId, { instance: validData.instance, path: validData.path }) - await intent.underlyingClient.sendStateEvent(roomId, this.CanonicalEventType, connection.stateKey, validData); - return {connection, warning}; + const hooks = await client.projects.hooks.list(project.id); + const hasHook = hooks.find( + (h) => h.url === gitlabConfig.webhook.publicUrl, + ); + if (!hasHook) { + log.info(`Creating webhook for ${validData.path}`); + await client.projects.hooks.add(project.id, { + url: gitlabConfig.webhook.publicUrl, + token: gitlabConfig.webhook.secret, + enable_ssl_verification: true, + // TODO: Determine which of these actually interests the user. + issues_events: true, + merge_requests_events: true, + push_events: true, + releases_events: true, + tag_push_events: true, + wiki_page_events: true, + }); + } + } else if (!gitlabConfig.webhook.publicUrl) { + log.info(`Not creating webhook, webhookUrl is not defined in config`); + } else { + warning = { + header: "Cannot create webhook", + message: + "You have insufficient permissions on this project to provision a webhook for it. Ask a Maintainer or Owner of the project to add the webhook for you.", + }; + log.warn( + `Not creating webhook, permission level is insufficient (${permissionLevel} < ${AccessLevel.Maintainer})`, + ); } - - public static getProvisionerDetails(botUserId: string) { - return { - service: "gitlab", - eventType: GitLabRepoConnection.CanonicalEventType, - type: "GitLabRepo", - botUserId, + await new GitLabGrantChecker(as, gitlabConfig, tokenStore).grantConnection( + roomId, + { instance: validData.instance, path: validData.path }, + ); + await intent.underlyingClient.sendStateEvent( + roomId, + this.CanonicalEventType, + connection.stateKey, + validData, + ); + return { connection, warning }; + } + + public static getProvisionerDetails(botUserId: string) { + return { + service: "gitlab", + eventType: GitLabRepoConnection.CanonicalEventType, + type: "GitLabRepo", + botUserId, + }; + } + + public static async getBase64Avatar( + avatarUrl: string, + client: GitLabClient, + storage: IBridgeStorageProvider, + ): Promise { + try { + const existingFile = await storage.getStoredTempFile(avatarUrl); + if (existingFile) { + return existingFile; + } + const res = await client.get(avatarUrl); + if (res.status !== 200) { + return null; + } + const contentType = res.headers["content-type"]; + if (!contentType?.startsWith("image/")) { + return null; + } + const data = res.data as Buffer; + const url = `data:${contentType};base64,${data.toString("base64")}`; + await storage.setStoredTempFile(avatarUrl, url); + return url; + } catch (ex) { + if (axios.isAxiosError(ex)) { + if (ex.response?.status === 401) { + // 401 means that the project is Private and GitLab haven't fixed + // the auth issues, just ignore this one. + // https://gitlab.com/gitlab-org/gitlab/-/issues/25498 + return null; } + } + log.warn(`Could not transform data from ${avatarUrl} into base64`, ex); + return null; } - - public static async getBase64Avatar(avatarUrl: string, client: GitLabClient, storage: IBridgeStorageProvider): Promise { - try { - const existingFile = await storage.getStoredTempFile(avatarUrl); - if (existingFile) { - return existingFile; - } - const res = await client.get(avatarUrl); - if (res.status !== 200) { - return null; - } - const contentType = res.headers["content-type"]; - if (!contentType?.startsWith("image/")) { - return null; - } - const data = res.data as Buffer; - const url = `data:${contentType};base64,${data.toString('base64')}`; - await storage.setStoredTempFile(avatarUrl, url); - return url; - } catch (ex) { - if (axios.isAxiosError(ex)) { - if (ex.response?.status === 401) { - // 401 means that the project is Private and GitLab haven't fixed - // the auth issues, just ignore this one. - // https://gitlab.com/gitlab-org/gitlab/-/issues/25498 - return null; - } - } - log.warn(`Could not transform data from ${avatarUrl} into base64`, ex); - return null; + } + + public static async getConnectionTargets( + userId: string, + config: BridgeConfigGitLab, + filters: GitLabTargetFilter = {}, + tokenStore: UserTokenStore, + storage: IBridgeStorageProvider, + ): Promise { + // Search for all repos under the user's control. + + if (!filters.instance) { + const results: GitLabRepoConnectionInstanceTarget[] = []; + for (const [name, instance] of Object.entries(config.instances)) { + const client = await tokenStore.getGitLabForUser(userId, instance.url); + if (client) { + results.push({ + name, + }); } + } + return results; } - - public static async getConnectionTargets(userId: string, config: BridgeConfigGitLab, filters: GitLabTargetFilter = {}, tokenStore: UserTokenStore, storage: IBridgeStorageProvider): Promise { - // Search for all repos under the user's control. - - if (!filters.instance) { - const results: GitLabRepoConnectionInstanceTarget[] = []; - for (const [name, instance] of Object.entries(config.instances)) { - const client = await tokenStore.getGitLabForUser(userId, instance.url); - if (client) { - results.push({ - name, - }); - } - } - return results; - } - // If we have an instance, search under it. - const instanceUrl = config.instances[filters.instance]?.url; - const client = instanceUrl && await tokenStore.getGitLabForUser(userId, instanceUrl); - if (!client) { - throw new ApiError('Instance is not known or you do not have access to it.', ErrCode.NotFound); - } - const allProjects = await client.projects.list(AccessLevel.Developer, filters.parent, undefined, filters.search); - return await Promise.all(allProjects.map(async p => ({ - state: { - instance: filters.instance, - path: p.path_with_namespace, - }, - name: p.name, - avatar_url: p.avatar_url && await this.getBase64Avatar(p.avatar_url, client, storage), - description: p.description, - }))) as GitLabRepoConnectionProjectTarget[]; - } - - private readonly debounceMRComments = new Map(); - - private readonly discussionThreads = new QuickLRU>({ maxSize: 100}); - - private readonly hookFilter: HookFilter; - - private readonly grantChecker; - private readonly commentDebounceMs: number; - - constructor( - roomId: string, - stateKey: string, - as: Appservice, - config: BridgeConfigGitLab, - private readonly intent: Intent, - state: ConnectionStateValidated, - private readonly tokenStore: UserTokenStore, - private readonly instance: GitLabInstance, - private readonly storage: IBridgeStorageProvider, - ) { - super( - roomId, - stateKey, - GitLabRepoConnection.CanonicalEventType, - state, - intent.underlyingClient, - GitLabRepoConnection.botCommands, - GitLabRepoConnection.helpMessage, - ["gitlab"], - "!gl", - "gitlab", - ) - this.grantChecker = new GitLabGrantChecker(as, config, tokenStore); - if (!state.path || !state.instance) { - throw Error('Invalid state, missing `path` or `instance`'); - } - this.hookFilter = new HookFilter( - state.enableHooks ?? DefaultHooks, - ); - this.commentDebounceMs = config.commentDebounceMs; + // If we have an instance, search under it. + const instanceUrl = config.instances[filters.instance]?.url; + const client = + instanceUrl && (await tokenStore.getGitLabForUser(userId, instanceUrl)); + if (!client) { + throw new ApiError( + "Instance is not known or you do not have access to it.", + ErrCode.NotFound, + ); } - - public get path() { - return this.state.path.toLowerCase(); + const allProjects = await client.projects.list( + AccessLevel.Developer, + filters.parent, + undefined, + filters.search, + ); + return (await Promise.all( + allProjects.map(async (p) => ({ + state: { + instance: filters.instance, + path: p.path_with_namespace, + }, + name: p.name, + avatar_url: + p.avatar_url && + (await this.getBase64Avatar(p.avatar_url, client, storage)), + description: p.description, + })), + )) as GitLabRepoConnectionProjectTarget[]; + } + + private readonly debounceMRComments = new Map< + string, + { + commentCount: number; + commentNotes?: string[]; + discussions: string[]; + author: string; + timeout: NodeJS.Timeout; + approved?: boolean; + skip?: boolean; } - - /** - * The project's path string as returned by GitLab, - * with the letter casing of the path that the - * project was created with. - */ - public get prettyPath() { - return this.state.path; + >(); + + private readonly discussionThreads = new QuickLRU< + string, + Promise + >({ maxSize: 100 }); + + private readonly hookFilter: HookFilter; + + private readonly grantChecker; + private readonly commentDebounceMs: number; + + constructor( + roomId: string, + stateKey: string, + as: Appservice, + config: BridgeConfigGitLab, + private readonly intent: Intent, + state: ConnectionStateValidated, + private readonly tokenStore: UserTokenStore, + private readonly instance: GitLabInstance, + private readonly storage: IBridgeStorageProvider, + ) { + super( + roomId, + stateKey, + GitLabRepoConnection.CanonicalEventType, + state, + intent.underlyingClient, + GitLabRepoConnection.botCommands, + GitLabRepoConnection.helpMessage, + ["gitlab"], + "!gl", + "gitlab", + ); + this.grantChecker = new GitLabGrantChecker(as, config, tokenStore); + if (!state.path || !state.instance) { + throw Error("Invalid state, missing `path` or `instance`"); } - - public get priority(): number { - return this.state.priority || super.priority; + this.hookFilter = new HookFilter(state.enableHooks ?? DefaultHooks); + this.commentDebounceMs = config.commentDebounceMs; + } + + public get path() { + return this.state.path.toLowerCase(); + } + + /** + * The project's path string as returned by GitLab, + * with the letter casing of the path that the + * project was created with. + */ + public get prettyPath() { + return this.state.path; + } + + public get priority(): number { + return this.state.priority || super.priority; + } + + protected validateConnectionState(content: unknown) { + return GitLabRepoConnection.validateState(content); + } + + public isInterestedInStateEvent(eventType: string, stateKey: string) { + return ( + GitLabRepoConnection.EventTypes.includes(eventType) && + this.stateKey === stateKey + ); + } + + public async onStateUpdate(stateEv: MatrixEvent) { + const validatedState = GitLabRepoConnection.validateState(stateEv.content); + await this.grantChecker.assertConnectionGranted( + this.roomId, + { + instance: validatedState.instance, + path: validatedState.path, + }, + stateEv.sender, + ); + await super.onStateUpdate(stateEv); + this.hookFilter.enabledHooks = this.state.enableHooks; + } + + public getProvisionerDetails(): GitLabRepoResponseItem { + return { + ...GitLabRepoConnection.getProvisionerDetails(this.intent.userId), + id: this.connectionId, + config: { + ...this.state, + }, + }; + } + + private async getClientForUser(userId: string) { + const client = await this.tokenStore.getGitLabForUser( + userId, + this.instance.url, + ); + if (!client) { + throw new CommandError( + "User is not logged into GitLab", + "You must be logged in to create an issue.", + ); } - - protected validateConnectionState(content: unknown) { - return GitLabRepoConnection.validateState(content); + return client; + } + + @botCommand( + "create", + "Create an issue for this repo", + ["title"], + ["description", "labels"], + true, + ) + public async onCreateIssue( + userId: string, + title: string, + description?: string, + labels?: string, + ) { + const client = await this.getClientForUser(userId); + const res = await client.issues.create({ + id: this.path, + title, + description, + labels: labels ? labels.split(",") : undefined, + }); + + const content = `Created issue #${res.iid}: [${res.web_url}](${res.web_url})`; + return this.intent.sendEvent(this.roomId, { + msgtype: "m.notice", + body: content, + formatted_body: md.render(content), + format: "org.matrix.custom.html", + }); + } + + @botCommand( + "create-confidential", + "Create a confidental issue for this repo", + ["title"], + ["description", "labels"], + true, + ) + public async onCreateConfidentialIssue( + userId: string, + title: string, + description?: string, + labels?: string, + ) { + const client = await this.getClientForUser(userId); + const res = await client.issues.create({ + id: this.path, + title, + description, + confidential: true, + labels: labels ? labels.split(",") : undefined, + }); + + const content = `Created confidential issue #${res.iid}: [${res.web_url}](${res.web_url})`; + return this.intent.sendEvent(this.roomId, { + msgtype: "m.notice", + body: content, + formatted_body: md.render(content), + format: "org.matrix.custom.html", + }); + } + + @botCommand("close", "Close an issue", ["number"], ["comment"], true) + public async onClose(userId: string, number: string) { + const client = await this.getClientForUser(userId); + + await client.issues.edit({ + id: this.state.path, + issue_iid: number, + state_event: "close", + }); + } + + private validateMREvent(event: IGitLabWebhookMREvent) { + if (!event.object_attributes) { + throw Error("No merge_request content!"); } - - public isInterestedInStateEvent(eventType: string, stateKey: string) { - return GitLabRepoConnection.EventTypes.includes(eventType) && this.stateKey === stateKey; + if (!event.project) { + throw Error("No repository content!"); } + } - public async onStateUpdate(stateEv: MatrixEvent) { - const validatedState = GitLabRepoConnection.validateState(stateEv.content); - await this.grantChecker.assertConnectionGranted(this.roomId, { - instance: validatedState.instance, - path: validatedState.path, - } , stateEv.sender); - await super.onStateUpdate(stateEv); - this.hookFilter.enabledHooks = this.state.enableHooks; + public async onMergeRequestOpened(event: IGitLabWebhookMREvent) { + if ( + this.hookFilter.shouldSkip("merge_request", "merge_request.open") || + !this.matchesLabelFilter(event) + ) { + return; } - - public getProvisionerDetails(): GitLabRepoResponseItem { - return { - ...GitLabRepoConnection.getProvisionerDetails(this.intent.userId), - id: this.connectionId, - config: { - ...this.state, - }, - } + log.info( + `onMergeRequestOpened ${this.roomId} ${this.path} !${event.object_attributes.iid}`, + ); + this.validateMREvent(event); + const orgRepoName = event.project.path_with_namespace; + const content = `**${event.user.username}** opened a new MR [${orgRepoName}!${event.object_attributes.iid}](${event.object_attributes.url}): "${event.object_attributes.title}"`; + await this.intent.sendEvent(this.roomId, { + msgtype: "m.notice", + body: content, + formatted_body: md.renderInline(content), + format: "org.matrix.custom.html", + }); + } + + public async onMergeRequestReopened(event: IGitLabWebhookMREvent) { + if ( + this.hookFilter.shouldSkip("merge_request", "merge_request.reopen") || + !this.matchesLabelFilter(event) + ) { + return; } - - private async getClientForUser(userId: string) { - const client = await this.tokenStore.getGitLabForUser(userId, this.instance.url); - if (!client) { - throw new CommandError('User is not logged into GitLab', 'You must be logged in to create an issue.'); - } - return client; + log.info( + `onMergeRequestReopened ${this.roomId} ${this.path} !${event.object_attributes.iid}`, + ); + this.validateMREvent(event); + const orgRepoName = event.project.path_with_namespace; + const content = `**${event.user.username}** reopened MR [${orgRepoName}!${event.object_attributes.iid}](${event.object_attributes.url}): "${event.object_attributes.title}"`; + await this.intent.sendEvent(this.roomId, { + msgtype: "m.notice", + body: content, + formatted_body: md.renderInline(content), + format: "org.matrix.custom.html", + }); + } + + public async onMergeRequestClosed(event: IGitLabWebhookMREvent) { + if ( + this.hookFilter.shouldSkip("merge_request", "merge_request.close") || + !this.matchesLabelFilter(event) + ) { + return; } - - @botCommand("create", "Create an issue for this repo", ["title"], ["description", "labels"], true) - public async onCreateIssue(userId: string, title: string, description?: string, labels?: string) { - const client = await this.getClientForUser(userId); - const res = await client.issues.create({ - id: this.path, - title, - description, - labels: labels ? labels.split(",") : undefined, - }); - - const content = `Created issue #${res.iid}: [${res.web_url}](${res.web_url})`; - return this.intent.sendEvent(this.roomId,{ - msgtype: "m.notice", - body: content, - formatted_body: md.render(content), - format: "org.matrix.custom.html" - }); + log.info( + `onMergeRequestClosed ${this.roomId} ${this.path} !${event.object_attributes.iid}`, + ); + this.validateMREvent(event); + const orgRepoName = event.project.path_with_namespace; + const content = `**${event.user.username}** closed MR [${orgRepoName}!${event.object_attributes.iid}](${event.object_attributes.url}): "${event.object_attributes.title}"`; + await this.intent.sendEvent(this.roomId, { + msgtype: "m.notice", + body: content, + formatted_body: md.renderInline(content), + format: "org.matrix.custom.html", + }); + } + + public async onMergeRequestMerged(event: IGitLabWebhookMREvent) { + if ( + this.hookFilter.shouldSkip("merge_request", "merge_request.merge") || + !this.matchesLabelFilter(event) + ) { + return; } - - @botCommand("create-confidential", "Create a confidental issue for this repo", ["title"], ["description", "labels"], true) - public async onCreateConfidentialIssue(userId: string, title: string, description?: string, labels?: string) { - const client = await this.getClientForUser(userId); - const res = await client.issues.create({ - id: this.path, - title, - description, - confidential: true, - labels: labels ? labels.split(",") : undefined, - }); - - const content = `Created confidential issue #${res.iid}: [${res.web_url}](${res.web_url})`; - return this.intent.sendEvent(this.roomId,{ - msgtype: "m.notice", - body: content, - formatted_body: md.render(content), - format: "org.matrix.custom.html" - }); + log.info( + `onMergeRequestMerged ${this.roomId} ${this.path} !${event.object_attributes.iid}`, + ); + this.validateMREvent(event); + const orgRepoName = event.project.path_with_namespace; + const content = `**${event.user.username}** merged MR [${orgRepoName}!${event.object_attributes.iid}](${event.object_attributes.url}): "${event.object_attributes.title}"`; + await this.intent.sendEvent(this.roomId, { + msgtype: "m.notice", + body: content, + formatted_body: md.renderInline(content), + format: "org.matrix.custom.html", + }); + } + + public async onMergeRequestUpdate(event: IGitLabWebhookMREvent) { + if ( + this.hookFilter.shouldSkip( + "merge_request", + "merge_request.ready_for_review", + ) + ) { + return; } - - @botCommand("close", "Close an issue", ["number"], ["comment"], true) - public async onClose(userId: string, number: string) { - const client = await this.getClientForUser(userId); - - await client.issues.edit({ - id: this.state.path, - issue_iid: number, - state_event: "close", - }); + log.info( + `onMergeRequestUpdate ${this.roomId} ${this.instance}/${this.path} !${event.object_attributes.iid}`, + ); + this.validateMREvent(event); + // Check if the MR changed to / from a draft + if (!event.changes.draft) { + return; } - - private validateMREvent(event: IGitLabWebhookMREvent) { - if (!event.object_attributes) { - throw Error('No merge_request content!'); - } - if (!event.project) { - throw Error('No repository content!'); - } + const orgRepoName = event.project.path_with_namespace; + let content: string; + const isDraft = event.changes.draft.current; + if (!isDraft) { + // Ready for review + content = `**${event.user.username}** marked MR [${orgRepoName}!${event.object_attributes.iid}](${event.object_attributes.url}) as ready for review "${event.object_attributes.title}" `; + } else { + // Back to draft. + content = `**${event.user.username}** marked MR [${orgRepoName}!${event.object_attributes.iid}](${event.object_attributes.url}) as draft "${event.object_attributes.title}" `; } - - public async onMergeRequestOpened(event: IGitLabWebhookMREvent) { - if (this.hookFilter.shouldSkip('merge_request', 'merge_request.open') || !this.matchesLabelFilter(event)) { - return; - } - log.info(`onMergeRequestOpened ${this.roomId} ${this.path} !${event.object_attributes.iid}`); - this.validateMREvent(event); - const orgRepoName = event.project.path_with_namespace; - const content = `**${event.user.username}** opened a new MR [${orgRepoName}!${event.object_attributes.iid}](${event.object_attributes.url}): "${event.object_attributes.title}"`; - await this.intent.sendEvent(this.roomId, { - msgtype: "m.notice", - body: content, - formatted_body: md.renderInline(content), - format: "org.matrix.custom.html", - }); + await this.intent.sendEvent(this.roomId, { + msgtype: "m.notice", + body: content, + formatted_body: md.renderInline(content), + format: "org.matrix.custom.html", + }); + } + + public async onGitLabTagPush(event: IGitLabWebhookTagPushEvent) { + if (this.hookFilter.shouldSkip("tag_push")) { + return; } - - public async onMergeRequestReopened(event: IGitLabWebhookMREvent) { - if (this.hookFilter.shouldSkip('merge_request', 'merge_request.reopen') || !this.matchesLabelFilter(event)) { - return; - } - log.info(`onMergeRequestReopened ${this.roomId} ${this.path} !${event.object_attributes.iid}`); - this.validateMREvent(event); - const orgRepoName = event.project.path_with_namespace; - const content = `**${event.user.username}** reopened MR [${orgRepoName}!${event.object_attributes.iid}](${event.object_attributes.url}): "${event.object_attributes.title}"`; - await this.intent.sendEvent(this.roomId, { - msgtype: "m.notice", - body: content, - formatted_body: md.renderInline(content), - format: "org.matrix.custom.html", - }); + log.info( + `onGitLabTagPush ${this.roomId} ${this.instance.url}/${this.path} ${event.ref}`, + ); + const tagname = event.ref.replace("refs/tags/", ""); + if (this.state.pushTagsRegex && !tagname.match(this.state.pushTagsRegex)) { + return; } - - public async onMergeRequestClosed(event: IGitLabWebhookMREvent) { - if (this.hookFilter.shouldSkip('merge_request', 'merge_request.close') || !this.matchesLabelFilter(event)) { - return; - } - log.info(`onMergeRequestClosed ${this.roomId} ${this.path} !${event.object_attributes.iid}`); - this.validateMREvent(event); - const orgRepoName = event.project.path_with_namespace; - const content = `**${event.user.username}** closed MR [${orgRepoName}!${event.object_attributes.iid}](${event.object_attributes.url}): "${event.object_attributes.title}"`; - await this.intent.sendEvent(this.roomId, { - msgtype: "m.notice", - body: content, - formatted_body: md.renderInline(content), - format: "org.matrix.custom.html", - }); + const url = `${event.project.homepage}/-/tree/${tagname}`; + const content = `**${event.user_name}** pushed tag [\`${tagname}\`](${url}) for ${event.project.path_with_namespace}`; + await this.intent.sendEvent(this.roomId, { + msgtype: "m.notice", + body: content, + formatted_body: md.renderInline(content), + format: "org.matrix.custom.html", + }); + } + + public async onGitLabPush(event: IGitLabWebhookPushEvent) { + if (this.hookFilter.shouldSkip("push")) { + return; } - - public async onMergeRequestMerged(event: IGitLabWebhookMREvent) { - if (this.hookFilter.shouldSkip('merge_request', 'merge_request.merge') || !this.matchesLabelFilter(event)) { - return; - } - log.info(`onMergeRequestMerged ${this.roomId} ${this.path} !${event.object_attributes.iid}`); - this.validateMREvent(event); - const orgRepoName = event.project.path_with_namespace; - const content = `**${event.user.username}** merged MR [${orgRepoName}!${event.object_attributes.iid}](${event.object_attributes.url}): "${event.object_attributes.title}"`; - await this.intent.sendEvent(this.roomId, { - msgtype: "m.notice", - body: content, - formatted_body: md.renderInline(content), - format: "org.matrix.custom.html", - }); + log.info( + `onGitLabPush ${this.roomId} ${this.instance.url}/${this.path} ${event.after}`, + ); + const branchname = event.ref.replace("refs/heads/", ""); + const commitsurl = `${event.project.homepage}/-/commits/${branchname}`; + const branchurl = `${event.project.homepage}/-/tree/${branchname}`; + const shouldName = !event.commits.every( + (c) => c.author.email === event.user_email, + ); + + const tooManyCommits = event.total_commits_count > PUSH_MAX_COMMITS; + const displayedCommits = tooManyCommits + ? 1 + : Math.min(event.total_commits_count, PUSH_MAX_COMMITS); + + // Take the top 5 commits. The array is ordered in reverse. + const commits = event.commits + .reverse() + .slice(0, displayedCommits) + .map((commit) => { + return `[\`${commit.id.slice(0, 8)}\`](${event.project.homepage}/-/commit/${commit.id}) ${commit.title}${shouldName ? ` by ${commit.author.name}` : ""}`; + }) + .join("\n - "); + + let content = + `**${event.user_name}** pushed [${event.total_commits_count} commit${event.total_commits_count > 1 ? "s" : ""}](${commitsurl})` + + ` to [\`${branchname}\`](${branchurl}) for ${event.project.path_with_namespace}`; + + if (displayedCommits >= 2) { + content += `\n - ${commits}\n`; + } else if (displayedCommits === 1) { + content += `: ${commits}`; + if (tooManyCommits) { + content += `, and [${event.total_commits_count - 1} more](${commitsurl}) commits`; + } } - public async onMergeRequestUpdate(event: IGitLabWebhookMREvent) { - if (this.hookFilter.shouldSkip('merge_request', 'merge_request.ready_for_review')) { - return; - } - log.info(`onMergeRequestUpdate ${this.roomId} ${this.instance}/${this.path} !${event.object_attributes.iid}`); - this.validateMREvent(event); - // Check if the MR changed to / from a draft - if (!event.changes.draft) { - return; - } - const orgRepoName = event.project.path_with_namespace; - let content: string; - const isDraft = event.changes.draft.current; - if (!isDraft) { - // Ready for review - content = `**${event.user.username}** marked MR [${orgRepoName}!${event.object_attributes.iid}](${event.object_attributes.url}) as ready for review "${event.object_attributes.title}" `; - } else { - // Back to draft. - content = `**${event.user.username}** marked MR [${orgRepoName}!${event.object_attributes.iid}](${event.object_attributes.url}) as draft "${event.object_attributes.title}" `; - } - await this.intent.sendEvent(this.roomId, { - msgtype: "m.notice", - body: content, - formatted_body: md.renderInline(content), - format: "org.matrix.custom.html", - }); + await this.intent.sendEvent(this.roomId, { + msgtype: "m.notice", + body: content, + formatted_body: md.render(content), + format: "org.matrix.custom.html", + }); + } + + public async onWikiPageEvent(data: IGitLabWebhookWikiPageEvent) { + const attributes = data.object_attributes; + if (this.hookFilter.shouldSkip("wiki", `wiki.${attributes.action}`)) { + return; } - - public async onGitLabTagPush(event: IGitLabWebhookTagPushEvent) { - if (this.hookFilter.shouldSkip('tag_push')) { - return; - } - log.info(`onGitLabTagPush ${this.roomId} ${this.instance.url}/${this.path} ${event.ref}`); - const tagname = event.ref.replace("refs/tags/", ""); - if (this.state.pushTagsRegex && !tagname.match(this.state.pushTagsRegex)) { - return; - } - const url = `${event.project.homepage}/-/tree/${tagname}`; - const content = `**${event.user_name}** pushed tag [\`${tagname}\`](${url}) for ${event.project.path_with_namespace}`; - await this.intent.sendEvent(this.roomId, { - msgtype: "m.notice", - body: content, - formatted_body: md.renderInline(content), - format: "org.matrix.custom.html", - }); + log.info(`onWikiPageEvent ${this.roomId} ${this.instance}/${this.path}`); + + let statement: string; + if (attributes.action === "create") { + statement = "created new wiki page"; + } else if (attributes.action === "delete") { + statement = "deleted wiki page"; + } else { + statement = "updated wiki page"; } + const message = attributes.message && ` "${attributes.message}"`; - public async onGitLabPush(event: IGitLabWebhookPushEvent) { - if (this.hookFilter.shouldSkip('push')) { - return; - } - log.info(`onGitLabPush ${this.roomId} ${this.instance.url}/${this.path} ${event.after}`); - const branchname = event.ref.replace("refs/heads/", ""); - const commitsurl = `${event.project.homepage}/-/commits/${branchname}`; - const branchurl = `${event.project.homepage}/-/tree/${branchname}`; - const shouldName = !event.commits.every(c => c.author.email === event.user_email); - - const tooManyCommits = event.total_commits_count > PUSH_MAX_COMMITS; - const displayedCommits = tooManyCommits ? 1 : Math.min(event.total_commits_count, PUSH_MAX_COMMITS); - - // Take the top 5 commits. The array is ordered in reverse. - const commits = event.commits.reverse().slice(0,displayedCommits).map(commit => { - return `[\`${commit.id.slice(0,8)}\`](${event.project.homepage}/-/commit/${commit.id}) ${commit.title}${shouldName ? ` by ${commit.author.name}` : ""}`; - }).join('\n - '); - - let content = `**${event.user_name}** pushed [${event.total_commits_count} commit${event.total_commits_count > 1 ? "s": ""}](${commitsurl})` - + ` to [\`${branchname}\`](${branchurl}) for ${event.project.path_with_namespace}`; - - if (displayedCommits >= 2) { - content += `\n - ${commits}\n`; - } else if (displayedCommits === 1) { - content += `: ${commits}`; - if (tooManyCommits) { - content += `, and [${event.total_commits_count - 1} more](${commitsurl}) commits`; - } - } + const content = `**${data.user.username}** ${statement} "[${attributes.title}](${attributes.url})" for ${data.project.path_with_namespace} ${message}`; + await this.intent.sendEvent(this.roomId, { + msgtype: "m.notice", + body: content, + formatted_body: md.renderInline(content), + format: "org.matrix.custom.html", + }); + } - await this.intent.sendEvent(this.roomId, { - msgtype: "m.notice", - body: content, - formatted_body: md.render(content), - format: "org.matrix.custom.html", - }); + public async onRelease(data: IGitLabWebhookReleaseEvent) { + if (this.hookFilter.shouldSkip("release", "release.created")) { + return; } + log.info(`onReleaseCreated ${this.roomId} ${this.toString()} ${data.tag}`); + const orgRepoName = data.project.path_with_namespace; + const content = `**${data.commit.author.name}** 🪄 released [${data.name}](${data.url}) for ${orgRepoName} - public async onWikiPageEvent(data: IGitLabWebhookWikiPageEvent) { - const attributes = data.object_attributes; - if (this.hookFilter.shouldSkip('wiki', `wiki.${attributes.action}`)) { - return; - } - log.info(`onWikiPageEvent ${this.roomId} ${this.instance}/${this.path}`); - - let statement: string; - if (attributes.action === "create") { - statement = "created new wiki page"; - } else if (attributes.action === "delete") { - statement = "deleted wiki page"; - } else { - statement = "updated wiki page"; - } - - const message = attributes.message && ` "${attributes.message}"`; - - const content = `**${data.user.username}** ${statement} "[${attributes.title}](${attributes.url})" for ${data.project.path_with_namespace} ${message}`; - await this.intent.sendEvent(this.roomId, { - msgtype: "m.notice", - body: content, - formatted_body: md.renderInline(content), - format: "org.matrix.custom.html", - }); +${data.description}`; + await this.intent.sendEvent(this.roomId, { + msgtype: "m.notice", + body: content, + formatted_body: md.render(content), + format: "org.matrix.custom.html", + }); + } + + private async renderDebouncedMergeRequest( + uniqueId: string, + mergeRequest: IGitlabMergeRequest, + project: IGitlabProject, + ) { + const result = this.debounceMRComments.get(uniqueId); + if (!result) { + // Always defined, but for type checking purposes. + return; + } + // Delete after use. + this.debounceMRComments.delete(uniqueId); + const orgRepoName = project.path_with_namespace; + let comments = ""; + if (result.commentCount === 1) { + comments = " with one comment"; + } else if (result.commentCount > 1) { + comments = ` with ${result.commentCount} comments`; } - public async onRelease(data: IGitLabWebhookReleaseEvent) { - if (this.hookFilter.shouldSkip('release', 'release.created')) { - return; - } - log.info(`onReleaseCreated ${this.roomId} ${this.toString()} ${data.tag}`); - const orgRepoName = data.project.path_with_namespace; - const content = `**${data.commit.author.name}** 🪄 released [${data.name}](${data.url}) for ${orgRepoName} - -${data.description}`; - await this.intent.sendEvent(this.roomId, { - msgtype: "m.notice", - body: content, - formatted_body: md.render(content), - format: "org.matrix.custom.html", + 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", + }, + }; + } } - private async renderDebouncedMergeRequest(uniqueId: string, mergeRequest: IGitlabMergeRequest, project: IGitlabProject) { - const result = this.debounceMRComments.get(uniqueId); - if (!result) { - // Always defined, but for type checking purposes. - return; - } - // Delete after use. - this.debounceMRComments.delete(uniqueId); - const orgRepoName = project.path_with_namespace; - let comments = ''; - if (result.commentCount === 1) { - comments = ' with one comment'; - } else if (result.commentCount > 1) { - comments = ` with ${result.commentCount} comments`; - } - - 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 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}`; - - let formatted = ''; - if (result.commentNotes) { - content += "\n\n> " + result.commentNotes.join("\n\n> "); - formatted = md.render(content); - } else { - formatted = md.renderInline(content); - } - - const eventPromise = this.intent.sendEvent(this.roomId, { - msgtype: "m.notice", - body: content, - formatted_body: formatted, - format: "org.matrix.custom.html", - ...relation, - }).catch(ex => { - log.error('Failed to send MR review message', ex); - return undefined; - }); + 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"; + } - 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); - }); + const target = relation + ? "" + : ` MR [${orgRepoName}!${mergeRequest.iid}](${mergeRequest.url}): "${mergeRequest.title}"`; + let content = `**${result.author}** ${action}${target} ${comments}`; + + let formatted = ""; + if (result.commentNotes) { + content += "\n\n> " + result.commentNotes.join("\n\n> "); + formatted = md.render(content); + } else { + formatted = md.renderInline(content); } - private debounceMergeRequestReview( - user: IGitlabUser, - mergeRequest: IGitlabMergeRequest, - project: IGitlabProject, - opts: { - commentCount: number, - commentNotes?: string[], - approved?: boolean, - discussionId?: string, - /** - * If the MR contains only comments, skip it. - */ - skip: boolean, - } + const eventPromise = this.intent + .sendEvent(this.roomId, { + msgtype: "m.notice", + body: content, + formatted_body: formatted, + 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, + ); + }); + } + + private debounceMergeRequestReview( + user: IGitlabUser, + mergeRequest: IGitlabMergeRequest, + project: IGitlabProject, + opts: { + commentCount: number; + commentNotes?: string[]; + approved?: boolean; + discussionId?: string; + /** + * If the MR contains only comments, skip it. + */ + skip: boolean; + }, + ) { + const { commentCount, commentNotes, approved } = opts; + const uniqueId = `${mergeRequest?.iid}/${user.username}`; + const existing = this.debounceMRComments.get(uniqueId); + if (existing) { + clearTimeout(existing.timeout); + existing.approved = approved; + if (commentNotes) { + existing.commentNotes = [ + ...(existing.commentNotes ?? []), + ...commentNotes, + ]; + } + existing.commentCount += opts.commentCount; + if (!opts.skip) { + existing.skip = false; + } + 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), + this.commentDebounceMs, + ), + }); + } + + public async onMergeRequestReviewed(event: IGitLabWebhookMREvent) { + if ( + this.hookFilter.shouldSkip( + "merge_request", + "merge_request.review", + `merge_request.${event.object_attributes.action}`, + ) || + !this.matchesLabelFilter(event) ) { - const { commentCount, commentNotes, approved } = opts; - const uniqueId = `${mergeRequest?.iid}/${user.username}`; - const existing = this.debounceMRComments.get(uniqueId); - if (existing) { - clearTimeout(existing.timeout); - existing.approved = approved; - if (commentNotes) { - existing.commentNotes = [...(existing.commentNotes ?? []), ...commentNotes]; - } - existing.commentCount += opts.commentCount; - if (!opts.skip) { - existing.skip = false; - } - 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), this.commentDebounceMs), - }); + return; } - - public async onMergeRequestReviewed(event: IGitLabWebhookMREvent) { - if (this.hookFilter.shouldSkip('merge_request', 'merge_request.review', `merge_request.${event.object_attributes.action}`) || !this.matchesLabelFilter(event)) { - return; - } - log.info(`onMergeRequestReviewed ${this.roomId} ${this.instance}/${this.path} !${event.object_attributes.iid}`); - this.validateMREvent(event); - this.debounceMergeRequestReview( - event.user, - event.object_attributes, - event.project, - { - commentCount: 0, - approved: "approved" === event.object_attributes.action, - skip: false, - } - ); + 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 onMergeRequestIndividualReview(event: IGitLabWebhookMREvent) { + if ( + this.hookFilter.shouldSkip( + "merge_request", + "merge_request.review.individual", + ) || + !this.matchesLabelFilter(event) + ) { + return; } - - public async onMergeRequestIndividualReview(event: IGitLabWebhookMREvent) { - if (this.hookFilter.shouldSkip('merge_request', 'merge_request.review.individual') || !this.matchesLabelFilter(event)) { - return; - } - - 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, - } - ); + 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 onMergeRequestCommentCreated(event: IGitLabWebhookNoteEvent) { + if ( + !event.merge_request || + event.object_attributes.noteable_type !== "MergeRequest" + ) { + // Not a MR comment + return; } - - public async onMergeRequestCommentCreated(event: IGitLabWebhookNoteEvent) { - if (!event.merge_request || event.object_attributes.noteable_type !== "MergeRequest") { - // Not a MR comment - return; - } - if (this.hookFilter.shouldSkip('merge_request', 'merge_request.review') || !this.matchesLabelFilter(event.merge_request)) { - return; - } - log.info(`onCommentCreated ${this.roomId} ${this.toString()} !${event.merge_request?.iid} ${event.object_attributes.id}`); - - 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'), - }); + if ( + this.hookFilter.shouldSkip("merge_request", "merge_request.review") || + !this.matchesLabelFilter(event.merge_request) + ) { + return; } - - public toString() { - return `GitLabRepo ${this.instance.url}/${this.path}`; + log.info( + `onCommentCreated ${this.roomId} ${this.toString()} !${event.merge_request?.iid} ${event.object_attributes.id}`, + ); + + 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"), + }, + ); + } + + public toString() { + return `GitLabRepo ${this.instance.url}/${this.path}`; + } + + public matchesLabelFilter(itemWithLabels: { + labels?: { title: string }[]; + }): boolean { + const labels = itemWithLabels.labels?.map((l) => l.title) || []; + if (this.state.excludingLabels?.length) { + if (this.state.excludingLabels.find((l) => labels.includes(l))) { + return false; + } } - - public matchesLabelFilter(itemWithLabels: {labels?: {title: string}[]}): boolean { - const labels = itemWithLabels.labels?.map(l => l.title) || []; - if (this.state.excludingLabels?.length) { - if (this.state.excludingLabels.find(l => labels.includes(l))) { - return false; - } - } - if (this.state.includingLabels?.length) { - return !!this.state.includingLabels.find(l => labels.includes(l)); - } - return true; - } - - public async provisionerUpdateConfig(userId: string, config: Record) { - // Apply previous state to the current config, as provisioners might not return "unknown" keys. - config = { ...this.state, ...config }; - const validatedConfig = GitLabRepoConnection.validateState(config); - await this.intent.underlyingClient.sendStateEvent(this.roomId, GitLabRepoConnection.CanonicalEventType, this.stateKey, validatedConfig); - this.state = validatedConfig; - this.hookFilter.enabledHooks = this.state.enableHooks; - } - - public async onRemove() { - log.info(`Removing ${this.toString()} for ${this.roomId}`); - await this.grantChecker.ungrantConnection(this.roomId, { instance: this.state.instance, path: this.path }); - // Do a sanity check that the event exists. - try { - await this.intent.underlyingClient.getRoomStateEvent(this.roomId, GitLabRepoConnection.CanonicalEventType, this.stateKey); - await this.intent.underlyingClient.sendStateEvent(this.roomId, GitLabRepoConnection.CanonicalEventType, this.stateKey, { disabled: true }); - } catch (ex) { - await this.intent.underlyingClient.getRoomStateEvent(this.roomId, GitLabRepoConnection.LegacyCanonicalEventType, this.stateKey); - await this.intent.underlyingClient.sendStateEvent(this.roomId, GitLabRepoConnection.LegacyCanonicalEventType, this.stateKey, { disabled: true }); - } - // TODO: Clean up webhooks + if (this.state.includingLabels?.length) { + return !!this.state.includingLabels.find((l) => labels.includes(l)); } - - private setDiscussionThreads(discussionThreads: SerializedGitlabDiscussionThreads): void { - for (const { discussionId, eventId } of discussionThreads) { - this.discussionThreads.set(discussionId, Promise.resolve(eventId)); - } + return true; + } + + public async provisionerUpdateConfig( + userId: string, + config: Record, + ) { + // Apply previous state to the current config, as provisioners might not return "unknown" keys. + config = { ...this.state, ...config }; + const validatedConfig = GitLabRepoConnection.validateState(config); + await this.intent.underlyingClient.sendStateEvent( + this.roomId, + GitLabRepoConnection.CanonicalEventType, + this.stateKey, + validatedConfig, + ); + this.state = validatedConfig; + this.hookFilter.enabledHooks = this.state.enableHooks; + } + + public async onRemove() { + log.info(`Removing ${this.toString()} for ${this.roomId}`); + await this.grantChecker.ungrantConnection(this.roomId, { + instance: this.state.instance, + path: this.path, + }); + // Do a sanity check that the event exists. + try { + await this.intent.underlyingClient.getRoomStateEvent( + this.roomId, + GitLabRepoConnection.CanonicalEventType, + this.stateKey, + ); + await this.intent.underlyingClient.sendStateEvent( + this.roomId, + GitLabRepoConnection.CanonicalEventType, + this.stateKey, + { disabled: true }, + ); + } catch (ex) { + await this.intent.underlyingClient.getRoomStateEvent( + this.roomId, + GitLabRepoConnection.LegacyCanonicalEventType, + this.stateKey, + ); + await this.intent.underlyingClient.sendStateEvent( + this.roomId, + GitLabRepoConnection.LegacyCanonicalEventType, + this.stateKey, + { disabled: true }, + ); } - - private async persistDiscussionThreads(): Promise { - 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); + // 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 { + 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. -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const res = compileBotCommands(GitLabRepoConnection.prototype as any, CommandConnection.prototype as any); + +const res = compileBotCommands( + GitLabRepoConnection.prototype as any, + CommandConnection.prototype as any, +); GitLabRepoConnection.helpMessage = res.helpMessage; GitLabRepoConnection.botCommands = res.botCommands; diff --git a/src/Connections/HoundConnection.ts b/src/Connections/HoundConnection.ts index a70c2e444..71c849f47 100644 --- a/src/Connections/HoundConnection.ts +++ b/src/Connections/HoundConnection.ts @@ -2,225 +2,288 @@ import { Intent, StateEvent } from "matrix-bot-sdk"; import markdownit from "markdown-it"; import { BaseConnection } from "./BaseConnection"; import { IConnection, IConnectionState } from "."; -import { Connection, InstantiateConnectionOpts, ProvisionConnectionOpts } from "./IConnection"; +import { + Connection, + InstantiateConnectionOpts, + ProvisionConnectionOpts, +} from "./IConnection"; import { CommandError } from "../errors"; import { IBridgeStorageProvider } from "../Stores/StorageProvider"; import { Logger } from "matrix-appservice-bridge"; export interface HoundConnectionState extends IConnectionState { - challengeId: string; + challengeId: string; } export interface HoundPayload { - activity: HoundActivity, - challengeId: string, + activity: HoundActivity; + challengeId: string; } /** * @url https://documenter.getpostman.com/view/22349866/UzXLzJUV#0913e0b9-9cb5-440e-9d8d-bf6430285ee9 */ export interface HoundActivity { - userId: string, - activityId: string, - participant: string, - /** - * @example "07/26/2022" - */ - date: string, - /** - * @example "2022-07-26T13:49:22Z" - */ - datetime: string, - name: string, - type: string, - /** - * @example strava - */ - app: string, - durationSeconds: number, - /** - * @example "1.39" - */ - distanceKilometers: string, - /** - * @example "0.86" - */ - distanceMiles: string, - /** - * @example "0.86" - */ - elevationMeters: string, - /** - * @example "0.86" - */ - elevationFeet: string, + userId: string; + activityId: string; + participant: string; + /** + * @example "07/26/2022" + */ + date: string; + /** + * @example "2022-07-26T13:49:22Z" + */ + datetime: string; + name: string; + type: string; + /** + * @example strava + */ + app: string; + durationSeconds: number; + /** + * @example "1.39" + */ + distanceKilometers: string; + /** + * @example "0.86" + */ + distanceMiles: string; + /** + * @example "0.86" + */ + elevationMeters: string; + /** + * @example "0.86" + */ + elevationFeet: string; } export interface IChallenge { - id: string; - distance: number; - duration: number; - elevaion: number; + id: string; + distance: number; + duration: number; + elevaion: number; } export interface ILeader { - id: string; - fullname: string; - duration: number; - distance: number; - elevation: number; + id: string; + fullname: string; + duration: number; + distance: number; + elevation: number; } function getEmojiForType(type: string) { - switch (type) { - case "run": - return "🏃"; - case "virtualrun": - return "👨‍💻🏃"; - case "ride": - case "cycle": - case "cycling": - return "🚴"; - case "mountainbikeride": - return "⛰️🚴"; - case "virtualride": - return "👨‍💻🚴"; - case "walk": - case "hike": - return "🚶"; - case "skateboard": - return "🛹"; - case "virtualwalk": - case "virtualhike": - return "👨‍💻🚶"; - case "alpineski": - return "⛷️"; - case "swim": - return "🏊"; - default: - return "🕴️"; - } + switch (type) { + case "run": + return "🏃"; + case "virtualrun": + return "👨‍💻🏃"; + case "ride": + case "cycle": + case "cycling": + return "🚴"; + case "mountainbikeride": + return "⛰️🚴"; + case "virtualride": + return "👨‍💻🚴"; + case "walk": + case "hike": + return "🚶"; + case "skateboard": + return "🛹"; + case "virtualwalk": + case "virtualhike": + return "👨‍💻🚶"; + case "alpineski": + return "⛷️"; + case "swim": + return "🏊"; + default: + return "🕴️"; + } } const log = new Logger("HoundConnection"); const md = markdownit(); @Connection export class HoundConnection extends BaseConnection implements IConnection { - static readonly CanonicalEventType = "uk.half-shot.matrix-hookshot.challengehound.activity"; - static readonly LegacyEventType = "uk.half-shot.matrix-challenger.activity"; // Magically import from matrix-challenger - - static readonly EventTypes = [ - HoundConnection.CanonicalEventType, - HoundConnection.LegacyEventType, - ]; - static readonly ServiceCategory = "challengehound"; - - public static getIdFromURL(url: string): string { - const parts = new URL(url).pathname.split('/'); - return parts[parts.length-1]; - } + static readonly CanonicalEventType = + "uk.half-shot.matrix-hookshot.challengehound.activity"; + static readonly LegacyEventType = "uk.half-shot.matrix-challenger.activity"; // Magically import from matrix-challenger - public static validateState(data: Record): HoundConnectionState { - // Convert URL to ID. - if (!data.challengeId && data.url && typeof data.url === "string") { - data.challengeId = this.getIdFromURL(data.url); - } + static readonly EventTypes = [ + HoundConnection.CanonicalEventType, + HoundConnection.LegacyEventType, + ]; + static readonly ServiceCategory = "challengehound"; - // Test for v1 uuid. - if (!data.challengeId || typeof data.challengeId !== "string" || !/^\w{8}(?:-\w{4}){3}-\w{12}$/.test(data.challengeId)) { - throw Error('Missing or invalid id'); - } + public static getIdFromURL(url: string): string { + const parts = new URL(url).pathname.split("/"); + return parts[parts.length - 1]; + } - return { - challengeId: data.challengeId - } + public static validateState( + data: Record, + ): HoundConnectionState { + // Convert URL to ID. + if (!data.challengeId && data.url && typeof data.url === "string") { + data.challengeId = this.getIdFromURL(data.url); } - public static createConnectionForState(roomId: string, event: StateEvent>, {config, intent, storage}: InstantiateConnectionOpts) { - if (!config.challengeHound) { - throw Error('Challenge hound is not configured'); - } - return new HoundConnection(roomId, event.stateKey, this.validateState(event.content), intent, storage); + // Test for v1 uuid. + if ( + !data.challengeId || + typeof data.challengeId !== "string" || + !/^\w{8}(?:-\w{4}){3}-\w{12}$/.test(data.challengeId) + ) { + throw Error("Missing or invalid id"); } - static async provisionConnection(roomId: string, _userId: string, data: Record = {}, {intent, config, storage}: ProvisionConnectionOpts) { - if (!config.challengeHound) { - throw Error('Challenge hound is not configured'); - } - const validState = this.validateState(data); - // Check the event actually exists. - const statusDataRequest = await fetch(`https://api.challengehound.com/challenges/${validState.challengeId}/status`); - if (!statusDataRequest.ok) { - throw new CommandError(`Fetch failed, status ${statusDataRequest.status}`, "Challenge could not be found. Is it active?"); - } - const { challengeName } = await statusDataRequest.json() as {challengeName: string}; - const connection = new HoundConnection(roomId, validState.challengeId, validState, intent, storage); - await intent.underlyingClient.sendStateEvent(roomId, HoundConnection.CanonicalEventType, validState.challengeId, validState); - return { - connection, - stateEventContent: validState, - challengeName, - }; - } + return { + challengeId: data.challengeId, + }; + } - constructor( - roomId: string, - stateKey: string, - private state: HoundConnectionState, - private readonly intent: Intent, - private readonly storage: IBridgeStorageProvider) { - super(roomId, stateKey, HoundConnection.CanonicalEventType) + public static createConnectionForState( + roomId: string, + event: StateEvent>, + { config, intent, storage }: InstantiateConnectionOpts, + ) { + if (!config.challengeHound) { + throw Error("Challenge hound is not configured"); } + return new HoundConnection( + roomId, + event.stateKey, + this.validateState(event.content), + intent, + storage, + ); + } - public isInterestedInStateEvent() { - return false; // We don't support state-updates...yet. + static async provisionConnection( + roomId: string, + _userId: string, + data: Record = {}, + { intent, config, storage }: ProvisionConnectionOpts, + ) { + if (!config.challengeHound) { + throw Error("Challenge hound is not configured"); } - - public get challengeId() { - return this.state.challengeId; + const validState = this.validateState(data); + // Check the event actually exists. + const statusDataRequest = await fetch( + `https://api.challengehound.com/challenges/${validState.challengeId}/status`, + ); + if (!statusDataRequest.ok) { + throw new CommandError( + `Fetch failed, status ${statusDataRequest.status}`, + "Challenge could not be found. Is it active?", + ); } + const { challengeName } = (await statusDataRequest.json()) as { + challengeName: string; + }; + const connection = new HoundConnection( + roomId, + validState.challengeId, + validState, + intent, + storage, + ); + await intent.underlyingClient.sendStateEvent( + roomId, + HoundConnection.CanonicalEventType, + validState.challengeId, + validState, + ); + return { + connection, + stateEventContent: validState, + challengeName, + }; + } - public get priority(): number { - return this.state.priority || super.priority; - } + constructor( + roomId: string, + stateKey: string, + private state: HoundConnectionState, + private readonly intent: Intent, + private readonly storage: IBridgeStorageProvider, + ) { + super(roomId, stateKey, HoundConnection.CanonicalEventType); + } - public async handleNewActivity(activity: HoundActivity) { - log.info(`New activity recorded ${activity.activityId}`); - const existingActivityEventId = await this.storage.getHoundActivity(this.challengeId, activity.activityId); - const distance = parseFloat(activity.distanceKilometers); - const distanceUnits = `${(distance).toFixed(2)}km`; - const emoji = getEmojiForType(activity.type); - const body = `🎉 **${activity.participant}** completed a ${distanceUnits} ${emoji} ${activity.type} (${activity.name})`; - let content: any = { - body, - format: "org.matrix.custom.html", - formatted_body: md.renderInline(body), - }; - content["msgtype"] = "m.notice"; - content["uk.half-shot.matrix-challenger.activity.id"] = activity.activityId; - content["uk.half-shot.matrix-challenger.activity.distance"] = Math.round(distance * 1000); - content["uk.half-shot.matrix-challenger.activity.elevation"] = Math.round(parseFloat(activity.elevationMeters)); - content["uk.half-shot.matrix-challenger.activity.duration"] = Math.round(activity.durationSeconds); - content["uk.half-shot.matrix-challenger.activity.user"] = { - "name": activity.participant, - id: activity.userId, - }; - if (existingActivityEventId) { - log.debug(`Updating existing activity ${activity.activityId} ${existingActivityEventId}`); - content = { - body: `* ${content.body}`, - msgtype: "m.notice", - "m.new_content": content, - "m.relates_to": { - "event_id": existingActivityEventId, - "rel_type": "m.replace" - }, - }; - } - const eventId = await this.intent.underlyingClient.sendMessage(this.roomId, content); - await this.storage.storeHoundActivityEvent(this.challengeId, activity.activityId, eventId); - } + public isInterestedInStateEvent() { + return false; // We don't support state-updates...yet. + } + + public get challengeId() { + return this.state.challengeId; + } - public toString() { - return `HoundConnection ${this.challengeId}`; + public get priority(): number { + return this.state.priority || super.priority; + } + + public async handleNewActivity(activity: HoundActivity) { + log.info(`New activity recorded ${activity.activityId}`); + const existingActivityEventId = await this.storage.getHoundActivity( + this.challengeId, + activity.activityId, + ); + const distance = parseFloat(activity.distanceKilometers); + const distanceUnits = `${distance.toFixed(2)}km`; + const emoji = getEmojiForType(activity.type); + const body = `🎉 **${activity.participant}** completed a ${distanceUnits} ${emoji} ${activity.type} (${activity.name})`; + let content: any = { + body, + format: "org.matrix.custom.html", + formatted_body: md.renderInline(body), + }; + content["msgtype"] = "m.notice"; + content["uk.half-shot.matrix-challenger.activity.id"] = activity.activityId; + content["uk.half-shot.matrix-challenger.activity.distance"] = Math.round( + distance * 1000, + ); + content["uk.half-shot.matrix-challenger.activity.elevation"] = Math.round( + parseFloat(activity.elevationMeters), + ); + content["uk.half-shot.matrix-challenger.activity.duration"] = Math.round( + activity.durationSeconds, + ); + content["uk.half-shot.matrix-challenger.activity.user"] = { + name: activity.participant, + id: activity.userId, + }; + if (existingActivityEventId) { + log.debug( + `Updating existing activity ${activity.activityId} ${existingActivityEventId}`, + ); + content = { + body: `* ${content.body}`, + msgtype: "m.notice", + "m.new_content": content, + "m.relates_to": { + event_id: existingActivityEventId, + rel_type: "m.replace", + }, + }; } + const eventId = await this.intent.underlyingClient.sendMessage( + this.roomId, + content, + ); + await this.storage.storeHoundActivityEvent( + this.challengeId, + activity.activityId, + eventId, + ); + } + + public toString() { + return `HoundConnection ${this.challengeId}`; + } } diff --git a/src/Connections/IConnection.ts b/src/Connections/IConnection.ts index 78293dcdf..4a621f1be 100644 --- a/src/Connections/IConnection.ts +++ b/src/Connections/IConnection.ts @@ -1,7 +1,12 @@ import { MatrixEvent, MatrixMessageContent } from "../MatrixEvent"; import { IssuesOpenedEvent, IssuesEditedEvent } from "@octokit/webhooks-types"; import { ConnectionWarning, GetConnectionsResponseItem } from "../Widgets/api"; -import { Appservice, Intent, IRichReplyMetadata, StateEvent } from "matrix-bot-sdk"; +import { + Appservice, + Intent, + IRichReplyMetadata, + StateEvent, +} from "matrix-bot-sdk"; import { BridgeConfig, BridgePermissionLevel } from "../config/Config"; import { UserTokenStore } from "../tokens/UserTokenStore"; import { CommentProcessor } from "../CommentProcessor"; @@ -10,120 +15,143 @@ import { IBridgeStorageProvider } from "../Stores/StorageProvider"; import { GithubInstance } from "../github/GithubInstance"; import "reflect-metadata"; -export type PermissionCheckFn = (service: string, level: BridgePermissionLevel) => boolean; +export type PermissionCheckFn = ( + service: string, + level: BridgePermissionLevel, +) => boolean; export interface IConnectionState { - priority?: number; - commandPrefix?: string; + priority?: number; + commandPrefix?: string; } export interface IConnection { - /** - * The roomId that this connection serves. - */ - roomId: string; - - priority: number; - - /** - * Ensures that the current state loaded into the connection has been granted by - * the remote service. I.e. If the room is bridged into a GitHub repository, - * check that the *sender* has permission to bridge it. - * - * If a grant cannot be found, it may be determined by doing an API lookup against - * the remote service. - * - * @param sender The matrix ID of the sender of the event. - * @throws If the grant cannot be found, and cannot be detetermined, this will throw. - */ - ensureGrant?: (sender?: string) => void; - - /** - * The unique connection ID. This is a opaque hash of the roomId, connection type and state key. - */ - get connectionId(): string; - /** - * When a room gets an update to its state. - */ - onStateUpdate?: (ev: MatrixEvent) => Promise; - /** - * When a room gets any event - */ - onEvent?: (ev: MatrixEvent) => Promise; - - /** - * When a room gets a message event. - * @returns Was the message handled - */ - onMessageEvent?: (ev: MatrixEvent, checkPermission: PermissionCheckFn, replyMetadata?: IRichReplyMetadata) => Promise; - - onIssueCreated?: (ev: IssuesOpenedEvent) => Promise; - - onIssueStateChange?: (ev: IssuesEditedEvent) => Promise; - - onIssueEdited?: (event: IssuesEditedEvent) => Promise; - - isInterestedInStateEvent: (eventType: string, stateKey: string) => boolean; - - /** - * The details to be sent to the provisioner when requested about this connection. - */ - getProvisionerDetails?: (showSecrets?: boolean) => GetConnectionsResponseItem; - - /** - * If supported, this is sent when a user attempts to update the configuration of a connection. - */ - provisionerUpdateConfig?: >(userId: string, config: T) => Promise; - - /** - * If supported, this is sent when a user attempts to remove the connection from a room. The connection - * state should be removed and any resources should be cleaned away. - * @props purgeRemoteConfig Should the remote configuration for the connection be purged (in the case that - * other connections may be sharing a remote resource). - */ - onRemove?: () => Promise; - - toString(): string; - - conflictsWithCommandPrefix?: (commandPrefix: string) => boolean; + /** + * The roomId that this connection serves. + */ + roomId: string; + + priority: number; + + /** + * Ensures that the current state loaded into the connection has been granted by + * the remote service. I.e. If the room is bridged into a GitHub repository, + * check that the *sender* has permission to bridge it. + * + * If a grant cannot be found, it may be determined by doing an API lookup against + * the remote service. + * + * @param sender The matrix ID of the sender of the event. + * @throws If the grant cannot be found, and cannot be detetermined, this will throw. + */ + ensureGrant?: (sender?: string) => void; + + /** + * The unique connection ID. This is a opaque hash of the roomId, connection type and state key. + */ + get connectionId(): string; + /** + * When a room gets an update to its state. + */ + onStateUpdate?: (ev: MatrixEvent) => Promise; + /** + * When a room gets any event + */ + onEvent?: (ev: MatrixEvent) => Promise; + + /** + * When a room gets a message event. + * @returns Was the message handled + */ + onMessageEvent?: ( + ev: MatrixEvent, + checkPermission: PermissionCheckFn, + replyMetadata?: IRichReplyMetadata, + ) => Promise; + + onIssueCreated?: (ev: IssuesOpenedEvent) => Promise; + + onIssueStateChange?: (ev: IssuesEditedEvent) => Promise; + + onIssueEdited?: (event: IssuesEditedEvent) => Promise; + + isInterestedInStateEvent: (eventType: string, stateKey: string) => boolean; + + /** + * The details to be sent to the provisioner when requested about this connection. + */ + getProvisionerDetails?: (showSecrets?: boolean) => GetConnectionsResponseItem; + + /** + * If supported, this is sent when a user attempts to update the configuration of a connection. + */ + provisionerUpdateConfig?: >( + userId: string, + config: T, + ) => Promise; + + /** + * If supported, this is sent when a user attempts to remove the connection from a room. The connection + * state should be removed and any resources should be cleaned away. + * @props purgeRemoteConfig Should the remote configuration for the connection be purged (in the case that + * other connections may be sharing a remote resource). + */ + onRemove?: () => Promise; + + toString(): string; + + conflictsWithCommandPrefix?: (commandPrefix: string) => boolean; } - - export interface ConnectionDeclaration { - EventTypes: string[]; - ServiceCategory: string; - provisionConnection?: (roomId: string, userId: string, data: Record, opts: ProvisionConnectionOpts) => Promise<{connection: C, warning?: ConnectionWarning}>; - createConnectionForState: (roomId: string, state: StateEvent>, opts: InstantiateConnectionOpts) => C|Promise; + EventTypes: string[]; + ServiceCategory: string; + provisionConnection?: ( + roomId: string, + userId: string, + data: Record, + opts: ProvisionConnectionOpts, + ) => Promise<{ connection: C; warning?: ConnectionWarning }>; + createConnectionForState: ( + roomId: string, + state: StateEvent>, + opts: InstantiateConnectionOpts, + ) => C | Promise; } export const ConnectionDeclarations: Array = []; export interface InstantiateConnectionOpts { - as: Appservice, - intent: Intent, - config: BridgeConfig, - tokenStore: UserTokenStore, - commentProcessor: CommentProcessor, - messageClient: MessageSenderClient, - storage: IBridgeStorageProvider, - github?: GithubInstance, + as: Appservice; + intent: Intent; + config: BridgeConfig; + tokenStore: UserTokenStore; + commentProcessor: CommentProcessor; + messageClient: MessageSenderClient; + storage: IBridgeStorageProvider; + github?: GithubInstance; } export interface ProvisionConnectionOpts extends InstantiateConnectionOpts { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - getAllConnectionsOfType(typeT: new (...params : any[]) => T): T[], + + getAllConnectionsOfType( + typeT: new (...params: any[]) => T, + ): T[]; } - export function Connection(connectionType: T) { - // Event type clashes - if (ConnectionDeclarations.find( - (existingConn) => !!connectionType.EventTypes.find( - (evtType) => existingConn.EventTypes.includes(evtType)) - ) - ) { - throw Error(`Provisioning connection for ${connectionType.EventTypes[0]} has a event type clash with another connection`); - } - ConnectionDeclarations.push(connectionType); - return connectionType; + // Event type clashes + if ( + ConnectionDeclarations.find( + (existingConn) => + !!connectionType.EventTypes.find((evtType) => + existingConn.EventTypes.includes(evtType), + ), + ) + ) { + throw Error( + `Provisioning connection for ${connectionType.EventTypes[0]} has a event type clash with another connection`, + ); + } + ConnectionDeclarations.push(connectionType); + return connectionType; } diff --git a/src/Connections/JiraProject.ts b/src/Connections/JiraProject.ts index 10c6e9954..720d101b5 100644 --- a/src/Connections/JiraProject.ts +++ b/src/Connections/JiraProject.ts @@ -1,10 +1,23 @@ -import { Connection, IConnection, IConnectionState, InstantiateConnectionOpts, ProvisionConnectionOpts } from "./IConnection"; +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 { + generateJiraWebLinkFromIssue, + generateJiraWebLinkFromVersion, +} from "../jira"; import { JiraProject, JiraVersion } from "../jira/Types"; import { botCommand, BotCommands, compileBotCommands } from "../BotCommands"; import { MatrixMessageContent } from "../MatrixEvent"; @@ -20,72 +33,85 @@ import { GrantChecker } from "../grants/GrantCheck"; import { JiraGrantChecker } from "../jira/GrantChecker"; type JiraAllowedEventsNames = - "issue_created" | - "issue_updated" | - "version_created" | - "version_updated" | - "version_released"; + | "issue_created" + | "issue_updated" + | "version_created" + | "version_updated" + | "version_released"; const JiraAllowedEvents: JiraAllowedEventsNames[] = [ - "issue_created" , - "issue_updated" , - "version_created" , - "version_updated" , - "version_released", + "issue_created", + "issue_updated", + "version_created", + "version_updated", + "version_released", ]; export interface JiraProjectConnectionState extends IConnectionState { - // prefer url, but some events identify projects by id - id?: string; - url: string; - events?: JiraAllowedEventsNames[], + // prefer url, but some events identify projects by id + id?: string; + url: string; + events?: JiraAllowedEventsNames[]; } - export interface JiraProjectConnectionInstanceTarget { - url: string; - name: string; + url: string; + name: string; } export interface JiraProjectConnectionProjectTarget { - state: JiraProjectConnectionState; - key: string; - name: string; + state: JiraProjectConnectionState; + key: string; + name: string; } -export type JiraProjectConnectionTarget = JiraProjectConnectionInstanceTarget|JiraProjectConnectionProjectTarget; +export type JiraProjectConnectionTarget = + | JiraProjectConnectionInstanceTarget + | JiraProjectConnectionProjectTarget; export interface JiraTargetFilter { - instanceName?: string; - search?: string; + instanceName?: string; + search?: string; } - -export type JiraProjectResponseItem = GetConnectionsResponseItem; - - -function validateJiraConnectionState(state: unknown): JiraProjectConnectionState { - const {id, url, commandPrefix, priority} = state as Partial; - if (id !== undefined && typeof id !== "string") { - throw new ApiError("Expected 'id' to be a string", ErrCode.BadValue); - } - if (url === undefined) { - throw new ApiError("Expected a 'url' property", ErrCode.BadValue); - } - if (commandPrefix) { - if (typeof commandPrefix !== "string") { - throw new ApiError("Expected 'commandPrefix' to be a string", ErrCode.BadValue); - } - if (commandPrefix.length < 2 || commandPrefix.length > 24) { - throw new ApiError("Expected 'commandPrefix' to be between 2-24 characters", ErrCode.BadValue); - } - } - let {events} = state as Partial; - if (!events || events[0] as string == 'issue.created') { // migration - events = ['issue_created']; - } else if (events.find((ev) => !JiraAllowedEvents.includes(ev))?.length) { - throw new ApiError(`'events' can only contain ${JiraAllowedEvents.join(", ")}`, ErrCode.BadValue); - } - return {id, url, commandPrefix, events, priority}; +export type JiraProjectResponseItem = + GetConnectionsResponseItem; + +function validateJiraConnectionState( + state: unknown, +): JiraProjectConnectionState { + const { id, url, commandPrefix, priority } = + state as Partial; + if (id !== undefined && typeof id !== "string") { + throw new ApiError("Expected 'id' to be a string", ErrCode.BadValue); + } + if (url === undefined) { + throw new ApiError("Expected a 'url' property", ErrCode.BadValue); + } + if (commandPrefix) { + if (typeof commandPrefix !== "string") { + throw new ApiError( + "Expected 'commandPrefix' to be a string", + ErrCode.BadValue, + ); + } + if (commandPrefix.length < 2 || commandPrefix.length > 24) { + throw new ApiError( + "Expected 'commandPrefix' to be between 2-24 characters", + ErrCode.BadValue, + ); + } + } + let { events } = state as Partial; + if (!events || (events[0] as string) == "issue.created") { + // migration + events = ["issue_created"]; + } else if (events.find((ev) => !JiraAllowedEvents.includes(ev))?.length) { + throw new ApiError( + `'events' can only contain ${JiraAllowedEvents.join(", ")}`, + ErrCode.BadValue, + ); + } + return { id, url, commandPrefix, events, priority }; } const log = new Logger("JiraProjectConnection"); @@ -95,470 +121,666 @@ const md = new markdownit(); * Handles rooms connected to a Jira project. */ @Connection -export class JiraProjectConnection extends CommandConnection implements IConnection { - static readonly CanonicalEventType = "uk.half-shot.matrix-hookshot.jira.project"; - static readonly LegacyCanonicalEventType = "uk.half-shot.matrix-github.jira.project"; - - static readonly EventTypes = [ - JiraProjectConnection.CanonicalEventType, - JiraProjectConnection.LegacyCanonicalEventType, - ]; - static readonly ServiceCategory = "jira"; - static botCommands: BotCommands; - static helpMessage: (cmdPrefix?: string) => MatrixMessageContent; - - static async assertUserHasAccessToProject(tokenStore: UserTokenStore, userId: string, urlStr: string) { - const url = new URL(urlStr); - const jiraClient = await tokenStore.getJiraForUser(userId, url.toString()); - if (!jiraClient) { - throw new ApiError("User is not authenticated with JIRA", ErrCode.ForbiddenUser); - } - const jiraResourceClient = await jiraClient.getClientForUrl(url); - if (!jiraResourceClient) { - throw new ApiError("User is not authenticated with this JIRA instance", ErrCode.ForbiddenUser); - } - const projectKey = JiraProjectConnection.getProjectKeyForUrl(url); - if (!projectKey) { - throw new ApiError("URL did not contain a valid project key", ErrCode.BadValue); - } - try { - // Need to check that the user can access this. - const project = await jiraResourceClient.getProject(projectKey); - return project; - } catch (ex) { - throw new ApiError("Requested project was not found", ErrCode.ForbiddenUser); - } - } - - static async provisionConnection(roomId: string, userId: string, data: Record, {as, intent, tokenStore, config}: ProvisionConnectionOpts) { - if (!config.jira) { - throw new ApiError('JIRA integration is not configured', ErrCode.DisabledFeature); - } - const validData = validateJiraConnectionState(data); - log.info(`Attempting to provisionConnection for ${roomId} ${validData.url} on behalf of ${userId}`); - const project = await this.assertUserHasAccessToProject(tokenStore, userId, validData.url); - const connection = new JiraProjectConnection(roomId, as, intent, validData, validData.url, tokenStore); - // Fetch the project's id now, to support events that identify projects by id instead of url - if (connection.state.id !== undefined && connection.state.id !== project.id) { - log.warn(`Updating ID of project ${connection.projectKey} from ${connection.state.id} to ${project.id}`); - connection.state.id = project.id; - } - await intent.underlyingClient.sendStateEvent(roomId, JiraProjectConnection.CanonicalEventType, connection.stateKey, validData); - log.info(`Created connection via provisionConnection ${connection.toString()}`); - return {connection}; - } - - static createConnectionForState(roomId: string, state: StateEvent>, {config, as, intent, tokenStore}: InstantiateConnectionOpts) { - if (!config.jira) { - throw Error('JIRA is not configured'); - } - const connectionConfig = validateJiraConnectionState(state.content); - return new JiraProjectConnection(roomId, as, intent, connectionConfig, state.stateKey, tokenStore); - } - - public get projectId() { - return this.state.id; - } - - public get instanceOrigin() { - return this.projectUrl?.host; - } - - public get projectKey() { - return this.projectUrl ? JiraProjectConnection.getProjectKeyForUrl(this.projectUrl) : undefined; - } - - public static getProjectKeyForUrl(projectUrl: URL) { - const parts = projectUrl?.pathname.split('/'); - return parts ? parts[parts.length - 1]?.toUpperCase() : undefined; - } - - public get priority(): number { - return this.state.priority || super.priority; - } - - public toString() { - return `JiraProjectConnection ${this.projectUrl || this.projectId}`; - } - - public isInterestedInHookEvent(eventName: JiraAllowedEventsNames, interestedByDefault = false) { - return !this.state.events ? interestedByDefault : this.state.events.includes(eventName); - } - - public interestedInProject(project: JiraProject) { - if (this.projectId === project.id) { - return true; - } - if (this.instanceOrigin) { - const url = new URL(project.self); - return this.instanceOrigin === url.host && this.projectKey === project.key.toUpperCase(); - } - return false; - } - - public interestedInVersion(version: JiraVersion) { - return this.projectId === version.projectId.toString(); - } - - /** - * The URL of the project - * @example https://test.atlassian.net/jira/software/c/projects/PLAY - */ - private projectUrl?: URL; - - private readonly grantChecker: GrantChecker<{url: string}>; - - constructor( - roomId: string, - private readonly as: Appservice, - private readonly intent: Intent, - state: JiraProjectConnectionState, - stateKey: string, - private readonly tokenStore: UserTokenStore +export class JiraProjectConnection + extends CommandConnection + implements IConnection +{ + static readonly CanonicalEventType = + "uk.half-shot.matrix-hookshot.jira.project"; + static readonly LegacyCanonicalEventType = + "uk.half-shot.matrix-github.jira.project"; + + static readonly EventTypes = [ + JiraProjectConnection.CanonicalEventType, + JiraProjectConnection.LegacyCanonicalEventType, + ]; + static readonly ServiceCategory = "jira"; + static botCommands: BotCommands; + static helpMessage: (cmdPrefix?: string) => MatrixMessageContent; + + static async assertUserHasAccessToProject( + tokenStore: UserTokenStore, + userId: string, + urlStr: string, + ) { + const url = new URL(urlStr); + const jiraClient = await tokenStore.getJiraForUser(userId, url.toString()); + if (!jiraClient) { + throw new ApiError( + "User is not authenticated with JIRA", + ErrCode.ForbiddenUser, + ); + } + const jiraResourceClient = await jiraClient.getClientForUrl(url); + if (!jiraResourceClient) { + throw new ApiError( + "User is not authenticated with this JIRA instance", + ErrCode.ForbiddenUser, + ); + } + const projectKey = JiraProjectConnection.getProjectKeyForUrl(url); + if (!projectKey) { + throw new ApiError( + "URL did not contain a valid project key", + ErrCode.BadValue, + ); + } + try { + // Need to check that the user can access this. + const project = await jiraResourceClient.getProject(projectKey); + return project; + } catch (ex) { + throw new ApiError( + "Requested project was not found", + ErrCode.ForbiddenUser, + ); + } + } + + static async provisionConnection( + roomId: string, + userId: string, + data: Record, + { as, intent, tokenStore, config }: ProvisionConnectionOpts, + ) { + if (!config.jira) { + throw new ApiError( + "JIRA integration is not configured", + ErrCode.DisabledFeature, + ); + } + const validData = validateJiraConnectionState(data); + log.info( + `Attempting to provisionConnection for ${roomId} ${validData.url} on behalf of ${userId}`, + ); + const project = await this.assertUserHasAccessToProject( + tokenStore, + userId, + validData.url, + ); + const connection = new JiraProjectConnection( + roomId, + as, + intent, + validData, + validData.url, + tokenStore, + ); + // Fetch the project's id now, to support events that identify projects by id instead of url + if ( + connection.state.id !== undefined && + connection.state.id !== project.id ) { - super( - roomId, - stateKey, - JiraProjectConnection.CanonicalEventType, - state, - intent.underlyingClient, - JiraProjectConnection.botCommands, - JiraProjectConnection.helpMessage, - ["jira"], - "!jira", - "jira" + log.warn( + `Updating ID of project ${connection.projectKey} from ${connection.state.id} to ${project.id}`, + ); + connection.state.id = project.id; + } + await intent.underlyingClient.sendStateEvent( + roomId, + JiraProjectConnection.CanonicalEventType, + connection.stateKey, + validData, + ); + log.info( + `Created connection via provisionConnection ${connection.toString()}`, + ); + return { connection }; + } + + static createConnectionForState( + roomId: string, + state: StateEvent>, + { config, as, intent, tokenStore }: InstantiateConnectionOpts, + ) { + if (!config.jira) { + throw Error("JIRA is not configured"); + } + const connectionConfig = validateJiraConnectionState(state.content); + return new JiraProjectConnection( + roomId, + as, + intent, + connectionConfig, + state.stateKey, + tokenStore, + ); + } + + public get projectId() { + return this.state.id; + } + + public get instanceOrigin() { + return this.projectUrl?.host; + } + + public get projectKey() { + return this.projectUrl + ? JiraProjectConnection.getProjectKeyForUrl(this.projectUrl) + : undefined; + } + + public static getProjectKeyForUrl(projectUrl: URL) { + const parts = projectUrl?.pathname.split("/"); + return parts ? parts[parts.length - 1]?.toUpperCase() : undefined; + } + + public get priority(): number { + return this.state.priority || super.priority; + } + + public toString() { + return `JiraProjectConnection ${this.projectUrl || this.projectId}`; + } + + public isInterestedInHookEvent( + eventName: JiraAllowedEventsNames, + interestedByDefault = false, + ) { + return !this.state.events + ? interestedByDefault + : this.state.events.includes(eventName); + } + + public interestedInProject(project: JiraProject) { + if (this.projectId === project.id) { + return true; + } + if (this.instanceOrigin) { + const url = new URL(project.self); + return ( + this.instanceOrigin === url.host && + this.projectKey === project.key.toUpperCase() + ); + } + return false; + } + + public interestedInVersion(version: JiraVersion) { + return this.projectId === version.projectId.toString(); + } + + /** + * The URL of the project + * @example https://test.atlassian.net/jira/software/c/projects/PLAY + */ + private projectUrl?: URL; + + private readonly grantChecker: GrantChecker<{ url: string }>; + + constructor( + roomId: string, + private readonly as: Appservice, + private readonly intent: Intent, + state: JiraProjectConnectionState, + stateKey: string, + private readonly tokenStore: UserTokenStore, + ) { + super( + roomId, + stateKey, + JiraProjectConnection.CanonicalEventType, + state, + intent.underlyingClient, + JiraProjectConnection.botCommands, + JiraProjectConnection.helpMessage, + ["jira"], + "!jira", + "jira", + ); + if (state.url) { + this.projectUrl = new URL(state.url); + } else if (state.id) { + log.warn(`Legacy ID option in use, needs to be switched to 'url'`); + } else { + throw Error("State is missing both id and url, cannot create connection"); + } + this.grantChecker = new JiraGrantChecker(as, tokenStore); + } + + public isInterestedInStateEvent(eventType: string, stateKey: string) { + return ( + JiraProjectConnection.EventTypes.includes(eventType) && + this.stateKey === stateKey + ); + } + + protected validateConnectionState(content: unknown) { + return validateJiraConnectionState(content); + } + + public ensureGrant(sender?: string) { + return this.grantChecker.assertConnectionGranted( + this.roomId, + { + url: this.state.url, + }, + sender, + ); + } + + public async onJiraIssueCreated(data: JiraIssueEvent) { + // NOTE This is the only event type that shouldn't be skipped if the state object is missing, + // for backwards compatibility with issue creation having been the only supported Jira event type, + // and a missing state object having been treated as wanting all events. + if (!this.isInterestedInHookEvent("issue_created", true)) { + return; + } + log.info( + `onIssueCreated ${this.roomId} ${this.projectUrl || this.projectId} ${data.issue.id}`, + ); + + const creator = data.issue.fields.creator; + if (!creator) { + throw Error("No creator field"); + } + const url = generateJiraWebLinkFromIssue(data.issue); + const content = `${creator.displayName} created a new JIRA issue [${data.issue.key}](${url}): "${data.issue.fields.summary}"`; + await this.intent.sendEvent(this.roomId, { + msgtype: "m.notice", + body: content, + formatted_body: md.renderInline(content), + format: "org.matrix.custom.html", + ...FormatUtil.getPartialBodyForJiraIssue(data.issue), + }); + } + + public static getProvisionerDetails(botUserId: string) { + return { + service: "jira", + eventType: JiraProjectConnection.CanonicalEventType, + type: "JiraProject", + botUserId: botUserId, + }; + } + + public getProvisionerDetails(): JiraProjectResponseItem { + return { + ...JiraProjectConnection.getProvisionerDetails(this.intent.userId), + id: this.connectionId, + config: { + ...this.state, + }, + }; + } + + public static async getConnectionTargets( + userId: string, + tokenStore: UserTokenStore, + config: BridgeConfigJira, + filters: JiraTargetFilter = {}, + ): Promise { + // Search for all projects under the user's control. + const jiraUser = await tokenStore.getJiraForUser(userId, config.url); + if (!jiraUser) { + throw new ApiError( + "User is not authenticated with JIRA", + ErrCode.ForbiddenUser, + ); + } + + if (!filters.instanceName) { + const results: JiraProjectConnectionInstanceTarget[] = []; + try { + for (const resource of await jiraUser.getAccessibleResources()) { + results.push({ + url: resource.url, + name: resource.name, + }); + } + } catch (ex) { + log.warn(`Failed to fetch accessible resources for ${userId}`, ex); + throw new ApiError( + "Could not fetch accessible resources for JIRA user.", + ErrCode.Unknown, ); - if (state.url) { - this.projectUrl = new URL(state.url); - } else if (state.id) { - log.warn(`Legacy ID option in use, needs to be switched to 'url'`); - } else { - throw Error('State is missing both id and url, cannot create connection'); - } - this.grantChecker = new JiraGrantChecker(as, tokenStore); - } - - public isInterestedInStateEvent(eventType: string, stateKey: string) { - return JiraProjectConnection.EventTypes.includes(eventType) && this.stateKey === stateKey; - } - - protected validateConnectionState(content: unknown) { - return validateJiraConnectionState(content); - } - - public ensureGrant(sender?: string) { - return this.grantChecker.assertConnectionGranted(this.roomId, { - url: this.state.url, - }, sender); - } - - public async onJiraIssueCreated(data: JiraIssueEvent) { - // NOTE This is the only event type that shouldn't be skipped if the state object is missing, - // for backwards compatibility with issue creation having been the only supported Jira event type, - // and a missing state object having been treated as wanting all events. - if (!this.isInterestedInHookEvent('issue_created', true)) { - return; - } - log.info(`onIssueCreated ${this.roomId} ${this.projectUrl || this.projectId} ${data.issue.id}`); - - const creator = data.issue.fields.creator; - if (!creator) { - throw Error('No creator field'); - } - const url = generateJiraWebLinkFromIssue(data.issue); - const content = `${creator.displayName} created a new JIRA issue [${data.issue.key}](${url}): "${data.issue.fields.summary}"`; - await this.intent.sendEvent(this.roomId, { - msgtype: "m.notice", - body: content, - formatted_body: md.renderInline(content), - format: "org.matrix.custom.html", - ...FormatUtil.getPartialBodyForJiraIssue(data.issue) + } + return results; + } + // If we have an instance, search under it. + let resClient: HookshotJiraApi | null; + try { + resClient = await jiraUser.getClientForName(filters.instanceName); + } catch (ex) { + log.warn( + `Failed to fetch client for ${filters.instanceName} for ${userId}`, + ex, + ); + throw new ApiError( + "Could not fetch accessible resources for JIRA user.", + ErrCode.Unknown, + ); + } + if (!resClient) { + throw new ApiError( + "Instance not known or not accessible to this user.", + ErrCode.ForbiddenUser, + ); + } + + const allProjects: JiraProjectConnectionProjectTarget[] = []; + try { + for await (const project of resClient.getAllProjects(filters.search)) { + allProjects.push({ + state: { + id: project.id, + // Technically not the real URL, but good enough for hookshot! + url: `${resClient.resource.url}/projects/${project.key}`, + }, + key: project.key, + name: project.name, }); - } - - public static getProvisionerDetails(botUserId: string) { - return { - service: "jira", - eventType: JiraProjectConnection.CanonicalEventType, - type: "JiraProject", - botUserId: botUserId, - } - } - - public getProvisionerDetails(): JiraProjectResponseItem { - return { - ...JiraProjectConnection.getProvisionerDetails(this.intent.userId), - id: this.connectionId, - config: { - ...this.state, - }, - } - } - - public static async getConnectionTargets(userId: string, tokenStore: UserTokenStore, config: BridgeConfigJira, filters: JiraTargetFilter = {}): Promise { - // Search for all projects under the user's control. - const jiraUser = await tokenStore.getJiraForUser(userId, config.url); - if (!jiraUser) { - throw new ApiError("User is not authenticated with JIRA", ErrCode.ForbiddenUser); - } - - if (!filters.instanceName) { - const results: JiraProjectConnectionInstanceTarget[] = []; - try { - for (const resource of await jiraUser.getAccessibleResources()) { - results.push({ - url: resource.url, - name: resource.name, - }); - } - } catch (ex) { - log.warn(`Failed to fetch accessible resources for ${userId}`, ex); - throw new ApiError("Could not fetch accessible resources for JIRA user.", ErrCode.Unknown); - } - return results; - } - // If we have an instance, search under it. - let resClient: HookshotJiraApi|null; - try { - resClient = await jiraUser.getClientForName(filters.instanceName); - } catch (ex) { - log.warn(`Failed to fetch client for ${filters.instanceName} for ${userId}`, ex); - throw new ApiError("Could not fetch accessible resources for JIRA user.", ErrCode.Unknown); - } - if (!resClient) { - throw new ApiError("Instance not known or not accessible to this user.", ErrCode.ForbiddenUser); - } - - const allProjects: JiraProjectConnectionProjectTarget[] = []; - try { - for await (const project of resClient.getAllProjects(filters.search)) { - allProjects.push({ - state: { - id: project.id, - // Technically not the real URL, but good enough for hookshot! - url: `${resClient.resource.url}/projects/${project.key}`, - }, - key: project.key, - name: project.name, - }); - } - } catch (ex) { - log.warn(`Failed to fetch accessible projects for ${config.instanceName} / ${userId}`, ex); - throw new ApiError("Could not fetch accessible projects for JIRA user.", ErrCode.Unknown); - } - return allProjects; - } - - public async onJiraIssueUpdated(data: JiraIssueUpdatedEvent) { - if (!this.isInterestedInHookEvent('issue_updated')) { - return; - } - log.info(`onJiraIssueUpdated ${this.roomId} ${this.projectUrl || this.projectId} ${data.issue.id}`); - const url = generateJiraWebLinkFromIssue(data.issue); - let content = `${data.user.displayName} updated JIRA [${data.issue.key}](${url}): `; - - const changes = data.changelog.items.map((change) => `**${change.field}** changed from '${change.fromString || "not set"}' to '${change.toString || "not set"}'`); - - if (changes.length < 0) { - // Empty changeset? - log.warn(`Empty changeset, not sending message`); - return; - } else if (changes.length === 1) { - content += changes[0]; - } else { - content += `\n - ` + changes.join(`\n - `); - } - - await this.intent.sendEvent(this.roomId, { - msgtype: "m.notice", - body: content, - formatted_body: md.renderInline(content), - format: "org.matrix.custom.html", - ...FormatUtil.getPartialBodyForJiraIssue(data.issue) - }); - } - - public async onJiraVersionEvent(data: JiraVersionEvent) { - if (!this.isInterestedInHookEvent(data.webhookEvent)) { - return; - } - log.info(`onJiraVersionEvent ${this.roomId} ${this.projectUrl || this.projectId} ${data.webhookEvent}`); - const url = generateJiraWebLinkFromVersion({ - ...data.version, - projectId: data.version.projectId.toString(), - }); - const action = data.webhookEvent.substring("version_".length); - const content = - `Version **${action}**` + - (this.projectKey && this.projectUrl ? ` for project [${this.projectKey}](${this.projectUrl})` : "") + - `: [${data.version.name}](${url}) (_${data.version.description}_)`; - - await this.intent.sendEvent(this.roomId, { - msgtype: "m.notice", - body: content, - formatted_body: md.renderInline(content), - format: "org.matrix.custom.html", - }); - } - - private async getUserClientForProject(userId: string) { - if (!this.projectUrl) { - throw new CommandError("No-resource-origin", "Room is configured with an ID and not a URL, cannot determine correct JIRA client"); - } - const jiraClient = await this.tokenStore.getJiraForUser(userId, this.projectUrl.toString()); - if (!jiraClient) { - throw new NotLoggedInError(); - } - const jiraProjectClient = await jiraClient.getClientForUrl(this.projectUrl); - if (!jiraProjectClient) { - throw new CommandError("No-resource", "You do not have permission to manage issues for this JIRA org"); - } - return jiraProjectClient; - } - - @botCommand("create", "Create an issue for this project", ["type", "title"], ["description", "labels"], true) - public async onCreateIssue(userId: string, type: string, title: string, description?: string, labels?: string) { - const api = await this.getUserClientForProject(userId); - const keyOrId = this.projectKey || this.projectId; - if (!keyOrId) { - throw Error('Neither Key or ID are specified'); - } - const project = await api.getProject(keyOrId); - if (!project.issueTypes || project.issueTypes.length === 0) { - throw new CommandError("project has no issue types", "Cannot create issue, project has no issue types"); - } - const issueTypeId = project.issueTypes.find((issueType) => issueType.name.toLowerCase() === type.toLowerCase())?.id; - if (!issueTypeId) { - const content = project.issueTypes.map((t) => t.name).join(', '); - throw new CommandError("invalid-issuetype", `You must specify a valid issue type (one of ${content}). E.g. ${this.commandPrefix} create ${project.issueTypes[0].name}`); - } - log.info(`Creating new issue on behalf of ${userId}`); - let result: JiraApi.JsonResponse; - try { - result = await api.addNewIssue({ - //update: {}, - fields: { - "summary": title, - "project": { - "key": this.projectKey, - }, - "issuetype": { - id: issueTypeId, - }, - ...( description ? {description} : undefined), - ...( labels ? {"labels": labels.split(",")} : undefined), - } - }); - if (!result) { - throw Error('Invalid result'); - } - } catch (ex) { - log.warn("Failed to create JIRA issue:", ex); - throw new CommandError(ex.message, "Failed to create JIRA issue"); - } - - const link = generateJiraWebLinkFromIssue({self: this.projectUrl?.toString() || result.self, key: result.key as string}); - const content = `Created JIRA issue ${result.key}: [${link}](${link})`; - return this.intent.sendEvent(this.roomId,{ - msgtype: "m.notice", - body: content, - formatted_body: md.render(content), - format: "org.matrix.custom.html" - }); - } - - @botCommand("issue-types", "Get issue types for this project", [], [], true) - public async getIssueTypes(userId: string) { - const api = await this.getUserClientForProject(userId); - let result: JiraProject; - try { - const keyOrId = this.projectKey || this.projectId; - if (!keyOrId) { - throw Error('Neither Key or ID are specified'); - } - result = await api.getProject(keyOrId); - } catch (ex) { - log.warn("Failed to get issue types:", ex); - throw new CommandError(ex.message, "Failed to get issue types"); - } - - const content = `Issue types: ${(result.issueTypes || []).map((t) => t.name).join(', ')}`; - return this.intent.sendEvent(this.roomId,{ - msgtype: "m.notice", - body: content, - formatted_body: md.render(content), - format: "org.matrix.custom.html" - }); - } - - @botCommand("assign", "Assign an issue to a user", ["issueKey", "user"], [], true) - public async onAssignIssue(userId: string, issueKey: string, user: string) { - const api = await this.getUserClientForProject(userId); - try { - await api.getIssue(issueKey); - } catch (ex) { - log.warn(`Failed to find issue`, ex); - throw new CommandError(ex.message, "Failed to find issue"); - } - - log.info(`Assinging issue on behalf of ${userId}`); - let searchForUser = await api.searchUsers({query: user, maxResults: 1}); - if (searchForUser.length === 0) { - searchForUser = await api.searchUsers({username: user, maxResults: 1}); - if (searchForUser.length === 0) { - throw new CommandError("not-found", `Could not find a user matching '${user}'`); - } - } - await api.updateAssigneeWithId(issueKey, searchForUser[0].accountId); - } - - public async onRemove() { - log.info(`Removing ${this.toString()} for ${this.roomId}`); - await this.grantChecker.ungrantConnection(this.roomId, { - url: this.state.url, - }); - // Do a sanity check that the event exists. - try { - await this.intent.underlyingClient.getRoomStateEvent(this.roomId, JiraProjectConnection.CanonicalEventType, this.stateKey); - await this.intent.underlyingClient.sendStateEvent(this.roomId, JiraProjectConnection.CanonicalEventType, this.stateKey, { disabled: true }); - } catch (ex) { - await this.intent.underlyingClient.getRoomStateEvent(this.roomId, JiraProjectConnection.LegacyCanonicalEventType, this.stateKey); - await this.intent.underlyingClient.sendStateEvent(this.roomId, JiraProjectConnection.LegacyCanonicalEventType, this.stateKey, { disabled: true }); - } - } - - public async provisionerUpdateConfig(userId: string, config: Record) { - // Apply previous state to the current config, as provisioners might not return "unknown" keys. - config = { ...this.state, ...config }; - const validatedConfig = validateJiraConnectionState(config); - if (!validatedConfig.id) { - await this.updateProjectId(validatedConfig, userId); - } - await this.intent.underlyingClient.sendStateEvent(this.roomId, JiraProjectConnection.CanonicalEventType, this.stateKey, validatedConfig); - this.state = validatedConfig; - } - - private async updateProjectId(validatedConfig: JiraProjectConnectionState, userIdForAuth: string) { - const jiraClient = await this.tokenStore.getJiraForUser(userIdForAuth); - if (!jiraClient) { - log.warn(`Cannot update JIRA project ID via user ${userIdForAuth} who is not authenticted with JIRA`); - return; - } - const url = new URL(validatedConfig.url); - const jiraResourceClient = await jiraClient.getClientForUrl(url); - if (!jiraResourceClient) { - log.warn(`Cannot update JIRA project ID via user ${userIdForAuth} who is not authenticated with this JIRA instance`); - return; - } - const projectKey = JiraProjectConnection.getProjectKeyForUrl(url); - if (projectKey) { - const project = await jiraResourceClient.getProject(projectKey); - validatedConfig.id = project.id; - } - } + } + } catch (ex) { + log.warn( + `Failed to fetch accessible projects for ${config.instanceName} / ${userId}`, + ex, + ); + throw new ApiError( + "Could not fetch accessible projects for JIRA user.", + ErrCode.Unknown, + ); + } + return allProjects; + } + + public async onJiraIssueUpdated(data: JiraIssueUpdatedEvent) { + if (!this.isInterestedInHookEvent("issue_updated")) { + return; + } + log.info( + `onJiraIssueUpdated ${this.roomId} ${this.projectUrl || this.projectId} ${data.issue.id}`, + ); + const url = generateJiraWebLinkFromIssue(data.issue); + let content = `${data.user.displayName} updated JIRA [${data.issue.key}](${url}): `; + + const changes = data.changelog.items.map( + (change) => + `**${change.field}** changed from '${change.fromString || "not set"}' to '${change.toString || "not set"}'`, + ); + + if (changes.length < 0) { + // Empty changeset? + log.warn(`Empty changeset, not sending message`); + return; + } else if (changes.length === 1) { + content += changes[0]; + } else { + content += `\n - ` + changes.join(`\n - `); + } + + await this.intent.sendEvent(this.roomId, { + msgtype: "m.notice", + body: content, + formatted_body: md.renderInline(content), + format: "org.matrix.custom.html", + ...FormatUtil.getPartialBodyForJiraIssue(data.issue), + }); + } + + public async onJiraVersionEvent(data: JiraVersionEvent) { + if (!this.isInterestedInHookEvent(data.webhookEvent)) { + return; + } + log.info( + `onJiraVersionEvent ${this.roomId} ${this.projectUrl || this.projectId} ${data.webhookEvent}`, + ); + const url = generateJiraWebLinkFromVersion({ + ...data.version, + projectId: data.version.projectId.toString(), + }); + const action = data.webhookEvent.substring("version_".length); + const content = + `Version **${action}**` + + (this.projectKey && this.projectUrl + ? ` for project [${this.projectKey}](${this.projectUrl})` + : "") + + `: [${data.version.name}](${url}) (_${data.version.description}_)`; + + await this.intent.sendEvent(this.roomId, { + msgtype: "m.notice", + body: content, + formatted_body: md.renderInline(content), + format: "org.matrix.custom.html", + }); + } + + private async getUserClientForProject(userId: string) { + if (!this.projectUrl) { + throw new CommandError( + "No-resource-origin", + "Room is configured with an ID and not a URL, cannot determine correct JIRA client", + ); + } + const jiraClient = await this.tokenStore.getJiraForUser( + userId, + this.projectUrl.toString(), + ); + if (!jiraClient) { + throw new NotLoggedInError(); + } + const jiraProjectClient = await jiraClient.getClientForUrl(this.projectUrl); + if (!jiraProjectClient) { + throw new CommandError( + "No-resource", + "You do not have permission to manage issues for this JIRA org", + ); + } + return jiraProjectClient; + } + + @botCommand( + "create", + "Create an issue for this project", + ["type", "title"], + ["description", "labels"], + true, + ) + public async onCreateIssue( + userId: string, + type: string, + title: string, + description?: string, + labels?: string, + ) { + const api = await this.getUserClientForProject(userId); + const keyOrId = this.projectKey || this.projectId; + if (!keyOrId) { + throw Error("Neither Key or ID are specified"); + } + const project = await api.getProject(keyOrId); + if (!project.issueTypes || project.issueTypes.length === 0) { + throw new CommandError( + "project has no issue types", + "Cannot create issue, project has no issue types", + ); + } + const issueTypeId = project.issueTypes.find( + (issueType) => issueType.name.toLowerCase() === type.toLowerCase(), + )?.id; + if (!issueTypeId) { + const content = project.issueTypes.map((t) => t.name).join(", "); + throw new CommandError( + "invalid-issuetype", + `You must specify a valid issue type (one of ${content}). E.g. ${this.commandPrefix} create ${project.issueTypes[0].name}`, + ); + } + log.info(`Creating new issue on behalf of ${userId}`); + let result: JiraApi.JsonResponse; + try { + result = await api.addNewIssue({ + //update: {}, + fields: { + summary: title, + project: { + key: this.projectKey, + }, + issuetype: { + id: issueTypeId, + }, + ...(description ? { description } : undefined), + ...(labels ? { labels: labels.split(",") } : undefined), + }, + }); + if (!result) { + throw Error("Invalid result"); + } + } catch (ex) { + log.warn("Failed to create JIRA issue:", ex); + throw new CommandError(ex.message, "Failed to create JIRA issue"); + } + + const link = generateJiraWebLinkFromIssue({ + self: this.projectUrl?.toString() || result.self, + key: result.key as string, + }); + const content = `Created JIRA issue ${result.key}: [${link}](${link})`; + return this.intent.sendEvent(this.roomId, { + msgtype: "m.notice", + body: content, + formatted_body: md.render(content), + format: "org.matrix.custom.html", + }); + } + + @botCommand("issue-types", "Get issue types for this project", [], [], true) + public async getIssueTypes(userId: string) { + const api = await this.getUserClientForProject(userId); + let result: JiraProject; + try { + const keyOrId = this.projectKey || this.projectId; + if (!keyOrId) { + throw Error("Neither Key or ID are specified"); + } + result = await api.getProject(keyOrId); + } catch (ex) { + log.warn("Failed to get issue types:", ex); + throw new CommandError(ex.message, "Failed to get issue types"); + } + + const content = `Issue types: ${(result.issueTypes || []).map((t) => t.name).join(", ")}`; + return this.intent.sendEvent(this.roomId, { + msgtype: "m.notice", + body: content, + formatted_body: md.render(content), + format: "org.matrix.custom.html", + }); + } + + @botCommand( + "assign", + "Assign an issue to a user", + ["issueKey", "user"], + [], + true, + ) + public async onAssignIssue(userId: string, issueKey: string, user: string) { + const api = await this.getUserClientForProject(userId); + try { + await api.getIssue(issueKey); + } catch (ex) { + log.warn(`Failed to find issue`, ex); + throw new CommandError(ex.message, "Failed to find issue"); + } + + log.info(`Assinging issue on behalf of ${userId}`); + let searchForUser = await api.searchUsers({ query: user, maxResults: 1 }); + if (searchForUser.length === 0) { + searchForUser = await api.searchUsers({ username: user, maxResults: 1 }); + if (searchForUser.length === 0) { + throw new CommandError( + "not-found", + `Could not find a user matching '${user}'`, + ); + } + } + await api.updateAssigneeWithId(issueKey, searchForUser[0].accountId); + } + + public async onRemove() { + log.info(`Removing ${this.toString()} for ${this.roomId}`); + await this.grantChecker.ungrantConnection(this.roomId, { + url: this.state.url, + }); + // Do a sanity check that the event exists. + try { + await this.intent.underlyingClient.getRoomStateEvent( + this.roomId, + JiraProjectConnection.CanonicalEventType, + this.stateKey, + ); + await this.intent.underlyingClient.sendStateEvent( + this.roomId, + JiraProjectConnection.CanonicalEventType, + this.stateKey, + { disabled: true }, + ); + } catch (ex) { + await this.intent.underlyingClient.getRoomStateEvent( + this.roomId, + JiraProjectConnection.LegacyCanonicalEventType, + this.stateKey, + ); + await this.intent.underlyingClient.sendStateEvent( + this.roomId, + JiraProjectConnection.LegacyCanonicalEventType, + this.stateKey, + { disabled: true }, + ); + } + } + + public async provisionerUpdateConfig( + userId: string, + config: Record, + ) { + // Apply previous state to the current config, as provisioners might not return "unknown" keys. + config = { ...this.state, ...config }; + const validatedConfig = validateJiraConnectionState(config); + if (!validatedConfig.id) { + await this.updateProjectId(validatedConfig, userId); + } + await this.intent.underlyingClient.sendStateEvent( + this.roomId, + JiraProjectConnection.CanonicalEventType, + this.stateKey, + validatedConfig, + ); + this.state = validatedConfig; + } + + private async updateProjectId( + validatedConfig: JiraProjectConnectionState, + userIdForAuth: string, + ) { + const jiraClient = await this.tokenStore.getJiraForUser(userIdForAuth); + if (!jiraClient) { + log.warn( + `Cannot update JIRA project ID via user ${userIdForAuth} who is not authenticted with JIRA`, + ); + return; + } + const url = new URL(validatedConfig.url); + const jiraResourceClient = await jiraClient.getClientForUrl(url); + if (!jiraResourceClient) { + log.warn( + `Cannot update JIRA project ID via user ${userIdForAuth} who is not authenticated with this JIRA instance`, + ); + return; + } + const projectKey = JiraProjectConnection.getProjectKeyForUrl(url); + if (projectKey) { + const project = await jiraResourceClient.getProject(projectKey); + validatedConfig.id = project.id; + } + } } -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const res = compileBotCommands(JiraProjectConnection.prototype as any, CommandConnection.prototype as any); + +const res = compileBotCommands( + JiraProjectConnection.prototype as any, + CommandConnection.prototype as any, +); JiraProjectConnection.helpMessage = res.helpMessage; JiraProjectConnection.botCommands = res.botCommands; diff --git a/src/Connections/OpenProjectConnection.ts b/src/Connections/OpenProjectConnection.ts index 8836adabc..b91b3d1e6 100644 --- a/src/Connections/OpenProjectConnection.ts +++ b/src/Connections/OpenProjectConnection.ts @@ -1,4 +1,10 @@ -import { Connection, IConnection, IConnectionState, InstantiateConnectionOpts, ProvisionConnectionOpts } from "./IConnection"; +import { + Connection, + IConnection, + IConnectionState, + InstantiateConnectionOpts, + ProvisionConnectionOpts, +} from "./IConnection"; import { Appservice, Intent, StateEvent } from "matrix-bot-sdk"; import { Logger } from "matrix-appservice-bridge"; import markdownit from "markdown-it"; @@ -9,93 +15,111 @@ import { UserTokenStore } from "../tokens/UserTokenStore"; import { ApiError, ErrCode } from "../api"; import { OpenProjectWebhookPayloadWorkPackage } from "../openproject/types"; import { BridgeOpenProjectConfig } from "../config/sections/openproject"; -import { formatWorkPackageDiff, formatWorkPackageForMatrix } from "../openproject/format"; +import { + formatWorkPackageDiff, + formatWorkPackageForMatrix, +} from "../openproject/format"; import { IBridgeStorageProvider } from "../Stores/StorageProvider"; import { workPackageToCacheState } from "../openproject/state"; import { OpenProjectGrantChecker } from "../openproject/GrantChecker"; import { GetConnectionsResponseItem } from "../Widgets/api"; export type OpenProjectEventsNames = - "work_package:created" | - "work_package:updated" | - "work_package:assignee_changed" | - "work_package:description_changed" | - "work_package:duedate_changed" | - "work_package:workpercent_changed" | - "work_package:priority_changed" | - "work_package:responsible_changed" | - "work_package:subject_changed"; + | "work_package:created" + | "work_package:updated" + | "work_package:assignee_changed" + | "work_package:description_changed" + | "work_package:duedate_changed" + | "work_package:workpercent_changed" + | "work_package:priority_changed" + | "work_package:responsible_changed" + | "work_package:subject_changed"; const JiraAllowedEvents: OpenProjectEventsNames[] = [ - "work_package:created", - "work_package:updated", - "work_package:assignee_changed", - "work_package:description_changed", - "work_package:duedate_changed", - "work_package:workpercent_changed", - "work_package:priority_changed", - "work_package:responsible_changed", - "work_package:subject_changed", + "work_package:created", + "work_package:updated", + "work_package:assignee_changed", + "work_package:description_changed", + "work_package:duedate_changed", + "work_package:workpercent_changed", + "work_package:priority_changed", + "work_package:responsible_changed", + "work_package:subject_changed", ]; export interface OpenProjectConnectionState extends IConnectionState { - /** - * We use URL here as it's more ergonomic for users to paste, and it preserves - * the origin of the project too for future enhancement. - */ - url: string; - events: OpenProjectEventsNames[], + /** + * We use URL here as it's more ergonomic for users to paste, and it preserves + * the origin of the project too for future enhancement. + */ + url: string; + events: OpenProjectEventsNames[]; } - -export type OpenProjectResponseItem = GetConnectionsResponseItem; +export type OpenProjectResponseItem = + GetConnectionsResponseItem; export interface OpenProjectConnectionRepoTarget { - name: string; - description: string; - id: number; - url: string; - suggested_prefix: string; + name: string; + description: string; + id: number; + url: string; + suggested_prefix: string; } export interface OpenProjectConnectionFilters { - search?: string; + search?: string; } - export interface OpenProjectServiceConfig { - baseUrl: string; + baseUrl: string; } -function validateOpenProjectConnectionState(state: unknown, baseUrl: URL): OpenProjectConnectionState { - const {url, commandPrefix, priority} = state as Partial; - if (url === undefined || typeof url !== "string") { - throw new ApiError("Expected 'url' to be a string", ErrCode.BadValue); - } - if (!URL.canParse(url)) { - throw new ApiError("Expected 'url' to be a URL", ErrCode.BadValue); - } - const parsedUrl = new URL(url); - if (parsedUrl.origin !== baseUrl.origin) { - throw new ApiError(`Expected 'url' to match the origin '${baseUrl.origin}'`, ErrCode.BadValue); - } - // Validate the URL - OpenProjectConnection.projectIdFromUrl(parsedUrl); - if (commandPrefix) { - if (typeof commandPrefix !== "string") { - throw new ApiError("Expected 'commandPrefix' to be a string", ErrCode.BadValue); - } - // Higher limit because project names can be long - if (commandPrefix.length < 2 || commandPrefix.length > 48) { - throw new ApiError("Expected 'commandPrefix' to be between 2-48 characters", ErrCode.BadValue); - } +function validateOpenProjectConnectionState( + state: unknown, + baseUrl: URL, +): OpenProjectConnectionState { + const { url, commandPrefix, priority } = + state as Partial; + if (url === undefined || typeof url !== "string") { + throw new ApiError("Expected 'url' to be a string", ErrCode.BadValue); + } + if (!URL.canParse(url)) { + throw new ApiError("Expected 'url' to be a URL", ErrCode.BadValue); + } + const parsedUrl = new URL(url); + if (parsedUrl.origin !== baseUrl.origin) { + throw new ApiError( + `Expected 'url' to match the origin '${baseUrl.origin}'`, + ErrCode.BadValue, + ); + } + // Validate the URL + OpenProjectConnection.projectIdFromUrl(parsedUrl); + if (commandPrefix) { + if (typeof commandPrefix !== "string") { + throw new ApiError( + "Expected 'commandPrefix' to be a string", + ErrCode.BadValue, + ); } - let {events} = state as Partial; - events = events ?? ["work_package:created", "work_package:updated"]; - if (events.find((ev) => !JiraAllowedEvents.includes(ev))?.length) { - throw new ApiError(`'events' can only contain ${JiraAllowedEvents.join(", ")}`, ErrCode.BadValue); + // Higher limit because project names can be long + if (commandPrefix.length < 2 || commandPrefix.length > 48) { + throw new ApiError( + "Expected 'commandPrefix' to be between 2-48 characters", + ErrCode.BadValue, + ); } - return {url, commandPrefix, events, priority}; + } + let { events } = state as Partial; + events = events ?? ["work_package:created", "work_package:updated"]; + if (events.find((ev) => !JiraAllowedEvents.includes(ev))?.length) { + throw new ApiError( + `'events' can only contain ${JiraAllowedEvents.join(", ")}`, + ErrCode.BadValue, + ); + } + return { url, commandPrefix, events, priority }; } const log = new Logger("OpenProjectConnection"); @@ -105,254 +129,384 @@ const md = new markdownit(); * Handles rooms connected to a Jira project. */ @Connection -export class OpenProjectConnection extends CommandConnection implements IConnection { - static readonly CanonicalEventType = "org.matrix.matrix-hookshot.openproject.project"; - - static readonly EventTypes = [ - OpenProjectConnection.CanonicalEventType, - ]; - static readonly ServiceCategory = "openproject"; - static botCommands: BotCommands; - static helpMessage: (cmdPrefix?: string) => MatrixMessageContent; - - static projectIdFromUrl(url: URL): number { - const id = parseInt(/\/projects\/(\d+)\/?/.exec(url.pathname)?.[1] ?? ""); - if (isNaN(id)) { - throw Error('URL for project doesnt contain a project ID'); - } - return id; - } - - static async assertUserHasAccessToProject(tokenStore: UserTokenStore, userId: string, urlStr: string) { - const url = new URL(urlStr); - const client = await tokenStore.getOpenProjectForUser(userId); - if (!client) { - throw new ApiError("User is not authenticated with OpenProject", ErrCode.ForbiddenUser); - } - const projectId = OpenProjectConnection.projectIdFromUrl(url); - if (!projectId) { - throw new ApiError("URL did not contain a valid project id", ErrCode.BadValue); - } - try { - // Need to check that the user can access this. - const project = await client.getProject(projectId) - return project; - } catch (ex) { - throw new ApiError("Requested project was not found", ErrCode.ForbiddenUser); - } - } - - - static async provisionConnection(roomId: string, userId: string, data: Record, {as, intent, tokenStore, config, storage}: ProvisionConnectionOpts) { - if (!config.openProject) { - throw new ApiError('OpenProject integration is not configured', ErrCode.DisabledFeature); - } - const validData = validateOpenProjectConnectionState(data, config.openProject.baseURL); - log.info(`Attempting to provisionConnection for ${roomId} ${validData.url} on behalf of ${userId}`); - await this.assertUserHasAccessToProject(tokenStore, userId, validData.url); - const connection = new OpenProjectConnection(roomId, as, intent, config.openProject, validData, validData.url, tokenStore, storage); - await intent.underlyingClient.sendStateEvent(roomId, OpenProjectConnection.CanonicalEventType, connection.stateKey, validData); - log.info(`Created connection via provisionConnection ${connection.toString()}`); - await new OpenProjectGrantChecker(as, tokenStore).grantConnection(roomId, { url: validData.url }); - return {connection}; - } - - static createConnectionForState(roomId: string, state: StateEvent>, {config, as, intent, tokenStore, storage}: InstantiateConnectionOpts) { - if (!config.openProject) { - throw Error('OpenProject is not configured'); - } - const connectionConfig = validateOpenProjectConnectionState(state.content, config.openProject.baseURL); - return new OpenProjectConnection(roomId, as, intent, config.openProject, connectionConfig, state.stateKey, tokenStore, storage); - } - - public static async getConnectionTargets(userId: string, tokenStore: UserTokenStore, filters: OpenProjectConnectionFilters = {}): Promise { - // Search for all repos under the user's control. - const client = await tokenStore.getOpenProjectForUser(userId); - if (!client) { - throw new ApiError("User is not authenticated with OpenProject", ErrCode.ForbiddenUser); - } - - const projects = await client.searchProjects(filters.search); - return projects.map((p) => ({ - id: p.id, - name: p.name, - description: p.description.raw, - url: p.project_url, - suggested_prefix: `!openproject ${p.identifier}` - })); +export class OpenProjectConnection + extends CommandConnection + implements IConnection +{ + static readonly CanonicalEventType = + "org.matrix.matrix-hookshot.openproject.project"; + + static readonly EventTypes = [OpenProjectConnection.CanonicalEventType]; + static readonly ServiceCategory = "openproject"; + static botCommands: BotCommands; + static helpMessage: (cmdPrefix?: string) => MatrixMessageContent; + + static projectIdFromUrl(url: URL): number { + const id = parseInt(/\/projects\/(\d+)\/?/.exec(url.pathname)?.[1] ?? ""); + if (isNaN(id)) { + throw Error("URL for project doesnt contain a project ID"); } - public get priority(): number { - return this.state.priority || super.priority; + return id; + } + + static async assertUserHasAccessToProject( + tokenStore: UserTokenStore, + userId: string, + urlStr: string, + ) { + const url = new URL(urlStr); + const client = await tokenStore.getOpenProjectForUser(userId); + if (!client) { + throw new ApiError( + "User is not authenticated with OpenProject", + ErrCode.ForbiddenUser, + ); } - - public toString() { - return `OpenProjectConnection ${this.projectId}`; + const projectId = OpenProjectConnection.projectIdFromUrl(url); + if (!projectId) { + throw new ApiError( + "URL did not contain a valid project id", + ErrCode.BadValue, + ); } - - public isInterestedInHookEvent(eventName: OpenProjectEventsNames, interestedByDefault = false) { - return !this.state.events ? interestedByDefault : this.state.events.includes(eventName); + try { + // Need to check that the user can access this. + const project = await client.getProject(projectId); + return project; + } catch (ex) { + throw new ApiError( + "Requested project was not found", + ErrCode.ForbiddenUser, + ); } - - public interestedInProject(project: number) { - if (this.projectId === project) { - return true; - } - return false; + } + + static async provisionConnection( + roomId: string, + userId: string, + data: Record, + { as, intent, tokenStore, config, storage }: ProvisionConnectionOpts, + ) { + if (!config.openProject) { + throw new ApiError( + "OpenProject integration is not configured", + ErrCode.DisabledFeature, + ); } - - public readonly url: URL; - public readonly projectId: number; - private readonly grantChecker: OpenProjectGrantChecker; - - constructor( - roomId: string, - private readonly as: Appservice, - private readonly intent: Intent, - private readonly config: BridgeOpenProjectConfig, - state: OpenProjectConnectionState, - stateKey: string, - private readonly tokenStore: UserTokenStore, - private readonly storage: IBridgeStorageProvider, - ) { - super( - roomId, - stateKey, - OpenProjectConnection.CanonicalEventType, - state, - intent.underlyingClient, - OpenProjectConnection.botCommands, - OpenProjectConnection.helpMessage, - ["openproject"], - "!openproject", - "openproject" - ); - this.grantChecker = new OpenProjectGrantChecker(as, tokenStore); - this.url = new URL(state.url); - this.projectId = OpenProjectConnection.projectIdFromUrl(this.url); + const validData = validateOpenProjectConnectionState( + data, + config.openProject.baseURL, + ); + log.info( + `Attempting to provisionConnection for ${roomId} ${validData.url} on behalf of ${userId}`, + ); + await this.assertUserHasAccessToProject(tokenStore, userId, validData.url); + const connection = new OpenProjectConnection( + roomId, + as, + intent, + config.openProject, + validData, + validData.url, + tokenStore, + storage, + ); + await intent.underlyingClient.sendStateEvent( + roomId, + OpenProjectConnection.CanonicalEventType, + connection.stateKey, + validData, + ); + log.info( + `Created connection via provisionConnection ${connection.toString()}`, + ); + await new OpenProjectGrantChecker(as, tokenStore).grantConnection(roomId, { + url: validData.url, + }); + return { connection }; + } + + static createConnectionForState( + roomId: string, + state: StateEvent>, + { config, as, intent, tokenStore, storage }: InstantiateConnectionOpts, + ) { + if (!config.openProject) { + throw Error("OpenProject is not configured"); } - - public isInterestedInStateEvent(eventType: string, stateKey: string) { - return OpenProjectConnection.EventTypes.includes(eventType) && this.stateKey === stateKey; + const connectionConfig = validateOpenProjectConnectionState( + state.content, + config.openProject.baseURL, + ); + return new OpenProjectConnection( + roomId, + as, + intent, + config.openProject, + connectionConfig, + state.stateKey, + tokenStore, + storage, + ); + } + + public static async getConnectionTargets( + userId: string, + tokenStore: UserTokenStore, + filters: OpenProjectConnectionFilters = {}, + ): Promise { + // Search for all repos under the user's control. + const client = await tokenStore.getOpenProjectForUser(userId); + if (!client) { + throw new ApiError( + "User is not authenticated with OpenProject", + ErrCode.ForbiddenUser, + ); } - protected validateConnectionState(content: unknown) { - return validateOpenProjectConnectionState(content, this.config.baseURL); + const projects = await client.searchProjects(filters.search); + return projects.map((p) => ({ + id: p.id, + name: p.name, + description: p.description.raw, + url: p.project_url, + suggested_prefix: `!openproject ${p.identifier}`, + })); + } + public get priority(): number { + return this.state.priority || super.priority; + } + + public toString() { + return `OpenProjectConnection ${this.projectId}`; + } + + public isInterestedInHookEvent( + eventName: OpenProjectEventsNames, + interestedByDefault = false, + ) { + return !this.state.events + ? interestedByDefault + : this.state.events.includes(eventName); + } + + public interestedInProject(project: number) { + if (this.projectId === project) { + return true; } - - public ensureGrant(sender?: string) { - return this.grantChecker.assertConnectionGranted(this.roomId, { - url: this.state.url, - }, sender); + return false; + } + + public readonly url: URL; + public readonly projectId: number; + private readonly grantChecker: OpenProjectGrantChecker; + + constructor( + roomId: string, + private readonly as: Appservice, + private readonly intent: Intent, + private readonly config: BridgeOpenProjectConfig, + state: OpenProjectConnectionState, + stateKey: string, + private readonly tokenStore: UserTokenStore, + private readonly storage: IBridgeStorageProvider, + ) { + super( + roomId, + stateKey, + OpenProjectConnection.CanonicalEventType, + state, + intent.underlyingClient, + OpenProjectConnection.botCommands, + OpenProjectConnection.helpMessage, + ["openproject"], + "!openproject", + "openproject", + ); + this.grantChecker = new OpenProjectGrantChecker(as, tokenStore); + this.url = new URL(state.url); + this.projectId = OpenProjectConnection.projectIdFromUrl(this.url); + } + + public isInterestedInStateEvent(eventType: string, stateKey: string) { + return ( + OpenProjectConnection.EventTypes.includes(eventType) && + this.stateKey === stateKey + ); + } + + protected validateConnectionState(content: unknown) { + return validateOpenProjectConnectionState(content, this.config.baseURL); + } + + public ensureGrant(sender?: string) { + return this.grantChecker.assertConnectionGranted( + this.roomId, + { + url: this.state.url, + }, + sender, + ); + } + + public async onWorkPackageCreated( + data: OpenProjectWebhookPayloadWorkPackage, + ) { + if (!this.isInterestedInHookEvent("work_package:created")) { + return; } + log.info( + `onWorkPackageCreated ${this.roomId} ${this.projectId} ${data.work_package.id}`, + ); - public async onWorkPackageCreated(data: OpenProjectWebhookPayloadWorkPackage) { - if (!this.isInterestedInHookEvent('work_package:created')) { - return; - } - log.info(`onWorkPackageCreated ${this.roomId} ${this.projectId} ${data.work_package.id}`); - - const creator = data.work_package._embedded.author; - if (!creator) { - throw Error('No creator field'); - } - const extraData = formatWorkPackageForMatrix(data.work_package, this.config.baseURL); - const content = `${creator.name} created a new work package [${data.work_package.id}](${extraData["org.matrix.matrix-hookshot.openproject.work_package"].url}): "${data.work_package.subject}"`; - await this.intent.sendEvent(this.roomId, { - msgtype: "m.notice", - body: content, - formatted_body: md.renderInline(content), - format: "org.matrix.custom.html", - ...formatWorkPackageForMatrix(data.work_package, this.config.baseURL), - }); - await this.storage.setOpenProjectWorkPackageState(workPackageToCacheState(data.work_package), data.work_package.id); + const creator = data.work_package._embedded.author; + if (!creator) { + throw Error("No creator field"); } - - public async onWorkPackageUpdated(data: OpenProjectWebhookPayloadWorkPackage) { - log.info(`onWorkPackageUpdated ${this.roomId} ${this.projectId} ${data.work_package.id}`); - - const creator = data.work_package._embedded.author; - if (!creator) { - throw Error('No creator field'); - } - const extraData = formatWorkPackageForMatrix(data.work_package, this.config.baseURL); - const oldChanges = await this.storage.getOpenProjectWorkPackageState(data.work_package._embedded.project.id, data.work_package.id); - - // Detect what changed. - let changeStatement = "updated work package"; - let postfix; - let hookEvent: OpenProjectEventsNames = "work_package:updated"; - if (oldChanges) { - const diffSet = formatWorkPackageDiff(oldChanges, data.work_package); - if (diffSet) { - hookEvent = diffSet.eventKind; - postfix = diffSet.postfix; - if (diffSet.changes.length === 1) { - changeStatement = diffSet.changes[0]; - } else { - postfix = ` - ${diffSet.changes.join('\n - ')}`; - } - } else { - // Changes were not understood, skip. - return; - } - } - if (!this.isInterestedInHookEvent(hookEvent ?? "work_package:updated")) { - return; - } - const content = `**${creator.name}** ${changeStatement} for [${data.work_package.id}](${extraData["org.matrix.matrix-hookshot.openproject.work_package"].url}): "${data.work_package.subject}"`; - - await this.intent.sendEvent(this.roomId, { - msgtype: "m.notice", - body: content + (postfix ? postfix : ""), - formatted_body: md.renderInline(content) + (postfix ? md.render(postfix) : ""), - format: "org.matrix.custom.html", - ...formatWorkPackageForMatrix(data.work_package, this.config.baseURL), - }); - await this.storage.setOpenProjectWorkPackageState(workPackageToCacheState(data.work_package), data.work_package.id); + const extraData = formatWorkPackageForMatrix( + data.work_package, + this.config.baseURL, + ); + const content = `${creator.name} created a new work package [${data.work_package.id}](${extraData["org.matrix.matrix-hookshot.openproject.work_package"].url}): "${data.work_package.subject}"`; + await this.intent.sendEvent(this.roomId, { + msgtype: "m.notice", + body: content, + formatted_body: md.renderInline(content), + format: "org.matrix.custom.html", + ...formatWorkPackageForMatrix(data.work_package, this.config.baseURL), + }); + await this.storage.setOpenProjectWorkPackageState( + workPackageToCacheState(data.work_package), + data.work_package.id, + ); + } + + public async onWorkPackageUpdated( + data: OpenProjectWebhookPayloadWorkPackage, + ) { + log.info( + `onWorkPackageUpdated ${this.roomId} ${this.projectId} ${data.work_package.id}`, + ); + + const creator = data.work_package._embedded.author; + if (!creator) { + throw Error("No creator field"); } - - public static getProvisionerDetails(botUserId: string) { - return { - service: "openproject", - eventType: OpenProjectConnection.CanonicalEventType, - type: "OpenProject", - botUserId: botUserId, + const extraData = formatWorkPackageForMatrix( + data.work_package, + this.config.baseURL, + ); + const oldChanges = await this.storage.getOpenProjectWorkPackageState( + data.work_package._embedded.project.id, + data.work_package.id, + ); + + // Detect what changed. + let changeStatement = "updated work package"; + let postfix; + let hookEvent: OpenProjectEventsNames = "work_package:updated"; + if (oldChanges) { + const diffSet = formatWorkPackageDiff(oldChanges, data.work_package); + if (diffSet) { + hookEvent = diffSet.eventKind; + postfix = diffSet.postfix; + if (diffSet.changes.length === 1) { + changeStatement = diffSet.changes[0]; + } else { + postfix = ` - ${diffSet.changes.join("\n - ")}`; } + } else { + // Changes were not understood, skip. + return; + } } - - public getProvisionerDetails(): OpenProjectResponseItem { - return { - ...OpenProjectConnection.getProvisionerDetails(this.intent.userId), - id: this.connectionId, - config: { - ...this.state, - }, - } - } - - public async onRemove() { - log.info(`Removing ${this.toString()} for ${this.roomId}`); - await this.grantChecker.ungrantConnection(this.roomId, { - url: this.state.url, - }); - // Do a sanity check that the event exists. - await this.intent.underlyingClient.getRoomStateEvent(this.roomId, OpenProjectConnection.CanonicalEventType, this.stateKey); - await this.intent.underlyingClient.sendStateEvent(this.roomId, OpenProjectConnection.CanonicalEventType, this.stateKey, { disabled: true }); + if (!this.isInterestedInHookEvent(hookEvent ?? "work_package:updated")) { + return; } - - public async provisionerUpdateConfig(userId: string, config: Record) { - // Apply previous state to the current config, as provisioners might not return "unknown" keys. - config = { ...this.state, ...config }; - const validatedConfig = validateOpenProjectConnectionState(config, this.config.baseURL); - if (this.state.url !== validatedConfig.url) { - throw new ApiError('Project URL cannot be changed. Create a new connection instead.', ErrCode.UnsupportedOperation); - } - await this.intent.underlyingClient.sendStateEvent(this.roomId, OpenProjectConnection.CanonicalEventType, this.stateKey, validatedConfig); - this.state = validatedConfig; + const content = `**${creator.name}** ${changeStatement} for [${data.work_package.id}](${extraData["org.matrix.matrix-hookshot.openproject.work_package"].url}): "${data.work_package.subject}"`; + + await this.intent.sendEvent(this.roomId, { + msgtype: "m.notice", + body: content + (postfix ? postfix : ""), + formatted_body: + md.renderInline(content) + (postfix ? md.render(postfix) : ""), + format: "org.matrix.custom.html", + ...formatWorkPackageForMatrix(data.work_package, this.config.baseURL), + }); + await this.storage.setOpenProjectWorkPackageState( + workPackageToCacheState(data.work_package), + data.work_package.id, + ); + } + + public static getProvisionerDetails(botUserId: string) { + return { + service: "openproject", + eventType: OpenProjectConnection.CanonicalEventType, + type: "OpenProject", + botUserId: botUserId, + }; + } + + public getProvisionerDetails(): OpenProjectResponseItem { + return { + ...OpenProjectConnection.getProvisionerDetails(this.intent.userId), + id: this.connectionId, + config: { + ...this.state, + }, + }; + } + + public async onRemove() { + log.info(`Removing ${this.toString()} for ${this.roomId}`); + await this.grantChecker.ungrantConnection(this.roomId, { + url: this.state.url, + }); + // Do a sanity check that the event exists. + await this.intent.underlyingClient.getRoomStateEvent( + this.roomId, + OpenProjectConnection.CanonicalEventType, + this.stateKey, + ); + await this.intent.underlyingClient.sendStateEvent( + this.roomId, + OpenProjectConnection.CanonicalEventType, + this.stateKey, + { disabled: true }, + ); + } + + public async provisionerUpdateConfig( + userId: string, + config: Record, + ) { + // Apply previous state to the current config, as provisioners might not return "unknown" keys. + config = { ...this.state, ...config }; + const validatedConfig = validateOpenProjectConnectionState( + config, + this.config.baseURL, + ); + if (this.state.url !== validatedConfig.url) { + throw new ApiError( + "Project URL cannot be changed. Create a new connection instead.", + ErrCode.UnsupportedOperation, + ); } + await this.intent.underlyingClient.sendStateEvent( + this.roomId, + OpenProjectConnection.CanonicalEventType, + this.stateKey, + validatedConfig, + ); + this.state = validatedConfig; + } } -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const res = compileBotCommands(OpenProjectConnection.prototype as any, CommandConnection.prototype as any); + +const res = compileBotCommands( + OpenProjectConnection.prototype as any, + CommandConnection.prototype as any, +); OpenProjectConnection.helpMessage = res.helpMessage; OpenProjectConnection.botCommands = res.botCommands; diff --git a/src/Connections/OutboundHook.ts b/src/Connections/OutboundHook.ts index 79aa80f73..2192c3b43 100644 --- a/src/Connections/OutboundHook.ts +++ b/src/Connections/OutboundHook.ts @@ -1,6 +1,12 @@ import axios, { isAxiosError } from "axios"; import { BaseConnection } from "./BaseConnection"; -import { Connection, IConnection, IConnectionState, InstantiateConnectionOpts, ProvisionConnectionOpts } from "./IConnection"; +import { + Connection, + IConnection, + IConnectionState, + InstantiateConnectionOpts, + ProvisionConnectionOpts, +} from "./IConnection"; import { ApiError, ErrCode, Logger } from "matrix-appservice-bridge"; import { MatrixEvent } from "../MatrixEvent"; import { FileMessageEventContent, Intent, StateEvent } from "matrix-bot-sdk"; @@ -10,17 +16,19 @@ import { hashId } from "../libRs"; import { GetConnectionsResponseItem } from "../Widgets/api"; export interface OutboundHookConnectionState extends IConnectionState { - name: string, - url: string; - method?: "PUT"|"POST"; + name: string; + url: string; + method?: "PUT" | "POST"; } export interface OutboundHookSecrets { - token: string; + token: string; } -export type OutboundHookResponseItem = GetConnectionsResponseItem; - +export type OutboundHookResponseItem = GetConnectionsResponseItem< + OutboundHookConnectionState, + OutboundHookSecrets +>; const log = new Logger("OutboundHookConnection"); @@ -28,254 +36,336 @@ const log = new Logger("OutboundHookConnection"); * Handles rooms connected to an outbound generic service. */ @Connection -export class OutboundHookConnection extends BaseConnection implements IConnection { - static readonly CanonicalEventType = "uk.half-shot.matrix-hookshot.outbound-hook"; - static readonly ServiceCategory = "genericOutbound"; - - static readonly EventTypes = [ - OutboundHookConnection.CanonicalEventType, - ]; - - private static getAccountDataKey(stateKey: string) { - return `${OutboundHookConnection.CanonicalEventType}:${stateKey}`; +export class OutboundHookConnection + extends BaseConnection + implements IConnection +{ + static readonly CanonicalEventType = + "uk.half-shot.matrix-hookshot.outbound-hook"; + static readonly ServiceCategory = "genericOutbound"; + + static readonly EventTypes = [OutboundHookConnection.CanonicalEventType]; + + private static getAccountDataKey(stateKey: string) { + return `${OutboundHookConnection.CanonicalEventType}:${stateKey}`; + } + + static validateState( + state: Record, + ): OutboundHookConnectionState { + const { url, method, name } = state; + if (typeof url !== "string") { + throw new ApiError("Outbound URL must be a string", ErrCode.BadValue); } - static validateState(state: Record): OutboundHookConnectionState { - const {url, method, name} = state; - if (typeof url !== "string") { - throw new ApiError('Outbound URL must be a string', ErrCode.BadValue); - } - - if (typeof name !== "string") { - throw new ApiError("A webhook name must be a string.", ErrCode.BadValue); - } - - try { - const validatedUrl = new URL(url); - if (validatedUrl.protocol !== "http:" && validatedUrl.protocol !== "https:") { - throw new ApiError('Outbound URL protocol must be http or https', ErrCode.BadValue); - } - } catch (ex) { - if (ex instanceof ApiError) { - throw ex; - } - throw new ApiError('Outbound URL is invalid', ErrCode.BadValue); - } - - if (method === "PUT" || method === "POST" || method === undefined) { - return { - name, - url, - method: method ?? 'PUT', - }; - } - throw new ApiError('Outbound Method must be one of PUT,POST', ErrCode.BadValue); + if (typeof name !== "string") { + throw new ApiError("A webhook name must be a string.", ErrCode.BadValue); } - static async createConnectionForState(roomId: string, event: StateEvent>, {intent, config, tokenStore}: InstantiateConnectionOpts) { - if (!config.generic) { - throw Error('Generic webhooks are not configured'); - } - // Generic hooks store the hookId in the account data - const state = this.validateState(event.content); - const token = await tokenStore.getGenericToken("outboundHookToken", hashId(`${roomId}:${event.stateKey}`)); - - if (!token) { - throw new Error(`Missing stored token for connection`); - } - - return new OutboundHookConnection( - roomId, - state, - token, - event.stateKey, - intent, + try { + const validatedUrl = new URL(url); + if ( + validatedUrl.protocol !== "http:" && + validatedUrl.protocol !== "https:" + ) { + throw new ApiError( + "Outbound URL protocol must be http or https", + ErrCode.BadValue, ); + } + } catch (ex) { + if (ex instanceof ApiError) { + throw ex; + } + throw new ApiError("Outbound URL is invalid", ErrCode.BadValue); } - static async provisionConnection(roomId: string, userId: string, data: Record = {}, {intent, config, tokenStore}: ProvisionConnectionOpts) { - if (!config.generic) { - throw Error('Generic Webhooks are not configured'); - } - if (!config.generic.outbound) { - throw Error('Outbound support for Generic Webhooks is not configured'); - } - - const token = `hs-ob-${randomUUID()}`; - - if (typeof data.name !== "string" || data.name.length < 3 || data.name.length > 64) { - throw new ApiError("A webhook name must be between 3-64 characters.", ErrCode.BadValue); - } - - const validState = OutboundHookConnection.validateState(data); - - const stateKey = data.name; - const tokenKey = hashId(`${roomId}:${stateKey}`); - await tokenStore.storeGenericToken("outboundHookToken", tokenKey, token); - - await intent.underlyingClient.sendStateEvent(roomId, this.CanonicalEventType, stateKey, validState); - const connection = new OutboundHookConnection(roomId, validState, token, stateKey, intent); - return { - connection, - stateEventContent: validState, - } + if (method === "PUT" || method === "POST" || method === undefined) { + return { + name, + url, + method: method ?? "PUT", + }; } - - /** - * @param state Should be a pre-validated state object returned by {@link validateState} - */ - constructor( - roomId: string, - private state: OutboundHookConnectionState, - public readonly outboundToken: string, - stateKey: string, - private readonly intent: Intent, - ) { - super(roomId, stateKey, OutboundHookConnection.CanonicalEventType); + throw new ApiError( + "Outbound Method must be one of PUT,POST", + ErrCode.BadValue, + ); + } + + static async createConnectionForState( + roomId: string, + event: StateEvent>, + { intent, config, tokenStore }: InstantiateConnectionOpts, + ) { + if (!config.generic) { + throw Error("Generic webhooks are not configured"); } - - public isInterestedInStateEvent(eventType: string, stateKey: string) { - return OutboundHookConnection.EventTypes.includes(eventType) && this.stateKey === stateKey; + // Generic hooks store the hookId in the account data + const state = this.validateState(event.content); + const token = await tokenStore.getGenericToken( + "outboundHookToken", + hashId(`${roomId}:${event.stateKey}`), + ); + + if (!token) { + throw new Error(`Missing stored token for connection`); } - /** - * Check for any embedded media in the event, and if present then extract it as a blob. This - * function also returns event content with the encryption details stripped from the event contents. - * @param ev The Matrix event to inspect for embedded media. - * @returns A blob and event object if media is found, otherwise null. - * @throws If media was expected (due to the msgtype) but not provided, or if the media could not - * be found or decrypted. - */ - private async extractMedia(ev: MatrixEvent): Promise<{blob: Blob, event: MatrixEvent}|null> { - // Check for non-extendable event types first. - const content = ev.content as FileMessageEventContent; - - if (!["m.image", "m.audio", "m.file", "m.video"].includes(content.msgtype)) { - return null; - } - - const client = this.intent.underlyingClient; - let data: { data: Buffer, contentType?: string}; - if (client.crypto && content.file) { - data = { - data: await client.crypto.decryptMedia(content.file), - contentType: content.info?.mimetype - }; - const strippedContent = {...ev, content: { - ...content, - file: null, - }}; - return { - blob: new File([await client.crypto.decryptMedia(content.file)], content.body, { type: data.contentType }), - event: strippedContent - } - } else if (content.url) { - data = await this.intent.underlyingClient.downloadContent(content.url); - return { - blob: new File([data.data], content.body, { type: data.contentType }), - event: ev, - }; - } - - throw Error('Missing file or url key on event, not handling media'); + return new OutboundHookConnection( + roomId, + state, + token, + event.stateKey, + intent, + ); + } + + static async provisionConnection( + roomId: string, + userId: string, + data: Record = {}, + { intent, config, tokenStore }: ProvisionConnectionOpts, + ) { + if (!config.generic) { + throw Error("Generic Webhooks are not configured"); + } + if (!config.generic.outbound) { + throw Error("Outbound support for Generic Webhooks is not configured"); } + const token = `hs-ob-${randomUUID()}`; - public async onEvent(ev: MatrixEvent): Promise { - // The event content first. - const multipartBlob = new FormData(); - try { - const mediaResult = await this.extractMedia(ev); - if (mediaResult) { - multipartBlob.set('event', new Blob([JSON.stringify(mediaResult?.event)], { - type: 'application/json', - }), "event_data.json"); - multipartBlob.set('media', mediaResult.blob); - } - } catch (ex) { - log.warn(`Failed to get media for ${ev.event_id} in ${this.roomId}`, ex); - } - - if (!multipartBlob.has('event')) { - multipartBlob.set('event', new Blob([JSON.stringify(ev)], { - type: 'application/json', - }), "event_data.json"); - } - - try { - await axios.request({ - url: this.state.url, - data: multipartBlob, - method: this.state.method, - responseType: 'text', - validateStatus: (status) => status >= 200 && status <= 299, - headers: { - 'User-Agent': UserAgent, - 'X-Matrix-Hookshot-RoomId': this.roomId, - 'X-Matrix-Hookshot-EventId': ev.event_id, - 'X-Matrix-Hookshot-Token': this.outboundToken, - }, - }); - log.info(`Sent webhook for ${ev.event_id}`); - } catch (ex) { - if (!isAxiosError(ex)) { - log.error(`Failed to send outbound webhook`, ex); - throw ex; - } - if (ex.status) { - log.error(`Failed to send outbound webhook: HTTP ${ex.status}`); - } else { - log.error(`Failed to send outbound webhook: ${ex.code}`); - } - log.debug("Response from server", ex.response?.data); - } + if ( + typeof data.name !== "string" || + data.name.length < 3 || + data.name.length > 64 + ) { + throw new ApiError( + "A webhook name must be between 3-64 characters.", + ErrCode.BadValue, + ); } - public static getProvisionerDetails(botUserId: string) { - return { - service: "genericOutbound", - eventType: OutboundHookConnection.CanonicalEventType, - type: "Webhook", - botUserId: botUserId, - } + const validState = OutboundHookConnection.validateState(data); + + const stateKey = data.name; + const tokenKey = hashId(`${roomId}:${stateKey}`); + await tokenStore.storeGenericToken("outboundHookToken", tokenKey, token); + + await intent.underlyingClient.sendStateEvent( + roomId, + this.CanonicalEventType, + stateKey, + validState, + ); + const connection = new OutboundHookConnection( + roomId, + validState, + token, + stateKey, + intent, + ); + return { + connection, + stateEventContent: validState, + }; + } + + /** + * @param state Should be a pre-validated state object returned by {@link validateState} + */ + constructor( + roomId: string, + private state: OutboundHookConnectionState, + public readonly outboundToken: string, + stateKey: string, + private readonly intent: Intent, + ) { + super(roomId, stateKey, OutboundHookConnection.CanonicalEventType); + } + + public isInterestedInStateEvent(eventType: string, stateKey: string) { + return ( + OutboundHookConnection.EventTypes.includes(eventType) && + this.stateKey === stateKey + ); + } + + /** + * Check for any embedded media in the event, and if present then extract it as a blob. This + * function also returns event content with the encryption details stripped from the event contents. + * @param ev The Matrix event to inspect for embedded media. + * @returns A blob and event object if media is found, otherwise null. + * @throws If media was expected (due to the msgtype) but not provided, or if the media could not + * be found or decrypted. + */ + private async extractMedia( + ev: MatrixEvent, + ): Promise<{ blob: Blob; event: MatrixEvent } | null> { + // Check for non-extendable event types first. + const content = ev.content as FileMessageEventContent; + + if ( + !["m.image", "m.audio", "m.file", "m.video"].includes(content.msgtype) + ) { + return null; } - public getProvisionerDetails(showSecrets = false): OutboundHookResponseItem { - return { - ...OutboundHookConnection.getProvisionerDetails(this.intent.userId), - id: this.connectionId, - config: { - url: this.state.url, - method: this.state.method, - name: this.state.name, - }, - ...(showSecrets ? { secrets: { - token: this.outboundToken, - } satisfies OutboundHookSecrets} : undefined) - } + const client = this.intent.underlyingClient; + let data: { data: Buffer; contentType?: string }; + if (client.crypto && content.file) { + data = { + data: await client.crypto.decryptMedia(content.file), + contentType: content.info?.mimetype, + }; + const strippedContent = { + ...ev, + content: { + ...content, + file: null, + }, + }; + return { + blob: new File( + [await client.crypto.decryptMedia(content.file)], + content.body, + { type: data.contentType }, + ), + event: strippedContent, + }; + } else if (content.url) { + data = await this.intent.underlyingClient.downloadContent(content.url); + return { + blob: new File([data.data], content.body, { type: data.contentType }), + event: ev, + }; } - public async onRemove() { - log.info(`Removing ${this.toString()} for ${this.roomId}`); - // Do a sanity check that the event exists. - await this.intent.underlyingClient.getRoomStateEvent(this.roomId, OutboundHookConnection.CanonicalEventType, this.stateKey); - await this.intent.underlyingClient.sendStateEvent(this.roomId, OutboundHookConnection.CanonicalEventType, this.stateKey, { disabled: true }); - // TODO: Remove token - + throw Error("Missing file or url key on event, not handling media"); + } + + public async onEvent(ev: MatrixEvent): Promise { + // The event content first. + const multipartBlob = new FormData(); + try { + const mediaResult = await this.extractMedia(ev); + if (mediaResult) { + multipartBlob.set( + "event", + new Blob([JSON.stringify(mediaResult?.event)], { + type: "application/json", + }), + "event_data.json", + ); + multipartBlob.set("media", mediaResult.blob); + } + } catch (ex) { + log.warn(`Failed to get media for ${ev.event_id} in ${this.roomId}`, ex); } - public async provisionerUpdateConfig(userId: string, config: Record) { - config = { ...this.state, ...config }; - const validatedConfig = OutboundHookConnection.validateState(config); - await this.intent.underlyingClient.sendStateEvent(this.roomId, OutboundHookConnection.CanonicalEventType, this.stateKey, - { - ...validatedConfig, - } - ); - this.state = validatedConfig; + if (!multipartBlob.has("event")) { + multipartBlob.set( + "event", + new Blob([JSON.stringify(ev)], { + type: "application/json", + }), + "event_data.json", + ); } - public toString() { - return `OutboundHookConnection ${this.roomId}`; + try { + await axios.request({ + url: this.state.url, + data: multipartBlob, + method: this.state.method, + responseType: "text", + validateStatus: (status) => status >= 200 && status <= 299, + headers: { + "User-Agent": UserAgent, + "X-Matrix-Hookshot-RoomId": this.roomId, + "X-Matrix-Hookshot-EventId": ev.event_id, + "X-Matrix-Hookshot-Token": this.outboundToken, + }, + }); + log.info(`Sent webhook for ${ev.event_id}`); + } catch (ex) { + if (!isAxiosError(ex)) { + log.error(`Failed to send outbound webhook`, ex); + throw ex; + } + if (ex.status) { + log.error(`Failed to send outbound webhook: HTTP ${ex.status}`); + } else { + log.error(`Failed to send outbound webhook: ${ex.code}`); + } + log.debug("Response from server", ex.response?.data); } -} \ No newline at end of file + } + + public static getProvisionerDetails(botUserId: string) { + return { + service: "genericOutbound", + eventType: OutboundHookConnection.CanonicalEventType, + type: "Webhook", + botUserId: botUserId, + }; + } + + public getProvisionerDetails(showSecrets = false): OutboundHookResponseItem { + return { + ...OutboundHookConnection.getProvisionerDetails(this.intent.userId), + id: this.connectionId, + config: { + url: this.state.url, + method: this.state.method, + name: this.state.name, + }, + ...(showSecrets + ? { + secrets: { + token: this.outboundToken, + } satisfies OutboundHookSecrets, + } + : undefined), + }; + } + + public async onRemove() { + log.info(`Removing ${this.toString()} for ${this.roomId}`); + // Do a sanity check that the event exists. + await this.intent.underlyingClient.getRoomStateEvent( + this.roomId, + OutboundHookConnection.CanonicalEventType, + this.stateKey, + ); + await this.intent.underlyingClient.sendStateEvent( + this.roomId, + OutboundHookConnection.CanonicalEventType, + this.stateKey, + { disabled: true }, + ); + // TODO: Remove token + } + + public async provisionerUpdateConfig( + userId: string, + config: Record, + ) { + config = { ...this.state, ...config }; + const validatedConfig = OutboundHookConnection.validateState(config); + await this.intent.underlyingClient.sendStateEvent( + this.roomId, + OutboundHookConnection.CanonicalEventType, + this.stateKey, + { + ...validatedConfig, + }, + ); + this.state = validatedConfig; + } + + public toString() { + return `OutboundHookConnection ${this.roomId}`; + } +} diff --git a/src/Connections/SetupConnection.ts b/src/Connections/SetupConnection.ts index cc56835e2..8611a1ab5 100644 --- a/src/Connections/SetupConnection.ts +++ b/src/Connections/SetupConnection.ts @@ -1,6 +1,18 @@ -import { BotCommands, botCommand, compileBotCommands, HelpFunction } from "../BotCommands"; +import { + BotCommands, + botCommand, + compileBotCommands, + HelpFunction, +} from "../BotCommands"; import { CommandConnection } from "./CommandConnection"; -import { GenericHookConnection, GenericHookConnectionState, GitHubRepoConnection, JiraProjectConnection, JiraProjectConnectionState, OutboundHookConnection } from "."; +import { + GenericHookConnection, + GenericHookConnectionState, + GitHubRepoConnection, + JiraProjectConnection, + JiraProjectConnectionState, + OutboundHookConnection, +} from "."; import { CommandError } from "../errors"; import { BridgePermissionLevel } from "../config/Config"; import markdown from "markdown-it"; @@ -10,538 +22,985 @@ import { URL } from "url"; import { SetupWidget } from "../Widgets/SetupWidget"; import { AdminRoom } from "../AdminRoom"; import { GitLabRepoConnection } from "./GitlabRepo"; -import { IConnection, IConnectionState, ProvisionConnectionOpts } from "./IConnection"; +import { + IConnection, + IConnectionState, + ProvisionConnectionOpts, +} from "./IConnection"; import { ApiError, Logger } from "matrix-appservice-bridge"; import { Intent } from "matrix-bot-sdk"; -import YAML from 'yaml'; +import YAML from "yaml"; import { HoundConnection } from "./HoundConnection"; import { OpenProjectConnection } from "./OpenProjectConnection"; const md = new markdown(); const log = new Logger("SetupConnection"); -const parseDurationImport = import('parse-duration'); +const parseDurationImport = import("parse-duration"); -const OUTBOUND_DOCS_LINK = "https://matrix-org.github.io/matrix-hookshot/latest/setup/webhooks.html"; +const OUTBOUND_DOCS_LINK = + "https://matrix-org.github.io/matrix-hookshot/latest/setup/webhooks.html"; /** * Handles setting up a room with connections. This connection is "virtual" in that it has * no state, and is only invoked when messages from other clients fall through. */ export class SetupConnection extends CommandConnection { - - static botCommands: BotCommands; - static helpMessage: HelpFunction; - - private get config() { - return this.provisionOpts.config; + static botCommands: BotCommands; + static helpMessage: HelpFunction; + + private get config() { + return this.provisionOpts.config; + } + + private get as() { + return this.provisionOpts.as; + } + + private get intent() { + return this.provisionOpts.intent; + } + + private get client() { + return this.intent.underlyingClient; + } + + protected validateConnectionState(content: unknown) { + log.warn("SetupConnection has no state to be validated"); + return content as IConnectionState; + } + + constructor( + readonly roomId: string, + readonly prefix: string, + readonly serviceTypes: string[], + readonly helpCategories: string[], + private readonly provisionOpts: ProvisionConnectionOpts, + private readonly getOrCreateAdminRoom: ( + intent: Intent, + userId: string, + ) => Promise, + private readonly pushConnections: (...connections: IConnection[]) => void, + ) { + super( + roomId, + "", + "", + // TODO Consider storing room-specific config in state. + {}, + provisionOpts.intent.underlyingClient, + SetupConnection.botCommands, + SetupConnection.helpMessage, + helpCategories, + prefix, + ); + this.includeTitlesInHelp = false; + } + + @botCommand("github repo", { + help: "Create a connection for a GitHub repository. (You must be logged in with GitHub to do this.)", + requiredArgs: ["url"], + includeUserId: true, + category: GitHubRepoConnection.ServiceCategory, + }) + public async onGitHubRepo(userId: string, url: string) { + if (!this.provisionOpts.github || !this.config.github) { + throw new CommandError( + "not-configured", + "The bridge is not configured to support GitHub.", + ); } - private get as() { - return this.provisionOpts.as; + await this.checkUserPermissions( + userId, + GitHubRepoConnection.ServiceCategory, + GitHubRepoConnection.CanonicalEventType, + ); + const octokit = + await this.provisionOpts.tokenStore.getOctokitForUser(userId); + if (!octokit) { + throw new CommandError( + "User not logged in", + "You are not logged into GitHub. Start a DM with this bot and use the command `github login`.", + ); } - - private get intent() { - return this.provisionOpts.intent; + const urlParts = + /^https:\/\/github\.com\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/.exec( + url.trim().toLowerCase(), + ); + if (!urlParts) { + throw new CommandError( + "Invalid GitHub url", + "The GitHub url you entered was not valid.", + ); } - - private get client() { - return this.intent.underlyingClient; + const [, org, repo] = urlParts; + const { connection } = await GitHubRepoConnection.provisionConnection( + this.roomId, + userId, + { org, repo }, + this.provisionOpts, + ); + this.pushConnections(connection); + await this.client.sendNotice( + this.roomId, + `Room configured to bridge ${connection.org}/${connection.repo}`, + ); + } + + @botCommand("gitlab project", { + help: "Create a connection for a GitHub project. (You must be logged in with GitLab to do this.)", + requiredArgs: ["url"], + includeUserId: true, + category: GitLabRepoConnection.ServiceCategory, + }) + public async onGitLabRepo(userId: string, url: string) { + if (!this.config.gitlab) { + throw new CommandError( + "not-configured", + "The bridge is not configured to support GitLab.", + ); } - protected validateConnectionState(content: unknown) { - log.warn("SetupConnection has no state to be validated"); - return content as IConnectionState; + await this.checkUserPermissions( + userId, + GitLabRepoConnection.ServiceCategory, + GitLabRepoConnection.CanonicalEventType, + ); + + const { name, instance } = + this.config.gitlab.getInstanceByProjectUrl(url) || {}; + if (!instance || !name) { + throw new CommandError( + "not-configured", + "No instance found that matches the provided URL.", + ); } - constructor( - readonly roomId: string, - readonly prefix: string, - readonly serviceTypes: string[], - readonly helpCategories: string[], - private readonly provisionOpts: ProvisionConnectionOpts, - private readonly getOrCreateAdminRoom: (intent: Intent, userId: string) => Promise, - private readonly pushConnections: (...connections: IConnection[]) => void, - ) { - super( - roomId, - "", - "", - // TODO Consider storing room-specific config in state. - {}, - provisionOpts.intent.underlyingClient, - SetupConnection.botCommands, - SetupConnection.helpMessage, - helpCategories, - prefix, - ); - this.includeTitlesInHelp = false; + const client = await this.provisionOpts.tokenStore.getGitLabForUser( + userId, + instance.url, + ); + if (!client) { + throw new CommandError( + "User not logged in", + "You are not logged into this GitLab instance. Start a DM with this bot and use the command `gitlab personaltoken`.", + ); } - - @botCommand("github repo", { help: "Create a connection for a GitHub repository. (You must be logged in with GitHub to do this.)", requiredArgs: ["url"], includeUserId: true, category: GitHubRepoConnection.ServiceCategory}) - public async onGitHubRepo(userId: string, url: string) { - if (!this.provisionOpts.github || !this.config.github) { - throw new CommandError("not-configured", "The bridge is not configured to support GitHub."); - } - - await this.checkUserPermissions(userId, GitHubRepoConnection.ServiceCategory, GitHubRepoConnection.CanonicalEventType); - const octokit = await this.provisionOpts.tokenStore.getOctokitForUser(userId); - if (!octokit) { - throw new CommandError("User not logged in", "You are not logged into GitHub. Start a DM with this bot and use the command `github login`."); - } - const urlParts = /^https:\/\/github\.com\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/.exec(url.trim().toLowerCase()); - if (!urlParts) { - throw new CommandError("Invalid GitHub url", "The GitHub url you entered was not valid."); - } - const [, org, repo] = urlParts; - const {connection} = await GitHubRepoConnection.provisionConnection(this.roomId, userId, {org, repo}, this.provisionOpts); - this.pushConnections(connection); - await this.client.sendNotice(this.roomId, `Room configured to bridge ${connection.org}/${connection.repo}`); + const path = url.slice(instance.url.length + 1); + if (!path) { + throw new CommandError( + "Invalid GitLab url", + "The GitLab project url you entered was not valid.", + ); } - - @botCommand("gitlab project", { help: "Create a connection for a GitHub project. (You must be logged in with GitLab to do this.)", requiredArgs: ["url"], includeUserId: true, category: GitLabRepoConnection.ServiceCategory}) - public async onGitLabRepo(userId: string, url: string) { - if (!this.config.gitlab) { - throw new CommandError("not-configured", "The bridge is not configured to support GitLab."); - } - - await this.checkUserPermissions(userId, GitLabRepoConnection.ServiceCategory, GitLabRepoConnection.CanonicalEventType); - - const {name, instance} = this.config.gitlab.getInstanceByProjectUrl(url) || {}; - if (!instance || !name) { - throw new CommandError("not-configured", "No instance found that matches the provided URL."); - } - - const client = await this.provisionOpts.tokenStore.getGitLabForUser(userId, instance.url); - if (!client) { - throw new CommandError("User not logged in", "You are not logged into this GitLab instance. Start a DM with this bot and use the command `gitlab personaltoken`."); - } - const path = url.slice(instance.url.length + 1); - if (!path) { - throw new CommandError("Invalid GitLab url", "The GitLab project url you entered was not valid."); - } - const {connection, warning} = await GitLabRepoConnection.provisionConnection(this.roomId, userId, {path, instance: name}, this.provisionOpts); - this.pushConnections(connection); - await this.client.sendNotice(this.roomId, `Room configured to bridge ${connection.prettyPath}` + (warning ? `\n${warning.header}: ${warning.message}` : "")); + const { connection, warning } = + await GitLabRepoConnection.provisionConnection( + this.roomId, + userId, + { path, instance: name }, + this.provisionOpts, + ); + this.pushConnections(connection); + await this.client.sendNotice( + this.roomId, + `Room configured to bridge ${connection.prettyPath}` + + (warning ? `\n${warning.header}: ${warning.message}` : ""), + ); + } + + private async checkJiraLogin(userId: string, urlStr: string) { + const jiraClient = await this.provisionOpts.tokenStore.getJiraForUser( + userId, + urlStr, + ); + if (!jiraClient) { + throw new CommandError( + "User not logged in", + "You are not logged into Jira. Start a DM with this bot and use the command `jira login`.", + ); } - - private async checkJiraLogin(userId: string, urlStr: string) { - const jiraClient = await this.provisionOpts.tokenStore.getJiraForUser(userId, urlStr); - if (!jiraClient) { - throw new CommandError("User not logged in", "You are not logged into Jira. Start a DM with this bot and use the command `jira login`."); - } + } + + private async getJiraProjectSafeUrl(urlStr: string) { + const url = new URL(urlStr); + const urlParts = /\/projects\/(\w+)\/?(\w+\/?)*$/.exec(url.pathname); + const projectKey = urlParts?.[1] || url.searchParams.get("projectKey"); + if (!projectKey) { + throw new CommandError( + "Invalid Jira url", + "The JIRA project url you entered was not valid. It should be in the format of `https://jira-instance/.../projects/PROJECTKEY/...` or `.../RapidBoard.jspa?projectKey=TEST`.", + ); } - - private async getJiraProjectSafeUrl(urlStr: string) { - const url = new URL(urlStr); - const urlParts = /\/projects\/(\w+)\/?(\w+\/?)*$/.exec(url.pathname); - const projectKey = urlParts?.[1] || url.searchParams.get('projectKey'); - if (!projectKey) { - throw new CommandError("Invalid Jira url", "The JIRA project url you entered was not valid. It should be in the format of `https://jira-instance/.../projects/PROJECTKEY/...` or `.../RapidBoard.jspa?projectKey=TEST`."); - } - return `https://${url.host}/projects/${projectKey}`; + return `https://${url.host}/projects/${projectKey}`; + } + + @botCommand("jira project", { + help: "Create a connection for a JIRA project. (You must be logged in with JIRA to do this.)", + requiredArgs: ["url"], + includeUserId: true, + category: JiraProjectConnection.ServiceCategory, + }) + public async onJiraProject(userId: string, urlStr: string) { + if (!this.config.jira) { + throw new CommandError( + "not-configured", + "The bridge is not configured to support Jira.", + ); } - @botCommand("jira project", { help: "Create a connection for a JIRA project. (You must be logged in with JIRA to do this.)", requiredArgs: ["url"], includeUserId: true, category: JiraProjectConnection.ServiceCategory}) - public async onJiraProject(userId: string, urlStr: string) { - if (!this.config.jira) { - throw new CommandError("not-configured", "The bridge is not configured to support Jira."); - } - - await this.checkUserPermissions(userId, JiraProjectConnection.ServiceCategory, JiraProjectConnection.CanonicalEventType); - await this.checkJiraLogin(userId, urlStr); - const safeUrl = await this.getJiraProjectSafeUrl(urlStr); - - const res = await JiraProjectConnection.provisionConnection(this.roomId, userId, { url: safeUrl }, this.provisionOpts); - this.pushConnections(res.connection); - await this.client.sendNotice(this.roomId, `Room configured to bridge Jira project ${res.connection.projectKey}.`); - } - - @botCommand("jira list project", { help: "Show JIRA projects currently connected to.", category: JiraProjectConnection.ServiceCategory}) - public async onJiraListProject() { - const projects: JiraProjectConnectionState[] = await this.client.getRoomState(this.roomId).catch((err: any) => { - if (err.body.errcode === 'M_NOT_FOUND') { - return []; // not an error to us - } - throw err; - }).then(events => - events.filter( - (ev: any) => ( - ev.type === JiraProjectConnection.CanonicalEventType || - ev.type === JiraProjectConnection.LegacyCanonicalEventType - ) && ev.content.url - ).map(ev => ev.content) + await this.checkUserPermissions( + userId, + JiraProjectConnection.ServiceCategory, + JiraProjectConnection.CanonicalEventType, + ); + await this.checkJiraLogin(userId, urlStr); + const safeUrl = await this.getJiraProjectSafeUrl(urlStr); + + const res = await JiraProjectConnection.provisionConnection( + this.roomId, + userId, + { url: safeUrl }, + this.provisionOpts, + ); + this.pushConnections(res.connection); + await this.client.sendNotice( + this.roomId, + `Room configured to bridge Jira project ${res.connection.projectKey}.`, + ); + } + + @botCommand("jira list project", { + help: "Show JIRA projects currently connected to.", + category: JiraProjectConnection.ServiceCategory, + }) + public async onJiraListProject() { + const projects: JiraProjectConnectionState[] = await this.client + .getRoomState(this.roomId) + .catch((err: any) => { + if (err.body.errcode === "M_NOT_FOUND") { + return []; // not an error to us + } + throw err; + }) + .then((events) => + events + .filter( + (ev: any) => + (ev.type === JiraProjectConnection.CanonicalEventType || + ev.type === JiraProjectConnection.LegacyCanonicalEventType) && + ev.content.url, + ) + .map((ev) => ev.content), + ); + + if (projects.length === 0) { + return this.client.sendHtmlNotice( + this.roomId, + md.renderInline("Not connected to any JIRA projects"), + ); + } else { + return this.client.sendHtmlNotice( + this.roomId, + md.render( + "Currently connected to these JIRA projects:\n\n" + + projects.map((project) => ` - ${project.url}`).join("\n"), + ), + ); + } + } + + @botCommand("jira remove project", { + help: "Remove a connection for a JIRA project.", + requiredArgs: ["url"], + includeUserId: true, + category: JiraProjectConnection.ServiceCategory, + }) + public async onJiraRemoveProject(userId: string, urlStr: string) { + await this.checkUserPermissions( + userId, + JiraProjectConnection.ServiceCategory, + JiraProjectConnection.CanonicalEventType, + ); + await this.checkJiraLogin(userId, urlStr); + const safeUrl = await this.getJiraProjectSafeUrl(urlStr); + + const eventTypes = [ + JiraProjectConnection.CanonicalEventType, + JiraProjectConnection.LegacyCanonicalEventType, + ]; + let event = null; + let eventType = ""; + for (eventType of eventTypes) { + try { + event = await this.client.getRoomStateEvent( + this.roomId, + eventType, + safeUrl, ); - - if (projects.length === 0) { - return this.client.sendHtmlNotice(this.roomId, md.renderInline('Not connected to any JIRA projects')); - } else { - return this.client.sendHtmlNotice(this.roomId, md.render( - 'Currently connected to these JIRA projects:\n\n' + - projects.map(project => ` - ${project.url}`).join('\n') - )); + break; + } catch (err: any) { + if (err.body.errcode !== "M_NOT_FOUND") { + throw err; } + } } - - @botCommand("jira remove project", { help: "Remove a connection for a JIRA project.", requiredArgs: ["url"], includeUserId: true, category: JiraProjectConnection.ServiceCategory}) - public async onJiraRemoveProject(userId: string, urlStr: string) { - await this.checkUserPermissions(userId, JiraProjectConnection.ServiceCategory, JiraProjectConnection.CanonicalEventType); - await this.checkJiraLogin(userId, urlStr); - const safeUrl = await this.getJiraProjectSafeUrl(urlStr); - - const eventTypes = [ - JiraProjectConnection.CanonicalEventType, - JiraProjectConnection.LegacyCanonicalEventType, - ]; - let event = null; - let eventType = ""; - for (eventType of eventTypes) { - try { - event = await this.client.getRoomStateEvent(this.roomId, eventType, safeUrl); - break; - } catch (err: any) { - if (err.body.errcode !== 'M_NOT_FOUND') { - throw err; - } - } - } - if (!event || Object.keys(event).length === 0) { - throw new CommandError("Invalid Jira project URL", `Feed "${urlStr}" is not currently bridged to this room`); - } - - await this.client.sendStateEvent(this.roomId, eventType, safeUrl, {}); - return this.client.sendHtmlNotice(this.roomId, md.renderInline(`Room no longer bridged to Jira project \`${safeUrl}\`.`)); + if (!event || Object.keys(event).length === 0) { + throw new CommandError( + "Invalid Jira project URL", + `Feed "${urlStr}" is not currently bridged to this room`, + ); } - @botCommand("webhook", { help: "Create an inbound webhook. The liveDuration must be specified as a duration string (e.g. 30d).", requiredArgs: ["name"], includeUserId: true, optionalArgs: ['liveDuration'], category: GenericHookConnection.ServiceCategory}) - public async onWebhook(userId: string, name: string, liveDuration?: string) { - if (!this.config.generic?.enabled) { - throw new CommandError("not-configured", "The bridge is not configured to support webhooks."); - } - - let expirationDate: string|undefined = undefined; - if (liveDuration) { - const expirationDuration = await (await parseDurationImport).default(liveDuration); - if (!expirationDuration) { - throw new CommandError("Bad webhook duration", "Duration could not be parsed"); - } - expirationDate = new Date(expirationDuration + Date.now()).toISOString(); - } - - await this.checkUserPermissions(userId, "webhooks", GitHubRepoConnection.CanonicalEventType); - - if (!name || name.length < 3 || name.length > 64) { - throw new CommandError("Bad webhook name", "A webhook name must be between 3-64 characters."); - } - const c = await GenericHookConnection.provisionConnection(this.roomId, userId, {name, expirationDate}, this.provisionOpts); - this.pushConnections(c.connection); - const url = new URL(c.connection.hookId, this.config.generic.parsedUrlPrefix); - const adminRoom = await this.getOrCreateAdminRoom(this.intent, userId); - const safeRoomId = encodeURIComponent(this.roomId); - await adminRoom.sendNotice( - `You have bridged the webhook "${name}" in https://matrix.to/#/${safeRoomId} .\n` + - // Line break before and no full stop after URL is intentional. - // This makes copying and pasting the URL much easier. - `Please configure your webhook source to use\n${url}` - ); - return this.client.sendNotice(this.roomId, `Room configured to bridge webhooks. See admin room for secret url.`); + await this.client.sendStateEvent(this.roomId, eventType, safeUrl, {}); + return this.client.sendHtmlNotice( + this.roomId, + md.renderInline(`Room no longer bridged to Jira project \`${safeUrl}\`.`), + ); + } + + @botCommand("webhook", { + help: "Create an inbound webhook. The liveDuration must be specified as a duration string (e.g. 30d).", + requiredArgs: ["name"], + includeUserId: true, + optionalArgs: ["liveDuration"], + category: GenericHookConnection.ServiceCategory, + }) + public async onWebhook(userId: string, name: string, liveDuration?: string) { + if (!this.config.generic?.enabled) { + throw new CommandError( + "not-configured", + "The bridge is not configured to support webhooks.", + ); } - - - @botCommand("webhook list", { help: "Show webhooks currently configured.", category: GenericHookConnection.ServiceCategory}) - public async onWebhookList() { - const webhooks: GenericHookConnectionState[] = await this.client.getRoomState(this.roomId).catch((err: any) => { - if (err.body.errcode === 'M_NOT_FOUND') { - 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) + let expirationDate: string | undefined = undefined; + if (liveDuration) { + const expirationDuration = await ( + await parseDurationImport + ).default(liveDuration); + if (!expirationDuration) { + throw new CommandError( + "Bad webhook duration", + "Duration could not be parsed", ); - - 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') - )); - } + } + expirationDate = new Date(expirationDuration + Date.now()).toISOString(); } - @botCommand("webhook remove", { help: "Remove a webhook from the room.", requiredArgs: ["name"], includeUserId: true, category: GenericHookConnection.ServiceCategory}) - public async onWebhookRemove(userId: string, name: string) { - await this.checkUserPermissions(userId, GenericHookConnection.ServiceCategory, GenericHookConnection.CanonicalEventType); - - const event = await this.client.getRoomStateEvent(this.roomId, GenericHookConnection.CanonicalEventType, name).catch((err: any) => { - if (err.body.errcode === 'M_NOT_FOUND') { - 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 + await this.checkUserPermissions( + userId, + "webhooks", + GitHubRepoConnection.CanonicalEventType, + ); + + if (!name || name.length < 3 || name.length > 64) { + throw new CommandError( + "Bad webhook name", + "A webhook name must be between 3-64 characters.", + ); + } + const c = await GenericHookConnection.provisionConnection( + this.roomId, + userId, + { name, expirationDate }, + this.provisionOpts, + ); + this.pushConnections(c.connection); + const url = new URL( + c.connection.hookId, + this.config.generic.parsedUrlPrefix, + ); + const adminRoom = await this.getOrCreateAdminRoom(this.intent, userId); + const safeRoomId = encodeURIComponent(this.roomId); + await adminRoom.sendNotice( + `You have bridged the webhook "${name}" in https://matrix.to/#/${safeRoomId} .\n` + + // Line break before and no full stop after URL is intentional. + // This makes copying and pasting the URL much easier. + `Please configure your webhook source to use\n${url}`, + ); + return this.client.sendNotice( + this.roomId, + `Room configured to bridge webhooks. See admin room for secret url.`, + ); + } + + @botCommand("webhook list", { + help: "Show webhooks currently configured.", + category: GenericHookConnection.ServiceCategory, + }) + public async onWebhookList() { + const webhooks: GenericHookConnectionState[] = await this.client + .getRoomState(this.roomId) + .catch((err: any) => { + if (err.body.errcode === "M_NOT_FOUND") { + 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.renderInline(`Removed webhook \`${name}\``)); + return this.client.sendHtmlNotice( + this.roomId, + md.render( + "Webhooks configured:\n\n" + + feedDescriptions.map((desc) => ` - ${desc}`).join("\n"), + ), + ); } - - - - @botCommand("outbound-hook", { help: "Create an outbound webhook.", requiredArgs: ["name", "url"], includeUserId: true, category: GenericHookConnection.ServiceCategory}) - public async onOutboundHook(userId: string, name: string, url: string) { - if (!this.config.generic?.outbound) { - throw new CommandError("not-configured", "The bridge is not configured to support webhooks."); - } - - await this.checkUserPermissions(userId, "webhooks", GitHubRepoConnection.CanonicalEventType); - - const { connection }= await OutboundHookConnection.provisionConnection(this.roomId, userId, {name, url}, this.provisionOpts); - this.pushConnections(connection); - - const adminRoom = await this.getOrCreateAdminRoom(this.intent, userId); - const safeRoomId = encodeURIComponent(this.roomId); - - await this.client.sendHtmlNotice( - adminRoom.roomId, - md.renderInline( - `You have bridged the webhook "${name}" in https://matrix.to/#/${safeRoomId} .\n` + - // Line break before and no full stop after URL is intentional. - // This makes copying and pasting the URL much easier. - `Please use the secret token \`${connection.outboundToken}\` when validating the request.\n` + - `See the [documentation](${OUTBOUND_DOCS_LINK}) for more information`, - )); - return this.client.sendNotice(this.roomId, `Room configured to bridge outbound webhooks. See admin room for the secret token.`); + } + + @botCommand("webhook remove", { + help: "Remove a webhook from the room.", + requiredArgs: ["name"], + includeUserId: true, + category: GenericHookConnection.ServiceCategory, + }) + public async onWebhookRemove(userId: string, name: string) { + await this.checkUserPermissions( + userId, + GenericHookConnection.ServiceCategory, + GenericHookConnection.CanonicalEventType, + ); + + const event = await this.client + .getRoomStateEvent( + this.roomId, + GenericHookConnection.CanonicalEventType, + name, + ) + .catch((err: any) => { + if (err.body.errcode === "M_NOT_FOUND") { + 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.`, + ); } - - @botCommand("figma file", { help: "Bridge a Figma file to the room.", requiredArgs: ["url"], includeUserId: true, category: FigmaFileConnection.ServiceCategory}) - public async onFigma(userId: string, url: string) { - if (!this.config.figma) { - throw new CommandError("not-configured", "The bridge is not configured to support Figma."); - } - - await this.checkUserPermissions(userId, FigmaFileConnection.ServiceCategory, FigmaFileConnection.CanonicalEventType); - - const res = /https:\/\/www\.figma\.com\/file\/(\w+).+/.exec(url); - if (!res) { - throw new CommandError("Invalid Figma url", "The Figma file url you entered was not valid. It should be in the format of `https://figma.com/file/FILEID/...`."); - } - const [, fileId] = res; - const {connection} = await FigmaFileConnection.provisionConnection(this.roomId, userId, { fileId }, this.provisionOpts); - this.pushConnections(connection); - return this.client.sendHtmlNotice(this.roomId, md.renderInline(`Room configured to bridge Figma file.`)); + await this.client.sendStateEvent( + this.roomId, + GenericHookConnection.CanonicalEventType, + name, + { + disabled: true, + }, + ); + + return this.client.sendHtmlNotice( + this.roomId, + md.renderInline(`Removed webhook \`${name}\``), + ); + } + + @botCommand("outbound-hook", { + help: "Create an outbound webhook.", + requiredArgs: ["name", "url"], + includeUserId: true, + category: GenericHookConnection.ServiceCategory, + }) + public async onOutboundHook(userId: string, name: string, url: string) { + if (!this.config.generic?.outbound) { + throw new CommandError( + "not-configured", + "The bridge is not configured to support webhooks.", + ); } - @botCommand("feed", { help: "Bridge an RSS/Atom feed to the room.", requiredArgs: ["url"], optionalArgs: ["label"], includeUserId: true, category: FeedConnection.ServiceCategory}) - public async onFeed(userId: string, url: string, label?: string) { - if (!this.config.feeds?.enabled) { - throw new CommandError("not-configured", "The bridge is not configured to support feeds."); - } + await this.checkUserPermissions( + userId, + "webhooks", + GitHubRepoConnection.CanonicalEventType, + ); + + const { connection } = await OutboundHookConnection.provisionConnection( + this.roomId, + userId, + { name, url }, + this.provisionOpts, + ); + this.pushConnections(connection); + + const adminRoom = await this.getOrCreateAdminRoom(this.intent, userId); + const safeRoomId = encodeURIComponent(this.roomId); + + await this.client.sendHtmlNotice( + adminRoom.roomId, + md.renderInline( + `You have bridged the webhook "${name}" in https://matrix.to/#/${safeRoomId} .\n` + + // Line break before and no full stop after URL is intentional. + // This makes copying and pasting the URL much easier. + `Please use the secret token \`${connection.outboundToken}\` when validating the request.\n` + + `See the [documentation](${OUTBOUND_DOCS_LINK}) for more information`, + ), + ); + return this.client.sendNotice( + this.roomId, + `Room configured to bridge outbound webhooks. See admin room for the secret token.`, + ); + } + + @botCommand("figma file", { + help: "Bridge a Figma file to the room.", + requiredArgs: ["url"], + includeUserId: true, + category: FigmaFileConnection.ServiceCategory, + }) + public async onFigma(userId: string, url: string) { + if (!this.config.figma) { + throw new CommandError( + "not-configured", + "The bridge is not configured to support Figma.", + ); + } - await this.checkUserPermissions(userId,FeedConnection.ServiceCategory, FeedConnection.CanonicalEventType); - - // provisionConnection will check it again, but won't give us a nice CommandError on failure - try { - await FeedConnection.validateUrl(url); - } catch (err: unknown) { - log.debug(`Feed URL '${url}' failed validation: ${err}`); - if (err instanceof ApiError) { - throw new CommandError("Invalid URL", err.error); - } else { - throw new CommandError("Invalid URL", `${url} doesn't look like a valid feed URL`); - } - } + await this.checkUserPermissions( + userId, + FigmaFileConnection.ServiceCategory, + FigmaFileConnection.CanonicalEventType, + ); + + const res = /https:\/\/www\.figma\.com\/file\/(\w+).+/.exec(url); + if (!res) { + throw new CommandError( + "Invalid Figma url", + "The Figma file url you entered was not valid. It should be in the format of `https://figma.com/file/FILEID/...`.", + ); + } + const [, fileId] = res; + const { connection } = await FigmaFileConnection.provisionConnection( + this.roomId, + userId, + { fileId }, + this.provisionOpts, + ); + this.pushConnections(connection); + return this.client.sendHtmlNotice( + this.roomId, + md.renderInline(`Room configured to bridge Figma file.`), + ); + } + + @botCommand("feed", { + help: "Bridge an RSS/Atom feed to the room.", + requiredArgs: ["url"], + optionalArgs: ["label"], + includeUserId: true, + category: FeedConnection.ServiceCategory, + }) + public async onFeed(userId: string, url: string, label?: string) { + if (!this.config.feeds?.enabled) { + throw new CommandError( + "not-configured", + "The bridge is not configured to support feeds.", + ); + } - const {connection} = await FeedConnection.provisionConnection(this.roomId, userId, { url, label }, this.provisionOpts); - this.pushConnections(connection); - return this.client.sendHtmlNotice(this.roomId, md.renderInline(`Room configured to bridge \`${url}\``)); - } - - @botCommand("feed list", { help: "Show feeds currently subscribed to. Supported formats `json` and `yaml`.", optionalArgs: ["format"], category: FeedConnection.ServiceCategory}) - 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 - } - throw err; - }).then(events => - events.filter( - (ev: any) => ev.type === FeedConnection.CanonicalEventType && ev.content.url - ).map(ev => ev.content) + await this.checkUserPermissions( + userId, + FeedConnection.ServiceCategory, + FeedConnection.CanonicalEventType, + ); + + // provisionConnection will check it again, but won't give us a nice CommandError on failure + try { + await FeedConnection.validateUrl(url); + } catch (err: unknown) { + log.debug(`Feed URL '${url}' failed validation: ${err}`); + if (err instanceof ApiError) { + throw new CommandError("Invalid URL", err.error); + } else { + throw new CommandError( + "Invalid URL", + `${url} doesn't look like a valid feed URL`, ); - - if (feeds.length === 0) { - return this.client.sendHtmlNotice(this.roomId, md.renderInline('Not subscribed to any feeds')); - } else { - 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; - }); - - 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)); - } + } } - @botCommand("feed remove", { help: "Unsubscribe from an RSS/Atom feed.", requiredArgs: ["url"], includeUserId: true, category: "feeds"}) - public async onFeedRemove(userId: string, url: string) { - await this.checkUserPermissions(userId, FeedConnection.ServiceCategory, FeedConnection.CanonicalEventType); - - const event = await this.client.getRoomStateEvent(this.roomId, FeedConnection.CanonicalEventType, url).catch((err: any) => { - if (err.body.errcode === 'M_NOT_FOUND') { - return null; // not an error to us - } - throw err; + const { connection } = await FeedConnection.provisionConnection( + this.roomId, + userId, + { url, label }, + this.provisionOpts, + ); + this.pushConnections(connection); + return this.client.sendHtmlNotice( + this.roomId, + md.renderInline(`Room configured to bridge \`${url}\``), + ); + } + + @botCommand("feed list", { + help: "Show feeds currently subscribed to. Supported formats `json` and `yaml`.", + optionalArgs: ["format"], + category: FeedConnection.ServiceCategory, + }) + 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 + } + throw err; + }) + .then((events) => + events + .filter( + (ev: any) => + ev.type === FeedConnection.CanonicalEventType && ev.content.url, + ) + .map((ev) => ev.content), + ); + + if (feeds.length === 0) { + return this.client.sendHtmlNotice( + this.roomId, + md.renderInline("Not subscribed to any feeds"), + ); + } else { + 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; }); - if (!event || Object.keys(event).length === 0) { - throw new CommandError("Invalid feed URL", `Feed "${url}" is not currently bridged to this room`); - } - await this.client.sendStateEvent(this.roomId, FeedConnection.CanonicalEventType, url, {}); - return this.client.sendHtmlNotice(this.roomId, md.renderInline(`Unsubscribed from \`${url}\``)); - } + 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"); + } - @botCommand("challenghound add", { help: "Bridge a ChallengeHound challenge to the room.", requiredArgs: ["url"], includeUserId: true, category: "challengehound"}) - public async onChallengeHoundAdd(userId: string, url: string) { - if (!this.config.challengeHound) { - throw new CommandError("not-configured", "The bridge is not configured to support challengeHound."); - } - - await this.checkUserPermissions(userId, HoundConnection.ServiceCategory, HoundConnection.CanonicalEventType); - const {connection, challengeName} = await HoundConnection.provisionConnection(this.roomId, userId, { url }, this.provisionOpts); - this.pushConnections(connection); - return this.client.sendHtmlNotice(this.roomId, md.renderInline(`Room configured to bridge ${challengeName}. Good luck!`)); - } - - @botCommand("challenghound remove", { help: "Unbridge a ChallengeHound challenge.", requiredArgs: ["urlOrId"], includeUserId: true, category: HoundConnection.ServiceCategory}) - public async onChallengeHoundRemove(userId: string, urlOrId: string) { - await this.checkUserPermissions(userId, HoundConnection.ServiceCategory, HoundConnection.CanonicalEventType); - const id = urlOrId.startsWith('http') ? HoundConnection.getIdFromURL(urlOrId) : urlOrId; - const event = await this.client.getRoomStateEvent(this.roomId, HoundConnection.CanonicalEventType, id).catch((err: any) => { - if (err.body.errcode === 'M_NOT_FOUND') { - return null; // not an error to us - } - throw err; - }); - if (!event || Object.keys(event).length === 0) { - throw new CommandError("Invalid feed URL", `Challenge "${id}" is not currently bridged to this room`); - } - - await this.client.sendStateEvent(this.roomId, FeedConnection.CanonicalEventType, id, {}); - return this.client.sendHtmlNotice(this.roomId, md.renderInline(`Unsubscribed from challenge`)); + return this.client.sendHtmlNotice(this.roomId, md.render(message)); } - - private getOpenProjectSafeUrl(urlStr: string): string { - // http://localhost:8080/projects/1/ - // This config is already validated. - if (!this.config.openProject?.baseURL.origin) { - throw Error('No BaseURL for OpenProject'); - } - const expectedOrigin = this.config.openProject.baseURL.origin; - const url = new URL(urlStr); - const urlParts = /\/projects\/(\d+)\/?/.exec(url.pathname); - const projectId = urlParts?.[1]; - if (!projectId) { - throw new CommandError("Invalid OpenProject", "The project url you entered was not valid. It should be in the format of `https://op-instance/projects/PROJECTID/...`"); - } - if (url.origin !== expectedOrigin) { - throw new CommandError("Invalid OpenProject", `The project url you entered was not valid. It should be part of ${expectedOrigin}`); - - } - return `http://${url.host}/projects/${projectId}`; + } + + @botCommand("feed remove", { + help: "Unsubscribe from an RSS/Atom feed.", + requiredArgs: ["url"], + includeUserId: true, + category: "feeds", + }) + public async onFeedRemove(userId: string, url: string) { + await this.checkUserPermissions( + userId, + FeedConnection.ServiceCategory, + FeedConnection.CanonicalEventType, + ); + + const event = await this.client + .getRoomStateEvent(this.roomId, FeedConnection.CanonicalEventType, url) + .catch((err: any) => { + if (err.body.errcode === "M_NOT_FOUND") { + return null; // not an error to us + } + throw err; + }); + if (!event || Object.keys(event).length === 0) { + throw new CommandError( + "Invalid feed URL", + `Feed "${url}" is not currently bridged to this room`, + ); } - @botCommand("openproject add", { help: "Bridge a OpenProject project to the room.", requiredArgs: ["url"], includeUserId: true, category: "openproject"}) - public async onOpenProjectAdd(userId: string, url: string) { - if (!this.config.openProject) { - throw new CommandError("not-configured", "The bridge is not configured to support open project."); - } - const safeUrl = this.getOpenProjectSafeUrl(url); - - await this.checkUserPermissions(userId, OpenProjectConnection.ServiceCategory, HoundConnection.CanonicalEventType); - const {connection} = await OpenProjectConnection.provisionConnection(this.roomId, userId, { url: safeUrl }, this.provisionOpts); - this.pushConnections(connection); - return this.client.sendHtmlNotice(this.roomId, md.renderInline(`Room configured to bridge this project`)); + await this.client.sendStateEvent( + this.roomId, + FeedConnection.CanonicalEventType, + url, + {}, + ); + return this.client.sendHtmlNotice( + this.roomId, + md.renderInline(`Unsubscribed from \`${url}\``), + ); + } + + @botCommand("challenghound add", { + help: "Bridge a ChallengeHound challenge to the room.", + requiredArgs: ["url"], + includeUserId: true, + category: "challengehound", + }) + public async onChallengeHoundAdd(userId: string, url: string) { + if (!this.config.challengeHound) { + throw new CommandError( + "not-configured", + "The bridge is not configured to support challengeHound.", + ); } - @botCommand("openproject remove", { help: "Remove an OpenProject project connection from this room.", requiredArgs: ["url"], includeUserId: true, category: OpenProjectConnection.ServiceCategory}) - public async onOpenProjectRemove(userId: string, urlStr: string) { - await this.checkUserPermissions(userId, OpenProjectConnection.ServiceCategory, OpenProjectConnection.CanonicalEventType); - - const safeUrl = await this.getJiraProjectSafeUrl(urlStr); - - let event; - try { - event = await this.client.getRoomStateEvent(this.roomId, OpenProjectConnection.CanonicalEventType, safeUrl); - } catch (err: any) { - if (err.body.errcode !== 'M_NOT_FOUND') { - throw err; - } - } - - if (!event || Object.keys(event).length === 0) { - throw new CommandError("Invalid OpenProject project URL", `OpenProject "${urlStr}" is not currently bridged to this room`); - } + await this.checkUserPermissions( + userId, + HoundConnection.ServiceCategory, + HoundConnection.CanonicalEventType, + ); + const { connection, challengeName } = + await HoundConnection.provisionConnection( + this.roomId, + userId, + { url }, + this.provisionOpts, + ); + this.pushConnections(connection); + return this.client.sendHtmlNotice( + this.roomId, + md.renderInline(`Room configured to bridge ${challengeName}. Good luck!`), + ); + } + + @botCommand("challenghound remove", { + help: "Unbridge a ChallengeHound challenge.", + requiredArgs: ["urlOrId"], + includeUserId: true, + category: HoundConnection.ServiceCategory, + }) + public async onChallengeHoundRemove(userId: string, urlOrId: string) { + await this.checkUserPermissions( + userId, + HoundConnection.ServiceCategory, + HoundConnection.CanonicalEventType, + ); + const id = urlOrId.startsWith("http") + ? HoundConnection.getIdFromURL(urlOrId) + : urlOrId; + const event = await this.client + .getRoomStateEvent(this.roomId, HoundConnection.CanonicalEventType, id) + .catch((err: any) => { + if (err.body.errcode === "M_NOT_FOUND") { + return null; // not an error to us + } + throw err; + }); + if (!event || Object.keys(event).length === 0) { + throw new CommandError( + "Invalid feed URL", + `Challenge "${id}" is not currently bridged to this room`, + ); + } - await this.client.sendStateEvent(this.roomId, OpenProjectConnection.CanonicalEventType, safeUrl, {}); - return this.client.sendHtmlNotice(this.roomId, md.renderInline(`Room no longer bridged to project \`${safeUrl}\`.`)); + await this.client.sendStateEvent( + this.roomId, + FeedConnection.CanonicalEventType, + id, + {}, + ); + return this.client.sendHtmlNotice( + this.roomId, + md.renderInline(`Unsubscribed from challenge`), + ); + } + + private getOpenProjectSafeUrl(urlStr: string): string { + // http://localhost:8080/projects/1/ + // This config is already validated. + if (!this.config.openProject?.baseURL.origin) { + throw Error("No BaseURL for OpenProject"); + } + const expectedOrigin = this.config.openProject.baseURL.origin; + const url = new URL(urlStr); + const urlParts = /\/projects\/(\d+)\/?/.exec(url.pathname); + const projectId = urlParts?.[1]; + if (!projectId) { + throw new CommandError( + "Invalid OpenProject", + "The project url you entered was not valid. It should be in the format of `https://op-instance/projects/PROJECTID/...`", + ); + } + if (url.origin !== expectedOrigin) { + throw new CommandError( + "Invalid OpenProject", + `The project url you entered was not valid. It should be part of ${expectedOrigin}`, + ); + } + return `http://${url.host}/projects/${projectId}`; + } + + @botCommand("openproject add", { + help: "Bridge a OpenProject project to the room.", + requiredArgs: ["url"], + includeUserId: true, + category: "openproject", + }) + public async onOpenProjectAdd(userId: string, url: string) { + if (!this.config.openProject) { + throw new CommandError( + "not-configured", + "The bridge is not configured to support open project.", + ); + } + const safeUrl = this.getOpenProjectSafeUrl(url); + + await this.checkUserPermissions( + userId, + OpenProjectConnection.ServiceCategory, + HoundConnection.CanonicalEventType, + ); + const { connection } = await OpenProjectConnection.provisionConnection( + this.roomId, + userId, + { url: safeUrl }, + this.provisionOpts, + ); + this.pushConnections(connection); + return this.client.sendHtmlNotice( + this.roomId, + md.renderInline(`Room configured to bridge this project`), + ); + } + + @botCommand("openproject remove", { + help: "Remove an OpenProject project connection from this room.", + requiredArgs: ["url"], + includeUserId: true, + category: OpenProjectConnection.ServiceCategory, + }) + public async onOpenProjectRemove(userId: string, urlStr: string) { + await this.checkUserPermissions( + userId, + OpenProjectConnection.ServiceCategory, + OpenProjectConnection.CanonicalEventType, + ); + + const safeUrl = await this.getJiraProjectSafeUrl(urlStr); + + let event; + try { + event = await this.client.getRoomStateEvent( + this.roomId, + OpenProjectConnection.CanonicalEventType, + safeUrl, + ); + } catch (err: any) { + if (err.body.errcode !== "M_NOT_FOUND") { + throw err; + } } - @botCommand("setup-widget", {category: "widget", help: "Open the setup widget in the room"}) - public async onSetupWidget() { - if (this.config.widgets?.roomSetupWidget === undefined) { - throw new CommandError("Not configured", "The bridge is not configured to support setup widgets"); - } - if (!await SetupWidget.SetupRoomConfigWidget(this.roomId, this.intent, this.config.widgets, this.serviceTypes)) { - await this.client.sendNotice(this.roomId, `This room already has a setup widget, please open the "Hookshot Configuration" widget.`); - } + if (!event || Object.keys(event).length === 0) { + throw new CommandError( + "Invalid OpenProject project URL", + `OpenProject "${urlStr}" is not currently bridged to this room`, + ); } - private async checkUserPermissions(userId: string, service: string, stateEventType: string): Promise { - if (!this.config.checkPermission(userId, service, BridgePermissionLevel.manageConnections)) { - throw new CommandError(`${userId} does not have permission to manageConnections for ${service}`, `You are not permitted to provision connections for ${service}.`); - } - if (!await this.client.userHasPowerLevelFor(userId, this.roomId, "", true)) { - throw new CommandError("not-configured", "You must be able to set state in a room ('Change settings') in order to set up new integrations."); - } - if (!await this.client.userHasPowerLevelFor(this.intent.userId, this.roomId, stateEventType, true)) { - throw new CommandError("Bot lacks power level to set room state", "I do not have permission to set up a bridge in this room. Please promote me to an Admin/Moderator."); - } + await this.client.sendStateEvent( + this.roomId, + OpenProjectConnection.CanonicalEventType, + safeUrl, + {}, + ); + return this.client.sendHtmlNotice( + this.roomId, + md.renderInline(`Room no longer bridged to project \`${safeUrl}\`.`), + ); + } + + @botCommand("setup-widget", { + category: "widget", + help: "Open the setup widget in the room", + }) + public async onSetupWidget() { + if (this.config.widgets?.roomSetupWidget === undefined) { + throw new CommandError( + "Not configured", + "The bridge is not configured to support setup widgets", + ); + } + if ( + !(await SetupWidget.SetupRoomConfigWidget( + this.roomId, + this.intent, + this.config.widgets, + this.serviceTypes, + )) + ) { + await this.client.sendNotice( + this.roomId, + `This room already has a setup widget, please open the "Hookshot Configuration" widget.`, + ); + } + } + + private async checkUserPermissions( + userId: string, + service: string, + stateEventType: string, + ): Promise { + if ( + !this.config.checkPermission( + userId, + service, + BridgePermissionLevel.manageConnections, + ) + ) { + throw new CommandError( + `${userId} does not have permission to manageConnections for ${service}`, + `You are not permitted to provision connections for ${service}.`, + ); + } + if ( + !(await this.client.userHasPowerLevelFor(userId, this.roomId, "", true)) + ) { + throw new CommandError( + "not-configured", + "You must be able to set state in a room ('Change settings') in order to set up new integrations.", + ); + } + if ( + !(await this.client.userHasPowerLevelFor( + this.intent.userId, + this.roomId, + stateEventType, + true, + )) + ) { + throw new CommandError( + "Bot lacks power level to set room state", + "I do not have permission to set up a bridge in this room. Please promote me to an Admin/Moderator.", + ); } + } } // Typescript doesn't understand Prototypes very well yet. -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const res = compileBotCommands(SetupConnection.prototype as any, CommandConnection.prototype as any); + +const res = compileBotCommands( + SetupConnection.prototype as any, + CommandConnection.prototype as any, +); SetupConnection.helpMessage = res.helpMessage; SetupConnection.botCommands = res.botCommands; diff --git a/src/Connections/index.ts b/src/Connections/index.ts index c06b97cbe..a63923163 100644 --- a/src/Connections/index.ts +++ b/src/Connections/index.ts @@ -11,4 +11,4 @@ export * from "./IConnection"; export * from "./JiraProject"; export * from "./FigmaFileConnection"; export * from "./FeedConnection"; -export * from "./OutboundHook"; \ No newline at end of file +export * from "./OutboundHook"; diff --git a/src/FormatUtil.ts b/src/FormatUtil.ts index dd6fbf889..78dfedae3 100644 --- a/src/FormatUtil.ts +++ b/src/FormatUtil.ts @@ -1,115 +1,143 @@ -import { ProjectsListResponseData } from './github/Types'; +import { ProjectsListResponseData } from "./github/Types"; import { emojify } from "node-emoji"; -import { JiraIssue } from './jira/Types'; -import { formatLabels, getPartialBodyForJiraIssue, hashId, getPartialBodyForGithubIssue, getPartialBodyForGithubRepo, MinimalGitHubIssue } from "./libRs"; +import { JiraIssue } from "./jira/Types"; +import { + formatLabels, + getPartialBodyForJiraIssue, + hashId, + getPartialBodyForGithubIssue, + getPartialBodyForGithubRepo, + MinimalGitHubIssue, +} from "./libRs"; interface IMinimalPR { - html_url: string; - id: number; - number: number; - title: string; - user: { - login: string; - }; + html_url: string; + id: number; + number: number; + title: string; + user: { + login: string; + }; } - export interface ILabel { - color?: string, - name: string, - description?: string + color?: string; + name: string; + description?: string; } export type LooseMinimalGitHubRepo = { - id: number, - full_name: string, - html_url: string, - description?: string|null, - } + id: number; + full_name: string; + html_url: string; + description?: string | null; +}; export class FormatUtil { - public static formatIssueRoomName(issue: MinimalGitHubIssue, repository: { full_name: string}) { - return emojify(`${repository.full_name}#${issue.number}: ${issue.title}`); - } + public static formatIssueRoomName( + issue: MinimalGitHubIssue, + repository: { full_name: string }, + ) { + return emojify(`${repository.full_name}#${issue.number}: ${issue.title}`); + } - public static formatRepoRoomName(repo: LooseMinimalGitHubRepo) { - return emojify(repo.description ? `${repo.full_name}: ${repo.description}` : repo.full_name); - } + public static formatRepoRoomName(repo: LooseMinimalGitHubRepo) { + return emojify( + repo.description + ? `${repo.full_name}: ${repo.description}` + : repo.full_name, + ); + } - public static formatRoomTopic(repo: {state: string, html_url: string}) { - return `Status: ${repo.state} | ${repo.html_url}`; - } + public static formatRoomTopic(repo: { state: string; html_url: string }) { + return `Status: ${repo.state} | ${repo.html_url}`; + } - public static formatRepoRoomTeam(repo: {html_url: string}) { - return `${repo.html_url}`; - } + public static formatRepoRoomTeam(repo: { html_url: string }) { + return `${repo.html_url}`; + } - public static getPartialBodyForGithubRepo(repo: LooseMinimalGitHubRepo) { - if (!repo.id || !repo.html_url || !repo.full_name) { - throw Error('Missing keys in repo object'); - } - return getPartialBodyForGithubRepo({ - ...repo, - description: repo.description ?? undefined, - }); + public static getPartialBodyForGithubRepo(repo: LooseMinimalGitHubRepo) { + if (!repo.id || !repo.html_url || !repo.full_name) { + throw Error("Missing keys in repo object"); } + return getPartialBodyForGithubRepo({ + ...repo, + description: repo.description ?? undefined, + }); + } - public static getPartialBodyForGithubIssue(repo: LooseMinimalGitHubRepo, issue: MinimalGitHubIssue) { - if (!repo.id || !repo.html_url || !repo.full_name) { - throw Error('Missing keys in repo object'); - } - if (!issue.html_url || !issue.id || !issue.number || !issue.title) { - throw Error('Missing keys in issue object'); - } - return getPartialBodyForGithubIssue({ - ...repo, - description: repo.description ?? undefined, - }, issue); + public static getPartialBodyForGithubIssue( + repo: LooseMinimalGitHubRepo, + issue: MinimalGitHubIssue, + ) { + if (!repo.id || !repo.html_url || !repo.full_name) { + throw Error("Missing keys in repo object"); } - - public static getPartialBodyForGitHubPR(repo: LooseMinimalGitHubRepo, issue: IMinimalPR) { - return { - ...FormatUtil.getPartialBodyForGithubRepo(repo), - "external_url": issue.html_url, - "uk.half-shot.matrix-hookshot.github.pull_request": { - id: issue.id, - number: issue.number, - title: issue.title, - url: issue.html_url, - }, - }; + if (!issue.html_url || !issue.id || !issue.number || !issue.title) { + throw Error("Missing keys in issue object"); } + return getPartialBodyForGithubIssue( + { + ...repo, + description: repo.description ?? undefined, + }, + issue, + ); + } + public static getPartialBodyForGitHubPR( + repo: LooseMinimalGitHubRepo, + issue: IMinimalPR, + ) { + return { + ...FormatUtil.getPartialBodyForGithubRepo(repo), + external_url: issue.html_url, + "uk.half-shot.matrix-hookshot.github.pull_request": { + id: issue.id, + number: issue.number, + title: issue.title, + url: issue.html_url, + }, + }; + } - public static getPartialBodyForComment(comment: {id: number, html_url: string}, - repo?: LooseMinimalGitHubRepo, - issue?: MinimalGitHubIssue) { - return { - ...(issue && repo ? FormatUtil.getPartialBodyForGithubIssue(repo, issue) : undefined), - "external_url": comment.html_url, - "uk.half-shot.matrix-hookshot.github.comment": { - id: comment.id, - }, - }; - } + public static getPartialBodyForComment( + comment: { id: number; html_url: string }, + repo?: LooseMinimalGitHubRepo, + issue?: MinimalGitHubIssue, + ) { + return { + ...(issue && repo + ? FormatUtil.getPartialBodyForGithubIssue(repo, issue) + : undefined), + external_url: comment.html_url, + "uk.half-shot.matrix-hookshot.github.comment": { + id: comment.id, + }, + }; + } - public static projectListing(projects: ProjectsListResponseData): string { - let f = ''; - for (const projectItem of projects) { - f += ` - ${projectItem.name} (#${projectItem.number}) - Project ID: ${projectItem.id}`; - } - return f; + public static projectListing(projects: ProjectsListResponseData): string { + let f = ""; + for (const projectItem of projects) { + f += ` - ${projectItem.name} (#${projectItem.number}) - Project ID: ${projectItem.id}`; } + return f; + } - public static formatLabels(labels: ILabel[] = []): { plain: string, html: string } { - return formatLabels(labels); - } + public static formatLabels(labels: ILabel[] = []): { + plain: string; + html: string; + } { + return formatLabels(labels); + } - public static getPartialBodyForJiraIssue(issue: JiraIssue) { - return getPartialBodyForJiraIssue(issue); - } + public static getPartialBodyForJiraIssue(issue: JiraIssue) { + return getPartialBodyForJiraIssue(issue); + } - public static hashId(id: string) { - return hashId(id); - } + public static hashId(id: string) { + return hashId(id); + } } diff --git a/src/Gitlab/Client.ts b/src/Gitlab/Client.ts index af90f78ee..dadd56b85 100644 --- a/src/Gitlab/Client.ts +++ b/src/Gitlab/Client.ts @@ -1,6 +1,22 @@ import axios from "axios"; import { GitLabInstance } from "../config/Config"; -import { GetIssueResponse, GetUserResponse, CreateIssueOpts, CreateIssueResponse, GetIssueOpts, EditIssueOpts, GetTodosResponse, EventsOpts, CreateIssueNoteOpts, CreateIssueNoteResponse, GetProjectResponse, ProjectHook, ProjectHookOpts, AccessLevel, SimpleProject } from "./Types"; +import { + GetIssueResponse, + GetUserResponse, + CreateIssueOpts, + CreateIssueResponse, + GetIssueOpts, + EditIssueOpts, + GetTodosResponse, + EventsOpts, + CreateIssueNoteOpts, + CreateIssueNoteResponse, + GetProjectResponse, + ProjectHook, + ProjectHookOpts, + AccessLevel, + SimpleProject, +} from "./Types"; import { Logger } from "matrix-appservice-bridge"; import { URLSearchParams } from "url"; import UserAgent from "../UserAgent"; @@ -10,182 +26,255 @@ const log = new Logger("GitLabClient"); /** * A GitLab project used inside a URL may either be the ID of the project, or the encoded path of the project. */ -type ProjectId = string|number; +type ProjectId = string | number; export class GitLabClient { - constructor(private instanceUrl: string, private token: string) { + constructor( + private instanceUrl: string, + private token: string, + ) {} + public static splitUrlIntoParts( + instances: { [name: string]: GitLabInstance }, + url: string, + ): [string, string[]] | null { + for (const [instanceKey, instanceConfig] of Object.entries(instances)) { + if (url.startsWith(instanceConfig.url)) { + return [ + instanceKey, + url + .substr(instanceConfig.url.length) + .split("/") + .filter((part) => part.length > 0), + ]; + } } + return null; + } - public static splitUrlIntoParts(instances: {[name: string]: GitLabInstance}, url: string): [string, string[]]|null { - for (const [instanceKey, instanceConfig] of Object.entries(instances)) { - if (url.startsWith(instanceConfig.url)) { - return [instanceKey, url.substr(instanceConfig.url.length).split("/").filter(part => part.length > 0)]; - } - } - return null; - } + get defaultConfig() { + return { + headers: { + Authorization: `Bearer ${this.token}`, + "User-Agent": UserAgent, + }, + baseURL: this.instanceUrl, + }; + } - get defaultConfig() { - return { - headers: { - "Authorization": `Bearer ${this.token}`, - "User-Agent": UserAgent, - }, - baseURL: this.instanceUrl - }; - } + async get(path: string) { + return await axios.get(path, { + ...this.defaultConfig, + responseType: "arraybuffer", + }); + } - async get(path: string) { - return await axios.get(path, { ...this.defaultConfig, responseType: 'arraybuffer'}); - } + async version() { + return (await axios.get("api/v4/versions", this.defaultConfig)).data; + } - async version() { - return (await axios.get("api/v4/versions", this.defaultConfig)).data; - } + async user(): Promise { + return (await axios.get("api/v4/user", this.defaultConfig)).data; + } - async user(): Promise { - return (await axios.get("api/v4/user", this.defaultConfig)).data; - } + private async createIssue( + opts: CreateIssueOpts, + ): Promise { + return ( + await axios.post( + `api/v4/projects/${encodeURIComponent(opts.id)}/issues`, + opts, + this.defaultConfig, + ) + ).data; + } - private async createIssue(opts: CreateIssueOpts): Promise { - return (await axios.post(`api/v4/projects/${encodeURIComponent(opts.id)}/issues`, opts, this.defaultConfig)).data; + private async getIssue(opts: GetIssueOpts): Promise { + try { + return ( + await axios.get( + `api/v4/projects/${opts.projects.join("%2F")}/issues/${opts.issue}`, + this.defaultConfig, + ) + ).data; + } catch (ex) { + log.warn(`Failed to get issue:`, ex); + throw ex; } + } - private async getIssue(opts: GetIssueOpts): Promise { - try { - return (await axios.get(`api/v4/projects/${opts.projects.join("%2F")}/issues/${opts.issue}`, this.defaultConfig)).data; - } catch (ex) { - log.warn(`Failed to get issue:`, ex); - throw ex; - } - } + private async editIssue(opts: EditIssueOpts): Promise { + return ( + await axios.put( + `api/v4/projects/${encodeURIComponent(opts.id)}/issues/${opts.issue_iid}`, + opts, + this.defaultConfig, + ) + ).data; + } - private async editIssue(opts: EditIssueOpts): Promise { - return (await axios.put(`api/v4/projects/${encodeURIComponent(opts.id)}/issues/${opts.issue_iid}`, opts, this.defaultConfig)).data; + private async getProject(id: ProjectId): Promise { + try { + return ( + await axios.get( + `api/v4/projects/${encodeURIComponent(id)}`, + this.defaultConfig, + ) + ).data; + } catch (ex) { + log.warn(`Failed to get project:`, ex); + throw ex; } + } - - private async getProject(id: ProjectId): Promise { - try { - return (await axios.get(`api/v4/projects/${encodeURIComponent(id)}`, this.defaultConfig)).data; - } catch (ex) { - log.warn(`Failed to get project:`, ex); - throw ex; - } + private async listProjects( + minAccess: AccessLevel, + inGroup?: ProjectId, + idAfter?: number, + search?: string, + ): Promise { + try { + const path = inGroup + ? `api/v4/groups/${encodeURIComponent(inGroup)}/projects` + : "api/v4/projects"; + return ( + await axios.get(path, { + ...this.defaultConfig, + params: { + archived: false, + min_access_level: minAccess, + simple: true, + pagination: "keyset", + per_page: 10, + order_by: "id", + sort: "asc", + id_after: idAfter, + search, + }, + }) + ).data; + } catch (ex) { + log.warn(`Failed to get projects:`, ex); + throw ex; } + } - private async listProjects(minAccess: AccessLevel, inGroup?: ProjectId, idAfter?: number, search?: string): Promise { - try { - const path = inGroup ? `api/v4/groups/${encodeURIComponent(inGroup)}/projects` : 'api/v4/projects'; - return (await axios.get(path, { - ...this.defaultConfig, - params: { - archived: false, - min_access_level: minAccess, - simple: true, - pagination: "keyset", - per_page: 10, - order_by: "id", - sort: "asc", - id_after: idAfter, - search, - } - })).data; - } catch (ex) { - log.warn(`Failed to get projects:`, ex); - throw ex; - } + private async getProjectHooks(id: ProjectId): Promise { + try { + return ( + await axios.get( + `api/v4/projects/${encodeURIComponent(id)}/hooks`, + this.defaultConfig, + ) + ).data; + } catch (ex) { + log.warn(`Failed to get project hooks:`, ex); + throw ex; } + } - private async getProjectHooks(id: ProjectId): Promise { - try { - return (await axios.get(`api/v4/projects/${encodeURIComponent(id)}/hooks`, this.defaultConfig)).data; - } catch (ex) { - log.warn(`Failed to get project hooks:`, ex); - throw ex; - } + private async addProjectHook( + id: ProjectId, + opts: ProjectHookOpts, + ): Promise { + try { + return ( + await axios.post( + `api/v4/projects/${encodeURIComponent(id)}/hooks`, + opts, + this.defaultConfig, + ) + ).data; + } catch (ex) { + log.warn(`Failed to create project hook:`, ex); + throw ex; } + } - private async addProjectHook(id: ProjectId, opts: ProjectHookOpts): Promise { - try { - return (await axios.post(`api/v4/projects/${encodeURIComponent(id)}/hooks`, opts, this.defaultConfig)).data; - } catch (ex) { - log.warn(`Failed to create project hook:`, ex); - throw ex; - } - } + public async getEvents(opts: EventsOpts) { + const after = + `${opts.after.getFullYear()}-` + + `${(opts.after.getMonth() + 1).toString().padStart(2, "0")}` + + `-${opts.after.getDay().toString().padStart(2, "0")}`; + return (await axios.get(`api/v4/events?after=${after}`, this.defaultConfig)) + .data as GetTodosResponse[]; + } - public async getEvents(opts: EventsOpts) { - const after = `${opts.after.getFullYear()}-` + - `${(opts.after.getMonth()+1).toString().padStart(2, "0")}`+ - `-${opts.after.getDay().toString().padStart(2, "0")}`; - return (await axios.get( - `api/v4/events?after=${after}`, - this.defaultConfig) - ).data as GetTodosResponse[]; + public async createIssueNote( + projectParts: string[], + issueId: number, + opts: CreateIssueNoteOpts, + ): Promise { + try { + const qp = new URLSearchParams({ + body: opts.body, + confidential: (opts.confidential || false).toString(), + }).toString(); + return ( + await axios.post( + `api/v4/projects/${projectParts.join("%2F")}/issues/${issueId}/notes?${qp}`, + undefined, + this.defaultConfig, + ) + ).data as CreateIssueNoteResponse; + } catch (ex) { + log.warn(`Failed to create issue note:`, ex); + throw ex; } + } - public async createIssueNote(projectParts: string[], issueId: number, opts: CreateIssueNoteOpts): Promise { - try { - const qp = new URLSearchParams({ - body: opts.body, - confidential: (opts.confidential || false).toString(), - }).toString(); - return (await axios.post(`api/v4/projects/${projectParts.join("%2F")}/issues/${issueId}/notes?${qp}`, undefined, this.defaultConfig)).data as CreateIssueNoteResponse; - } catch (ex) { - log.warn(`Failed to create issue note:`, ex); - throw ex; + /** + * Get the access level the authenticated user has for a project. Includes + * any access levels inherited from parent project(s). + * @param id The project ID + * @returns The user's access level. + */ + public async getProjectAccessLevel(id: ProjectId): Promise { + try { + const me = await this.user(); + // https://docs.gitlab.com/ee/api/members.html#get-a-member-of-a-group-or-project-including-inherited-and-invited-members + const { data } = await axios.get( + `api/v4/projects/${encodeURIComponent(id)}/members/all/${me.id}`, + this.defaultConfig, + ); + if (typeof data?.access_level !== "number") { + throw Error( + `Unexpected value for data.access_level. '${data?.access_level}'`, + ); + } + return data.access_level as AccessLevel; + } catch (ex) { + if (axios.isAxiosError(ex)) { + if (ex.response?.status === 404) { + return AccessLevel.NoAccess; } + } + log.warn(`Failed to get project access level:`, ex); + throw ex; } + } - /** - * Get the access level the authenticated user has for a project. Includes - * any access levels inherited from parent project(s). - * @param id The project ID - * @returns The user's access level. - */ - public async getProjectAccessLevel(id: ProjectId): Promise { - try { - const me = await this.user(); - // https://docs.gitlab.com/ee/api/members.html#get-a-member-of-a-group-or-project-including-inherited-and-invited-members - const { data } = await axios.get(`api/v4/projects/${encodeURIComponent(id)}/members/all/${me.id}`, this.defaultConfig); - if (typeof data?.access_level !== "number") { - throw Error(`Unexpected value for data.access_level. '${data?.access_level}'`); - } - return data.access_level as AccessLevel; - } catch (ex) { - if (axios.isAxiosError(ex)) { - if (ex.response?.status === 404) { - return AccessLevel.NoAccess; - } - } - log.warn(`Failed to get project access level:`, ex); - throw ex; - } - } + get issues() { + return { + create: this.createIssue.bind(this), + edit: this.editIssue.bind(this), + get: this.getIssue.bind(this), + }; + } + get projects() { + return { + get: this.getProject.bind(this), + list: this.listProjects.bind(this), + getMyAccessLevel: this.getProjectAccessLevel.bind(this), + hooks: { + list: this.getProjectHooks.bind(this), + add: this.addProjectHook.bind(this), + }, + }; + } - get issues() { - return { - create: this.createIssue.bind(this), - edit: this.editIssue.bind(this), - get: this.getIssue.bind(this), - } - } - get projects() { - return { - get: this.getProject.bind(this), - list: this.listProjects.bind(this), - getMyAccessLevel: this.getProjectAccessLevel.bind(this), - hooks: { - list: this.getProjectHooks.bind(this), - add: this.addProjectHook.bind(this), - } - } - } - - get notes() { - return { - createForIssue: this.createIssueNote.bind(this), - } - } + get notes() { + return { + createForIssue: this.createIssueNote.bind(this), + }; + } } diff --git a/src/Gitlab/GrantChecker.ts b/src/Gitlab/GrantChecker.ts index de759fc6e..a2fbd6888 100644 --- a/src/Gitlab/GrantChecker.ts +++ b/src/Gitlab/GrantChecker.ts @@ -5,36 +5,51 @@ import { GitLabRepoConnection } from "../Connections"; import { GrantChecker } from "../grants/GrantCheck"; import { UserTokenStore } from "../tokens/UserTokenStore"; -const log = new Logger('GitLabGrantChecker'); +const log = new Logger("GitLabGrantChecker"); -interface GitLabGrantConnectionId{ - instance: string; - path: string; +interface GitLabGrantConnectionId { + instance: string; + path: string; } - - export class GitLabGrantChecker extends GrantChecker { - constructor(private readonly as: Appservice, private readonly config: BridgeConfigGitLab, private readonly tokenStore: UserTokenStore) { - super(as.botIntent, "gitlab") - } + constructor( + private readonly as: Appservice, + private readonly config: BridgeConfigGitLab, + private readonly tokenStore: UserTokenStore, + ) { + super(as.botIntent, "gitlab"); + } - protected async checkFallback(roomId: string, connectionId: GitLabGrantConnectionId, sender?: string) { - if (!sender) { - log.debug(`Tried to check fallback for ${roomId} with a missing sender`); - // Cannot validate without a sender. - return false; - } - if (this.as.isNamespacedUser(sender)) { - // Bridge is always valid. - return true; - } - try { - await GitLabRepoConnection.assertUserHasAccessToProject(connectionId.instance, connectionId.path, sender, this.tokenStore, this.config); - return true; - } catch (ex) { - log.info(`${sender} does not have access to ${connectionId.instance}/${connectionId.path}`, ex); - return false; - } + protected async checkFallback( + roomId: string, + connectionId: GitLabGrantConnectionId, + sender?: string, + ) { + if (!sender) { + log.debug(`Tried to check fallback for ${roomId} with a missing sender`); + // Cannot validate without a sender. + return false; } -} \ No newline at end of file + if (this.as.isNamespacedUser(sender)) { + // Bridge is always valid. + return true; + } + try { + await GitLabRepoConnection.assertUserHasAccessToProject( + connectionId.instance, + connectionId.path, + sender, + this.tokenStore, + this.config, + ); + return true; + } catch (ex) { + log.info( + `${sender} does not have access to ${connectionId.instance}/${connectionId.path}`, + ex, + ); + return false; + } + } +} diff --git a/src/Gitlab/Types.ts b/src/Gitlab/Types.ts index 956b8a823..d568e1158 100644 --- a/src/Gitlab/Types.ts +++ b/src/Gitlab/Types.ts @@ -1,232 +1,233 @@ export interface GitLabAuthor { - id: number; - name: string; - username: string; - state: 'active'; - avatar_url: string; - web_url: string; + id: number; + name: string; + username: string; + state: "active"; + avatar_url: string; + web_url: string; } // https://docs.gitlab.com/ee/api/access_requests.html#valid-access-levels export enum AccessLevel { - NoAccess = 0, - MinimalAccess = 5, - Guest = 10, - Reporter = 20, - Developer = 30, - Maintainer = 40, - // Only valid to set for groups - Owner = 50, + NoAccess = 0, + MinimalAccess = 5, + Guest = 10, + Reporter = 20, + Developer = 30, + Maintainer = 40, + // Only valid to set for groups + Owner = 50, } export interface GetUserResponse { - id: number; - username: string; - email: string; - name: string; - state: string; - avatar_url: string; - web_url: string; - created_at: string; - bio: string; - bio_html: string; - location: null|string; - public_email: string; - skype: string; - linkedin: string; - twitter: string; - website_url: string; - organization: string; - last_sign_in_at: string; - confirmed_at: string; - theme_id: number; - last_activity_on: string; - color_scheme_id: number; - projects_limit: number; - current_sign_in_at: string; - identities: [ - {provider: string, extern_uid: string}, - ]; - can_create_group: boolean; - can_create_project: boolean; - two_factor_enabled: boolean; - external: boolean; - private_profile: boolean; + id: number; + username: string; + email: string; + name: string; + state: string; + avatar_url: string; + web_url: string; + created_at: string; + bio: string; + bio_html: string; + location: null | string; + public_email: string; + skype: string; + linkedin: string; + twitter: string; + website_url: string; + organization: string; + last_sign_in_at: string; + confirmed_at: string; + theme_id: number; + last_activity_on: string; + color_scheme_id: number; + projects_limit: number; + current_sign_in_at: string; + identities: [{ provider: string; extern_uid: string }]; + can_create_group: boolean; + can_create_project: boolean; + two_factor_enabled: boolean; + external: boolean; + private_profile: boolean; } // hhttps://docs.gitlab.com/ee/api/issues.html#single-project-issue export interface GetIssueOpts { - projects: string[]; - issue: number; + projects: string[]; + issue: number; } // https://docs.gitlab.com/ee/api/issues.html#new-issue export interface CreateIssueOpts { - id: string|number; - title: string; - description?: string; - confidential?: boolean; - labels?: string[]; + id: string | number; + title: string; + description?: string; + confidential?: boolean; + labels?: string[]; } export interface CreateIssueResponse { - state: string; - id: string; - iid: string; - web_url: string; + state: string; + id: string; + iid: string; + web_url: string; } // https://docs.gitlab.com/ee/api/issues.html#new-issue export interface EditIssueOpts { - id: string|number; - issue_iid: string|number; - title?: string; - description?: string; - confidential?: boolean; - labels?: string[]; - state_event?: string; + id: string | number; + issue_iid: string | number; + title?: string; + description?: string; + confidential?: boolean; + labels?: string[]; + state_event?: string; } export interface CreateIssueResponse { - state: string; - id: string; - web_url: string; + state: string; + id: string; + web_url: string; } export interface GetIssueResponse { - id: number; - iid: number; - title: string; - description: string; - state: 'opened'|'closed'; - author: GitLabAuthor; - references: { - short: string; - relative: string; - full: string; - } - web_url: string; + id: number; + iid: number; + title: string; + description: string; + state: "opened" | "closed"; + author: GitLabAuthor; + references: { + short: string; + relative: string; + full: string; + }; + web_url: string; } export interface GetTodosResponse { + id: number; + author: GitLabAuthor; + action_name: string; + project: { id: number; - author: GitLabAuthor; - action_name: string; - project: { - id: number; - name: string; - name_with_namespace: string; - path: string; - path_with_namespace: string; + name: string; + name_with_namespace: string; + path: string; + path_with_namespace: string; + }; + target: { + title: string; + description: string; + state: "opened" | "closed"; + assignee: { + name: string; + username: string; + id: 1; + state: "active"; + avatar_url: string; + web_url: string; }; - target: { - title: string; - description: string; - state: 'opened'|'closed'; - assignee: { - name: string; - username: string; - id: 1; - state: "active"; - avatar_url: string; - web_url: string; - } - } - target_url: string; - body: string; - created_at: string; - updated_at: string; + }; + target_url: string; + body: string; + created_at: string; + updated_at: string; } export interface EventsOpts { - after: Date; + after: Date; } export interface CreateIssueNoteOpts { - body: string; - confidential?: boolean; + body: string; + confidential?: boolean; } export interface CreateIssueNoteResponse { - id: number; - type: string|null; - body: string; - attachment: null; - author: GitLabAuthor; - created_at: string; - updated_at: string; - system: boolean; - noteable_id: number; - noteable_type: 'Issue'; - resolvable: boolean; - confidential: boolean; - noteable_iid: string; - commands_changes: unknown; -} - + id: number; + type: string | null; + body: string; + attachment: null; + author: GitLabAuthor; + created_at: string; + updated_at: string; + system: boolean; + noteable_id: number; + noteable_type: "Issue"; + resolvable: boolean; + confidential: boolean; + noteable_iid: string; + commands_changes: unknown; +} + export interface GetProjectResponse { + id: number; + name: string; + path_with_namespace: string; + title: string; + description: string; + visibility: "private" | "internal" | "public"; + state: "opened" | "closed"; + owner: { id: number; name: string; - path_with_namespace: string; - title: string; - description: string; - visibility: "private"|"internal"|"public", - state: 'opened'|'closed'; - owner: { - id: number; - name: string; + }; + permissions: { + project_access?: { + access_level: AccessLevel; }; - permissions: { - project_access?: { - access_level: AccessLevel; - }, - group_access?: { - access_level: AccessLevel; - } + group_access?: { + access_level: AccessLevel; }; - author: GitLabAuthor; - references: { - short: string; - relative: string; - full: string; - } - web_url: string; + }; + author: GitLabAuthor; + references: { + short: string; + relative: string; + full: string; + }; + web_url: string; } export interface ProjectHookOpts { - url: string; - token: string; - push_events?: true; - push_events_branch_filter?: string; - issues_events?: boolean; - confidential_issues_events?: boolean; - merge_requests_events?: boolean; - tag_push_events?: boolean; - note_events?: boolean; - confidential_note_events?: boolean; - job_events?: boolean; - pipeline_events?: boolean; - wiki_page_events?: boolean; - deployment_events?: boolean; - releases_events?: boolean; - enable_ssl_verification?: boolean; + url: string; + token: string; + push_events?: true; + push_events_branch_filter?: string; + issues_events?: boolean; + confidential_issues_events?: boolean; + merge_requests_events?: boolean; + tag_push_events?: boolean; + note_events?: boolean; + confidential_note_events?: boolean; + job_events?: boolean; + pipeline_events?: boolean; + wiki_page_events?: boolean; + deployment_events?: boolean; + releases_events?: boolean; + enable_ssl_verification?: boolean; } export interface ProjectHook extends ProjectHookOpts { - id: number; - token: never; - project_id: 3; - created_at?: string; + id: number; + token: never; + project_id: 3; + created_at?: string; } /** newest last, to enable feeding it straight into an LRU cache */ -export type SerializedGitlabDiscussionThreads = { discussionId: string, eventId: string }[]; +export type SerializedGitlabDiscussionThreads = { + discussionId: string; + eventId: string; +}[]; export interface SimpleProject { - avatar_url?: string; - description?: string; - id: string; - name: string; - path: string; - path_with_namespace: string; -} \ No newline at end of file + avatar_url?: string; + description?: string; + id: string; + name: string; + path: string; + path_with_namespace: string; +} diff --git a/src/Gitlab/WebhookTypes.ts b/src/Gitlab/WebhookTypes.ts index 27e1e806e..b091a9b95 100644 --- a/src/Gitlab/WebhookTypes.ts +++ b/src/Gitlab/WebhookTypes.ts @@ -1,213 +1,219 @@ export interface IGitLabWebhookEvent { - object_kind: string; + object_kind: string; } export interface IGitlabUser { - name: string; - username: string; - avatar_url: string; - email: string; + name: string; + username: string; + avatar_url: string; + email: string; } export interface IGitlabRepository { - name: string; - homepage: string; - url: string; - description: string; + name: string; + homepage: string; + url: string; + description: string; } - export interface IGitlabProject { - path_with_namespace: string; - web_url: string; - homepage: string; + path_with_namespace: string; + web_url: string; + homepage: string; } export interface IGitlabIssue { - iid: number; - description: string; + iid: number; + description: string; } export interface IGitlabMergeRequest { - url: string; - title: string; - iid: number; - author_id: number; - state: 'opened'|'closed'|'merged'; - labels: IGitLabLabel[]; + url: string; + title: string; + iid: number; + author_id: number; + state: "opened" | "closed" | "merged"; + labels: IGitLabLabel[]; } -export interface IGitLabMergeRequestObjectAttributes extends IGitlabMergeRequest { - action: "open"|"close"|"reopen"|"approved"|"unapproved"|"merge"; +export interface IGitLabMergeRequestObjectAttributes + extends IGitlabMergeRequest { + action: "open" | "close" | "reopen" | "approved" | "unapproved" | "merge"; } export interface IGitLabLabel { - id: number; - title: string; - color: string; - project_id: number; - created_at: string; - updated_at: string; - template: boolean; - description: string; - type: "ProjectLabel"|"GroupLabel"; - group_id: number; + id: number; + title: string; + color: string; + project_id: number; + created_at: string; + updated_at: string; + template: boolean; + description: string; + type: "ProjectLabel" | "GroupLabel"; + group_id: number; } export interface IGitLabWebhookMREvent { - object_kind: "merge_request"; - event_type: string; - user: IGitlabUser; - project: IGitlabProject; - repository: IGitlabRepository; - object_attributes: IGitLabMergeRequestObjectAttributes; - labels: IGitLabLabel[]; - changes: { - draft?: { - previous: boolean; - current: boolean; - } - } + object_kind: "merge_request"; + event_type: string; + user: IGitlabUser; + project: IGitlabProject; + repository: IGitlabRepository; + object_attributes: IGitLabMergeRequestObjectAttributes; + labels: IGitLabLabel[]; + changes: { + draft?: { + previous: boolean; + current: boolean; + }; + }; } export interface IGitLabWebhookTagPushEvent { - object_kind: "tag_push"; - user_id: number; - ref: string; - user_name: string; - /** - * Commit hash before push - */ - before: string; - /** - * Commit hash after push - */ - after: string; - project: IGitlabProject; - repository: IGitlabRepository; + object_kind: "tag_push"; + user_id: number; + ref: string; + user_name: string; + /** + * Commit hash before push + */ + before: string; + /** + * Commit hash after push + */ + after: string; + project: IGitlabProject; + repository: IGitlabRepository; } export interface IGitLabWebhookPushEvent { - object_kind: "push"; - /** - * Commit hash before push - */ - before: string; - /** - * Commit hash after push - */ - after: string; - ref: string; - user_id: number; - user_name: string; - user_email: string; - project: IGitlabProject; - repository: IGitlabRepository; - commits: [{ - id: string, - message: string, - title: string, - timestamp: string, - url: string, + object_kind: "push"; + /** + * Commit hash before push + */ + before: string; + /** + * Commit hash after push + */ + after: string; + ref: string; + user_id: number; + user_name: string; + user_email: string; + project: IGitlabProject; + repository: IGitlabRepository; + commits: [ + { + id: string; + message: string; + title: string; + timestamp: string; + url: string; author: { - "name": string, - "email": string - }, - added: string[], - modified: string[], - removed: string[], - }], - total_commits_count: number, + name: string; + email: string; + }; + added: string[]; + modified: string[]; + removed: string[]; + }, + ]; + total_commits_count: number; } export interface IGitLabWebhookWikiPageEvent { - object_kind: "wiki_page"; - user: IGitlabUser; - project: IGitlabProject; - wiki: { - web_url: string; - path_with_namespace: string; - }; - object_attributes: { - title: string; - url: string; - message: string; - format: "markdown"; - content: string; - action: "create"|"update"|"delete"; - }; + object_kind: "wiki_page"; + user: IGitlabUser; + project: IGitlabProject; + wiki: { + web_url: string; + path_with_namespace: string; + }; + object_attributes: { + title: string; + url: string; + message: string; + format: "markdown"; + content: string; + action: "create" | "update" | "delete"; + }; } export interface IGitLabWebhookReleaseEvent { - object_kind: "release"; - description: string; - name: string; - tag: string; - created_at: string; - released_at: string; + object_kind: "release"; + description: string; + name: string; + tag: string; + created_at: string; + released_at: string; + url: string; + action: "create"; + project: IGitlabProject; + commit: { + id: string; + message: string; + title: string; + timestamp: string; url: string; - action: "create"; - project: IGitlabProject; - commit: { + author: { + name: string; + email: string; + }; + }; + assets: { + count: number; + links: [ + { id: string; - message: string; - title: string; - timestamp: string; + external: boolean; + link_type: "other"; + name: string; url: string; - author: { - name: string; - email: string; - }; - }; - assets: { - count: number; - links: [{ - id: string; - external: boolean; - link_type: "other"; - name: string; - url: string; - }], - sources: [{ - format: string; - url: string; - }] - } + }, + ]; + sources: [ + { + format: string; + url: string; + }, + ]; + }; } export interface IGitLabNote { - id: number; - note: string; - noteable_type: 'MergeRequest'; - author_id: number; - noteable_id: number; - discussion_id?: string; - url: string; + id: number; + note: string; + noteable_type: "MergeRequest"; + author_id: number; + noteable_id: number; + discussion_id?: string; + url: string; } export interface IGitLabWebhookNoteEvent { - object_kind: 'note', - user: IGitlabUser; - event_type: string; - project: IGitlabProject; - issue?: IGitlabIssue; - repository: IGitlabRepository; - object_attributes: IGitLabNote; - merge_request?: IGitlabMergeRequest; + object_kind: "note"; + user: IGitlabUser; + event_type: string; + project: IGitlabProject; + issue?: IGitlabIssue; + repository: IGitlabRepository; + object_attributes: IGitLabNote; + merge_request?: IGitlabMergeRequest; } export interface IGitLabWebhookIssueStateEvent { - user: IGitlabUser; - event_type: string; - project: IGitlabProject; - repository: { - name: string; - url: string; - description: string; - homepage: string; - }; - object_attributes: { - id: number; - iid: number; - action: string; - description: string; - } + user: IGitlabUser; + event_type: string; + project: IGitlabProject; + repository: { + name: string; + url: string; + description: string; + homepage: string; + }; + object_attributes: { + id: number; + iid: number; + action: string; + description: string; + }; } diff --git a/src/HookFilter.ts b/src/HookFilter.ts index 474ddfe4c..573a257d4 100644 --- a/src/HookFilter.ts +++ b/src/HookFilter.ts @@ -1,32 +1,29 @@ export class HookFilter { - static convertIgnoredHooksToEnabledHooks(explicitlyEnabledHooks: T[] = [], ignoredHooks: T[], defaultHooks: T[]): T[] { - const resultHookSet = new Set([ - ...explicitlyEnabledHooks, - ...defaultHooks, - ]); + static convertIgnoredHooksToEnabledHooks( + explicitlyEnabledHooks: T[] = [], + ignoredHooks: T[], + defaultHooks: T[], + ): T[] { + const resultHookSet = new Set([...explicitlyEnabledHooks, ...defaultHooks]); - // For each ignored hook, remove anything that matches. - for (const ignoredHook of ignoredHooks) { - resultHookSet.delete(ignoredHook); - // If the hook is a "root" hook name, remove all children. - for (const enabledHook of resultHookSet) { - if (enabledHook.startsWith(`${ignoredHook}.`)) { - resultHookSet.delete(enabledHook); - } - } + // For each ignored hook, remove anything that matches. + for (const ignoredHook of ignoredHooks) { + resultHookSet.delete(ignoredHook); + // If the hook is a "root" hook name, remove all children. + for (const enabledHook of resultHookSet) { + if (enabledHook.startsWith(`${ignoredHook}.`)) { + resultHookSet.delete(enabledHook); } - - return [...resultHookSet]; + } } - constructor( - public enabledHooks: T[] = [], - ) { + return [...resultHookSet]; + } - } + constructor(public enabledHooks: T[] = []) {} - public shouldSkip(...hookName: T[]) { - // Should skip if all of the hook names are missing - return hookName.every(name => !this.enabledHooks.includes(name)); - } -} \ No newline at end of file + public shouldSkip(...hookName: T[]) { + // Should skip if all of the hook names are missing + return hookName.every((name) => !this.enabledHooks.includes(name)); + } +} diff --git a/src/IntentUtils.ts b/src/IntentUtils.ts index f5660ba72..29f139d24 100644 --- a/src/IntentUtils.ts +++ b/src/IntentUtils.ts @@ -7,7 +7,7 @@ const log = new Logger("IntentUtils"); /** * Attempt to ensure that a given user is in a room, inviting them * via the bot user if nessacery. - * + * * If the bot user isn't in the room (and the target isn't in the room already), * this will fail. * @param targetIntent The intent for the user who should be in the room. @@ -15,66 +15,85 @@ const log = new Logger("IntentUtils"); * @param roomId The target room to invite to. * @throws If it was not possible to invite the user. */ -export async function ensureUserIsInRoom(targetIntent: Intent, botClient: MatrixClient, roomId: string) { - const senderUserId = targetIntent.userId; +export async function ensureUserIsInRoom( + targetIntent: Intent, + botClient: MatrixClient, + roomId: string, +) { + const senderUserId = targetIntent.userId; + try { try { - try { - await targetIntent.ensureJoined(roomId); - } catch (ex) { - if ('errcode' in ex && ex.errcode === "M_FORBIDDEN") { - // Make sure ghost user is invited to the room - await botClient.inviteUser(senderUserId, roomId); - await targetIntent.ensureJoined(roomId); - } else { - throw ex; - } - } + await targetIntent.ensureJoined(roomId); } catch (ex) { - log.warn(`Could not ensure that ${senderUserId} is in ${roomId}`, ex); - throw Error(`Could not ensure that ${senderUserId} is in ${roomId}`); + if ("errcode" in ex && ex.errcode === "M_FORBIDDEN") { + // Make sure ghost user is invited to the room + await botClient.inviteUser(senderUserId, roomId); + await targetIntent.ensureJoined(roomId); + } else { + throw ex; + } } + } catch (ex) { + log.warn(`Could not ensure that ${senderUserId} is in ${roomId}`, ex); + throw Error(`Could not ensure that ${senderUserId} is in ${roomId}`); + } } -export async function getIntentForUser(user: {avatarUrl?: string, login: string}, as: Appservice, prefix?: string) { - const domain = as.botUserId.split(":")[1]; - const intent = as.getIntentForUserId(`@${prefix ?? ''}${user.login}:${domain}`); - const displayName = user.login; - const capabilites = await intent.underlyingClient.getCapabilities(); +export async function getIntentForUser( + user: { avatarUrl?: string; login: string }, + as: Appservice, + prefix?: string, +) { + const domain = as.botUserId.split(":")[1]; + const intent = as.getIntentForUserId( + `@${prefix ?? ""}${user.login}:${domain}`, + ); + const displayName = user.login; + const capabilites = await intent.underlyingClient.getCapabilities(); + // Verify up-to-date profile + let profile; + await intent.ensureRegistered(); + if ( + capabilites["m.set_displayname"]?.enabled === false && + capabilites["m.set_avatar_url"]?.enabled === false + ) { + // No ability to change profile. + return intent; + } + try { + profile = await intent.underlyingClient.getUserProfile(intent.userId); + } catch (ex) { + profile = {}; + } - // Verify up-to-date profile - let profile; - await intent.ensureRegistered(); - if (capabilites["m.set_displayname"]?.enabled === false && capabilites["m.set_avatar_url"]?.enabled === false) { - // No ability to change profile. - return intent; - } - try { - profile = await intent.underlyingClient.getUserProfile(intent.userId); - } catch (ex) { - profile = {}; - } - - if (capabilites["m.set_displayname"]?.enabled !== false && profile.displayname !== displayName) { - log.debug(`Updating ${intent.userId}'s displayname`); - await intent.underlyingClient.setDisplayName(displayName); - } + if ( + capabilites["m.set_displayname"]?.enabled !== false && + profile.displayname !== displayName + ) { + log.debug(`Updating ${intent.userId}'s displayname`); + await intent.underlyingClient.setDisplayName(displayName); + } - if (capabilites["m.set_avatar_url"]?.enabled !== false && !profile.avatar_url && user.avatarUrl) { - log.debug(`Updating ${intent.userId}'s avatar`); - const buffer = await axios.get(user.avatarUrl, { - responseType: "arraybuffer", - }); - log.info(`Uploading ${user.avatarUrl}`); - // This does exist, but headers is silly and doesn't have content-type. - // tslint:disable-next-line: no-any - const contentType = buffer.headers["content-type"]; - const mxc = await intent.underlyingClient.uploadContent( - Buffer.from(buffer.data as ArrayBuffer), - contentType, - ); - await intent.underlyingClient.setAvatarUrl(mxc); - } + if ( + capabilites["m.set_avatar_url"]?.enabled !== false && + !profile.avatar_url && + user.avatarUrl + ) { + log.debug(`Updating ${intent.userId}'s avatar`); + const buffer = await axios.get(user.avatarUrl, { + responseType: "arraybuffer", + }); + log.info(`Uploading ${user.avatarUrl}`); + // This does exist, but headers is silly and doesn't have content-type. + // tslint:disable-next-line: no-any + const contentType = buffer.headers["content-type"]; + const mxc = await intent.underlyingClient.uploadContent( + Buffer.from(buffer.data as ArrayBuffer), + contentType, + ); + await intent.underlyingClient.setAvatarUrl(mxc); + } - return intent; -} \ No newline at end of file + return intent; +} diff --git a/src/ListenerService.ts b/src/ListenerService.ts index 0aea2ad69..49e3ab392 100644 --- a/src/ListenerService.ts +++ b/src/ListenerService.ts @@ -1,100 +1,132 @@ import { Server } from "http"; import { Logger } from "matrix-appservice-bridge"; -import { Application, default as expressApp, NextFunction, Request, Response, Router } from "express"; +import { + Application, + default as expressApp, + NextFunction, + Request, + Response, + Router, +} from "express"; import { errorMiddleware } from "./api"; // Appserices can't be handled yet because the bot-sdk maintains control of it. // See https://github.com/turt2live/matrix-bot-sdk/issues/191 -export type ResourceName = "webhooks"|"widgets"|"metrics"; -export const ResourceTypeArray: ResourceName[] = ["webhooks","widgets","metrics"]; +export type ResourceName = "webhooks" | "widgets" | "metrics"; +export const ResourceTypeArray: ResourceName[] = [ + "webhooks", + "widgets", + "metrics", +]; import { Handlers } from "@sentry/node"; export interface BridgeConfigListener { - bindAddress?: string; - port: number; - resources: Array; + bindAddress?: string; + port: number; + resources: Array; } const log = new Logger("ListenerService"); export class ListenerService { - private readonly listeners: { - server?: Server, - app: Application, - config: BridgeConfigListener, - resourcesBound: boolean, - }[] = []; + private readonly listeners: { + server?: Server; + app: Application; + config: BridgeConfigListener; + resourcesBound: boolean; + }[] = []; - constructor(config: BridgeConfigListener[]) { - if (config.length < 1) { - throw Error('No listeners configured'); - } - for (const listenerConfig of config) { - const app = expressApp(); - app.set('x-powered-by', false); - app.use(Handlers.requestHandler()); - this.listeners.push({ - config: listenerConfig, - app, - resourcesBound: false, - }); - } + constructor(config: BridgeConfigListener[]) { + if (config.length < 1) { + throw Error("No listeners configured"); } + for (const listenerConfig of config) { + const app = expressApp(); + app.set("x-powered-by", false); + app.use(Handlers.requestHandler()); + this.listeners.push({ + config: listenerConfig, + app, + resourcesBound: false, + }); + } + } - public bindResource(resourceName: ResourceName, router: Router) { - const listeners = this.listeners.filter((l) => l.config.resources.includes(resourceName)); - if (listeners.length === 0) { - throw Error(`No listeners found for resource ${resourceName}`); - } - for (const listener of listeners) { - log.debug(`Registering ${listener.config.bindAddress || "127.0.0.1"}:${listener.config.port} for ${resourceName}`); - listener.app.use(router); - listener.resourcesBound = true; - } + public bindResource(resourceName: ResourceName, router: Router) { + const listeners = this.listeners.filter((l) => + l.config.resources.includes(resourceName), + ); + if (listeners.length === 0) { + throw Error(`No listeners found for resource ${resourceName}`); + } + for (const listener of listeners) { + log.debug( + `Registering ${listener.config.bindAddress || "127.0.0.1"}:${listener.config.port} for ${resourceName}`, + ); + listener.app.use(router); + listener.resourcesBound = true; } + } - public finaliseListeners() { - for (const listener of this.listeners) { - // By default, Sentry only reports 500+ errors, which is what we want. - listener.app.use(Handlers.errorHandler()); - listener.app.use((err: unknown, req: Request, res: Response, next: NextFunction) => errorMiddleware(log)(err, req, res, next)); - } + public finaliseListeners() { + for (const listener of this.listeners) { + // By default, Sentry only reports 500+ errors, which is what we want. + listener.app.use(Handlers.errorHandler()); + listener.app.use( + (err: unknown, req: Request, res: Response, next: NextFunction) => + errorMiddleware(log)(err, req, res, next), + ); } + } - public getApplicationsForResource(resourceName: ResourceName): Application[] { - const listeners = this.listeners.filter((l) => l.config.resources.includes(resourceName)); - if (listeners.length === 0) { - throw Error(`No listener found for resource ${resourceName}`); - } - for (const listener of listeners) { - log.debug(`Reverse binding ${listener.config.bindAddress || "127.0.0.1"}:${listener.config.port} for ${resourceName}`); - listener.resourcesBound = true; - } - return listeners.map(l => l.app); + public getApplicationsForResource(resourceName: ResourceName): Application[] { + const listeners = this.listeners.filter((l) => + l.config.resources.includes(resourceName), + ); + if (listeners.length === 0) { + throw Error(`No listener found for resource ${resourceName}`); + } + for (const listener of listeners) { + log.debug( + `Reverse binding ${listener.config.bindAddress || "127.0.0.1"}:${listener.config.port} for ${resourceName}`, + ); + listener.resourcesBound = true; } + return listeners.map((l) => l.app); + } - public start() { - for (const listener of this.listeners) { - if (listener.server) { - throw Error('Cannot run start() twice'); - } - const addr = listener.config.bindAddress || "127.0.0.1"; - listener.server = listener.app.listen(listener.config.port, addr); + public start() { + for (const listener of this.listeners) { + if (listener.server) { + throw Error("Cannot run start() twice"); + } + const addr = listener.config.bindAddress || "127.0.0.1"; + listener.server = listener.app.listen(listener.config.port, addr); - // Ensure each listener has a ready probe. - listener.app.get("/live", (_, res) => res.send({ok: true})); - listener.app.get("/ready", (_, res) => res.status(listener.resourcesBound ? 200 : 500).send({ready: listener.resourcesBound})); - log.info(`Listening on http://${addr}:${listener.config.port} for ${listener.config.resources.join(', ')}`) - } + // Ensure each listener has a ready probe. + listener.app.get("/live", (_, res) => res.send({ ok: true })); + listener.app.get("/ready", (_, res) => + res + .status(listener.resourcesBound ? 200 : 500) + .send({ ready: listener.resourcesBound }), + ); + log.info( + `Listening on http://${addr}:${listener.config.port} for ${listener.config.resources.join(", ")}`, + ); } + } - public async stop() { - const promises = []; - log.info(`Stopping all listeners`); - for (const listener of this.listeners) { - if (listener.server && listener.server.listening) { - promises.push(new Promise((res, rej) => listener.server?.close((e) => e ? rej(e) : res()))); - } - } - await Promise.all(promises); + public async stop() { + const promises = []; + log.info(`Stopping all listeners`); + for (const listener of this.listeners) { + if (listener.server && listener.server.listening) { + promises.push( + new Promise((res, rej) => + listener.server?.close((e) => (e ? rej(e) : res())), + ), + ); + } } + await Promise.all(promises); + } } diff --git a/src/Managers/BotUsersManager.ts b/src/Managers/BotUsersManager.ts index b42495c45..e6bb2593d 100644 --- a/src/Managers/BotUsersManager.ts +++ b/src/Managers/BotUsersManager.ts @@ -4,320 +4,341 @@ import { Logger } from "matrix-appservice-bridge"; import { BridgeConfig } from "../config/Config"; -const mime = import('mime'); +const mime = import("mime"); const log = new Logger("BotUsersManager"); export class BotUser { - constructor( - private readonly as: Appservice, - readonly userId: string, - readonly services: string[], - readonly prefix: string, - // Bots with higher priority should handle a command first - readonly priority: number, - readonly avatar?: string, - readonly displayname?: string, - ) {} - - get intent(): Intent { - return this.as.getIntentForUserId(this.userId); - } + constructor( + private readonly as: Appservice, + readonly userId: string, + readonly services: string[], + readonly prefix: string, + // Bots with higher priority should handle a command first + readonly priority: number, + readonly avatar?: string, + readonly displayname?: string, + ) {} + + get intent(): Intent { + return this.as.getIntentForUserId(this.userId); + } } // Sort bot users by highest priority first. -const higherPriority: (a: BotUser, b: BotUser) => number = (a, b) => (b.priority - a.priority); +const higherPriority: (a: BotUser, b: BotUser) => number = (a, b) => + b.priority - a.priority; export default class BotUsersManager { - // Map of user ID to config for all our configured bot users - private _botUsers = new Map(); - - // Map of room ID to set of bot users in the room - private _botsInRooms = new Map>(); - - constructor( - readonly config: BridgeConfig, - readonly as: Appservice, - ) { - // Default bot user + // Map of user ID to config for all our configured bot users + private _botUsers = new Map(); + + // Map of room ID to set of bot users in the room + private _botsInRooms = new Map>(); + + constructor( + readonly config: BridgeConfig, + readonly as: Appservice, + ) { + // Default bot user + this._botUsers.set( + this.as.botUserId, + new BotUser( + this.as, + this.as.botUserId, + // Default bot can handle all services + this.config.enabledServices, + "!hookshot", + 0, + this.config.bot?.avatar, + this.config.bot?.displayname, + ), + ); + + // Service bot users + if (this.config.serviceBots) { + this.config.serviceBots.forEach((bot) => { + const botUserId = this.as.getUserId(bot.localpart); this._botUsers.set( - this.as.botUserId, - new BotUser( - this.as, - this.as.botUserId, - // Default bot can handle all services - this.config.enabledServices, - "!hookshot", - 0, - this.config.bot?.avatar, - this.config.bot?.displayname, - ) + botUserId, + new BotUser( + this.as, + botUserId, + [bot.service], + bot.prefix, + // Service bots should handle commands first + 1, + bot.avatar, + bot.displayname, + ), ); - - // Service bot users - if (this.config.serviceBots) { - this.config.serviceBots.forEach(bot => { - const botUserId = this.as.getUserId(bot.localpart); - this._botUsers.set( - botUserId, - new BotUser( - this.as, - botUserId, - [bot.service], - bot.prefix, - // Service bots should handle commands first - 1, - bot.avatar, - bot.displayname, - ) - ); - }); - } + }); } + } - async start(): Promise { - await this.ensureProfiles(); - await this.getJoinedRooms(); - } + async start(): Promise { + await this.ensureProfiles(); + await this.getJoinedRooms(); + } - private async ensureProfiles(): Promise { - log.info("Ensuring bot users are set up..."); - for (const botUser of this.botUsers) { - // Ensure the bot is registered - log.debug(`Ensuring bot user ${botUser.userId} is registered`); - await botUser.intent.ensureRegistered(); + private async ensureProfiles(): Promise { + log.info("Ensuring bot users are set up..."); + for (const botUser of this.botUsers) { + // Ensure the bot is registered + log.debug(`Ensuring bot user ${botUser.userId} is registered`); + await botUser.intent.ensureRegistered(); - await this.ensureProfile(botUser); - } + await this.ensureProfile(botUser); } - - /** - * Ensures the bot user profile display name and avatar image are updated. - * - * @returns Promise resolving when the user profile has been ensured. - */ - private async ensureProfile(botUser: BotUser): Promise { - const capabilites = await botUser.intent.underlyingClient.getCapabilities(); - const canSetDisplayname = capabilites["m.set_displayname"]?.enabled !== false; - const canSetAvatarUrl = capabilites["m.set_avatar_url"]?.enabled !== false; - log.debug(`Ensuring profile for ${botUser.userId} is updated`); - - let profile: { - avatar_url?: string, - displayname?: string, - }; - try { - profile = await botUser.intent.underlyingClient.getUserProfile(botUser.userId); - } catch (e) { - log.error(`Failed to get user profile for ${botUser.userId}:`, e); - profile = {}; - } - - // Update display name if necessary - if (canSetDisplayname && botUser.displayname && profile.displayname !== botUser.displayname) { - try { - await botUser.intent.underlyingClient.setDisplayName(botUser.displayname); - log.info(`Updated displayname for "${botUser.userId}" to ${botUser.displayname}`); - } catch (e) { - log.error(`Failed to set displayname for ${botUser.userId}:`, e); - } - } - - if (!canSetAvatarUrl) { - return; - } - - if (!botUser.avatar) { - // Unset any avatar - if (profile.avatar_url) { - await botUser.intent.underlyingClient.setAvatarUrl(''); - log.info(`Removed avatar for "${botUser.userId}"`); - } - - return; - } - - if (botUser.avatar.startsWith("mxc://")) { - // Configured avatar is a Matrix content URL - if (profile.avatar_url === botUser.avatar) { - // Avatar is current, no need to update - log.debug(`Avatar for ${botUser.userId} is already updated`); - return; - } - - try { - await botUser.intent.underlyingClient.setAvatarUrl(botUser.avatar); - log.info(`Updated avatar for ${botUser.userId} to ${botUser.avatar}`); - } catch (e) { - log.error(`Failed to set avatar for ${botUser.userId}:`, e); - } - - return; - } - - // Otherwise assume configured avatar is a file path - let avatarImage: { - image: Buffer, - contentType: string, - }; - try { - const contentType = (await mime).default.getType(botUser.avatar); - if (!contentType) { - throw new Error("Could not determine content type"); - } - // File path - avatarImage = { - image: await fs.readFile(botUser.avatar), - contentType, - }; - } catch (e) { - log.error(`Failed to load avatar at ${botUser.avatar}:`, e); - return; - } - - // Determine if an avatar update is needed - if (profile.avatar_url) { - try { - const res = await botUser.intent.underlyingClient.downloadContent(profile.avatar_url); - const currentAvatarImage = { - image: res.data, - contentType: res.contentType, - }; - if ( - currentAvatarImage.image.equals(avatarImage.image) - && currentAvatarImage.contentType === avatarImage.contentType - ) { - // Avatar is current, no need to update - log.debug(`Avatar for ${botUser.userId} is already updated`); - return; - } - } catch (e) { - log.error(`Failed to get current avatar image for ${botUser.userId}:`, e); - } - } - - // Update the avatar - try { - const uploadedAvatarMxcUrl = await botUser.intent.underlyingClient.uploadContent( - avatarImage.image, - avatarImage.contentType, - ); - await botUser.intent.underlyingClient.setAvatarUrl(uploadedAvatarMxcUrl); - log.info(`Updated avatar for ${botUser.userId} to ${uploadedAvatarMxcUrl}`); - } catch (e) { - log.error(`Failed to set avatar for ${botUser.userId}:`, e); - } + } + + /** + * Ensures the bot user profile display name and avatar image are updated. + * + * @returns Promise resolving when the user profile has been ensured. + */ + private async ensureProfile(botUser: BotUser): Promise { + const capabilites = await botUser.intent.underlyingClient.getCapabilities(); + const canSetDisplayname = + capabilites["m.set_displayname"]?.enabled !== false; + const canSetAvatarUrl = capabilites["m.set_avatar_url"]?.enabled !== false; + log.debug(`Ensuring profile for ${botUser.userId} is updated`); + + let profile: { + avatar_url?: string; + displayname?: string; + }; + try { + profile = await botUser.intent.underlyingClient.getUserProfile( + botUser.userId, + ); + } catch (e) { + log.error(`Failed to get user profile for ${botUser.userId}:`, e); + profile = {}; } - private async getJoinedRooms(): Promise { - log.info("Getting joined rooms..."); - for (const botUser of this.botUsers) { - const joinedRooms = await botUser.intent.underlyingClient.getJoinedRooms(); - for (const roomId of joinedRooms) { - this.onRoomJoin(botUser, roomId); - } - } + // Update display name if necessary + if ( + canSetDisplayname && + botUser.displayname && + profile.displayname !== botUser.displayname + ) { + try { + await botUser.intent.underlyingClient.setDisplayName( + botUser.displayname, + ); + log.info( + `Updated displayname for "${botUser.userId}" to ${botUser.displayname}`, + ); + } catch (e) { + log.error(`Failed to set displayname for ${botUser.userId}:`, e); + } } - /** - * Records a bot user having joined a room. - * - * @param botUser - * @param roomId - */ - onRoomJoin(botUser: BotUser, roomId: string): void { - log.debug(`Bot user ${botUser.userId} joined room ${roomId}`); - const botUsers = this._botsInRooms.get(roomId) ?? new Set(); - botUsers.add(botUser); - this._botsInRooms.set(roomId, botUsers); + if (!canSetAvatarUrl) { + return; } - /** - * Records a bot user having left a room. - * - * @param botUser - * @param roomId - */ - onRoomLeave(botUser: BotUser, roomId: string): void { - log.info(`Bot user ${botUser.userId} left room ${roomId}`); - const botUsers = this._botsInRooms.get(roomId) ?? new Set(); - botUsers.delete(botUser); - if (botUsers.size > 0) { - this._botsInRooms.set(roomId, botUsers); - } else { - this._botsInRooms.delete(roomId); - } - } + if (!botUser.avatar) { + // Unset any avatar + if (profile.avatar_url) { + await botUser.intent.underlyingClient.setAvatarUrl(""); + log.info(`Removed avatar for "${botUser.userId}"`); + } - /** - * Gets the list of room IDs where at least one bot is a member. - * - * @returns List of room IDs. - */ - get joinedRooms(): string[] { - return Array.from(this._botsInRooms.keys()); + return; } - /** - * Gets the configured bot users, ordered by priority. - * - * @returns List of bot users. - */ - get botUsers(): BotUser[] { - return Array.from(this._botUsers.values()) - .sort(higherPriority) + if (botUser.avatar.startsWith("mxc://")) { + // Configured avatar is a Matrix content URL + if (profile.avatar_url === botUser.avatar) { + // Avatar is current, no need to update + log.debug(`Avatar for ${botUser.userId} is already updated`); + return; + } + + try { + await botUser.intent.underlyingClient.setAvatarUrl(botUser.avatar); + log.info(`Updated avatar for ${botUser.userId} to ${botUser.avatar}`); + } catch (e) { + log.error(`Failed to set avatar for ${botUser.userId}:`, e); + } + + return; } - /** - * Gets a configured bot user by user ID. - * - * @param userId User ID to get. - */ - getBotUser(userId: string): BotUser | undefined { - return this._botUsers.get(userId); + // Otherwise assume configured avatar is a file path + let avatarImage: { + image: Buffer; + contentType: string; + }; + try { + const contentType = (await mime).default.getType(botUser.avatar); + if (!contentType) { + throw new Error("Could not determine content type"); + } + // File path + avatarImage = { + image: await fs.readFile(botUser.avatar), + contentType, + }; + } catch (e) { + log.error(`Failed to load avatar at ${botUser.avatar}:`, e); + return; } - /** - * Checks if the given user ID belongs to a configured bot user. - * - * @param userId User ID to check. - * @returns `true` if the user ID belongs to a bot user, otherwise `false`. - */ - isBotUser(userId: string): boolean { - return this._botUsers.has(userId); + // Determine if an avatar update is needed + if (profile.avatar_url) { + try { + const res = await botUser.intent.underlyingClient.downloadContent( + profile.avatar_url, + ); + const currentAvatarImage = { + image: res.data, + contentType: res.contentType, + }; + if ( + currentAvatarImage.image.equals(avatarImage.image) && + currentAvatarImage.contentType === avatarImage.contentType + ) { + // Avatar is current, no need to update + log.debug(`Avatar for ${botUser.userId} is already updated`); + return; + } + } catch (e) { + log.error( + `Failed to get current avatar image for ${botUser.userId}:`, + e, + ); + } } - /** - * Gets all the bot users in a room, ordered by priority. - * - * @param roomId Room ID to get bots for. - */ - getBotUsersInRoom(roomId: string): BotUser[] { - return Array.from(this._botsInRooms.get(roomId) || new Set()) - .sort(higherPriority); + // Update the avatar + try { + const uploadedAvatarMxcUrl = + await botUser.intent.underlyingClient.uploadContent( + avatarImage.image, + avatarImage.contentType, + ); + await botUser.intent.underlyingClient.setAvatarUrl(uploadedAvatarMxcUrl); + log.info( + `Updated avatar for ${botUser.userId} to ${uploadedAvatarMxcUrl}`, + ); + } catch (e) { + log.error(`Failed to set avatar for ${botUser.userId}:`, e); } - - /** - * Gets a bot user in a room, optionally for a particular service. - * When a service is specified, the bot user with the highest priority which handles that service is returned. - * - * @param roomId Room ID to get a bot user for. - * @param serviceType Optional service type for the bot. - */ - getBotUserInRoom(roomId: string, serviceType?: string): BotUser | undefined { - const botUsersInRoom = this.getBotUsersInRoom(roomId); - if (serviceType) { - return botUsersInRoom.find(b => b.services.includes(serviceType)); - } else { - return botUsersInRoom[0]; - } + } + + private async getJoinedRooms(): Promise { + log.info("Getting joined rooms..."); + for (const botUser of this.botUsers) { + const joinedRooms = + await botUser.intent.underlyingClient.getJoinedRooms(); + for (const roomId of joinedRooms) { + this.onRoomJoin(botUser, roomId); + } } - - /** - * Gets the bot user with the highest priority for a particular service. - * - * @param serviceType Service type for the bot. - */ - getBotUserForService(serviceType: string): BotUser | undefined { - return this.botUsers.find(b => b.services.includes(serviceType)); + } + + /** + * Records a bot user having joined a room. + * + * @param botUser + * @param roomId + */ + onRoomJoin(botUser: BotUser, roomId: string): void { + log.debug(`Bot user ${botUser.userId} joined room ${roomId}`); + const botUsers = this._botsInRooms.get(roomId) ?? new Set(); + botUsers.add(botUser); + this._botsInRooms.set(roomId, botUsers); + } + + /** + * Records a bot user having left a room. + * + * @param botUser + * @param roomId + */ + onRoomLeave(botUser: BotUser, roomId: string): void { + log.info(`Bot user ${botUser.userId} left room ${roomId}`); + const botUsers = this._botsInRooms.get(roomId) ?? new Set(); + botUsers.delete(botUser); + if (botUsers.size > 0) { + this._botsInRooms.set(roomId, botUsers); + } else { + this._botsInRooms.delete(roomId); + } + } + + /** + * Gets the list of room IDs where at least one bot is a member. + * + * @returns List of room IDs. + */ + get joinedRooms(): string[] { + return Array.from(this._botsInRooms.keys()); + } + + /** + * Gets the configured bot users, ordered by priority. + * + * @returns List of bot users. + */ + get botUsers(): BotUser[] { + return Array.from(this._botUsers.values()).sort(higherPriority); + } + + /** + * Gets a configured bot user by user ID. + * + * @param userId User ID to get. + */ + getBotUser(userId: string): BotUser | undefined { + return this._botUsers.get(userId); + } + + /** + * Checks if the given user ID belongs to a configured bot user. + * + * @param userId User ID to check. + * @returns `true` if the user ID belongs to a bot user, otherwise `false`. + */ + isBotUser(userId: string): boolean { + return this._botUsers.has(userId); + } + + /** + * Gets all the bot users in a room, ordered by priority. + * + * @param roomId Room ID to get bots for. + */ + getBotUsersInRoom(roomId: string): BotUser[] { + return Array.from(this._botsInRooms.get(roomId) || new Set()).sort( + higherPriority, + ); + } + + /** + * Gets a bot user in a room, optionally for a particular service. + * When a service is specified, the bot user with the highest priority which handles that service is returned. + * + * @param roomId Room ID to get a bot user for. + * @param serviceType Optional service type for the bot. + */ + getBotUserInRoom(roomId: string, serviceType?: string): BotUser | undefined { + const botUsersInRoom = this.getBotUsersInRoom(roomId); + if (serviceType) { + return botUsersInRoom.find((b) => b.services.includes(serviceType)); + } else { + return botUsersInRoom[0]; } + } + + /** + * Gets the bot user with the highest priority for a particular service. + * + * @param serviceType Service type for the bot. + */ + getBotUserForService(serviceType: string): BotUser | undefined { + return this.botUsers.find((b) => b.services.includes(serviceType)); + } } diff --git a/src/MatrixEvent.ts b/src/MatrixEvent.ts index 67bb5e90b..4815be38d 100644 --- a/src/MatrixEvent.ts +++ b/src/MatrixEvent.ts @@ -1,38 +1,44 @@ -export interface MatrixEvent { - content: T; - event_id: string; - origin_server_ts: number; - sender: string; - state_key: string|undefined; - type: string; +export interface MatrixEvent { + content: T; + event_id: string; + origin_server_ts: number; + sender: string; + state_key: string | undefined; + type: string; } type MatrixEventContent = object; export interface MatrixMemberContent extends MatrixEventContent { - avatar_url: string|null; - displayname: string|null; - membership: "invite"|"join"|"knock"|"leave"|"ban"; - is_direct?: boolean; - // Deliberately ignoring third_party_invite, unsigned + avatar_url: string | null; + displayname: string | null; + membership: "invite" | "join" | "knock" | "leave" | "ban"; + is_direct?: boolean; + // Deliberately ignoring third_party_invite, unsigned } export interface MatrixMessageContent extends MatrixEventContent { - body: string; - formatted_body?: string; - format?: string; - msgtype: "m.text"|"m.notice"|"m.image"|"m.video"|"m.audio"|"m.emote"; - "m.relates_to"?: { - "m.in_reply_to"?: { - event_id: string; - }, + body: string; + formatted_body?: string; + format?: string; + msgtype: + | "m.text" + | "m.notice" + | "m.image" + | "m.video" + | "m.audio" + | "m.emote"; + "m.relates_to"?: { + "m.in_reply_to"?: { + event_id: string; }; + }; } export interface MatrixReactionContent extends MatrixEventContent { - 'm.relates_to': { - event_id: string; - key: string; - rel_type: 'm.annotation'; - } + "m.relates_to": { + event_id: string; + key: string; + rel_type: "m.annotation"; + }; } diff --git a/src/MatrixSender.ts b/src/MatrixSender.ts index 36b59e127..3a0f60763 100644 --- a/src/MatrixSender.ts +++ b/src/MatrixSender.ts @@ -2,112 +2,138 @@ import { BridgeConfig } from "./config/Config"; import { MessageQueue, createMessageQueue } from "./MessageQueue"; import { Appservice } from "matrix-bot-sdk"; import { Logger } from "matrix-appservice-bridge"; -import { randomUUID } from 'node:crypto'; +import { randomUUID } from "node:crypto"; export interface IMatrixSendMessage { - sender: string|null; - type: string; - roomId: string; - content: Record; + sender: string | null; + type: string; + roomId: string; + content: Record; } export interface IMatrixSendMessageResponse { - eventId: string; + eventId: string; } export interface IMatrixSendMessageFailedResponse { - failed: boolean; + failed: boolean; } - const log = new Logger("MatrixSender"); export class MatrixSender { - private mq: MessageQueue; - constructor(private config: BridgeConfig, private readonly as: Appservice) { - this.mq = createMessageQueue(this.config.queue); - } + private mq: MessageQueue; + constructor( + private config: BridgeConfig, + private readonly as: Appservice, + ) { + this.mq = createMessageQueue(this.config.queue); + } - public listen() { - this.mq.subscribe("matrix.message"); - this.mq.on("matrix.message", async (msg) => { - try { - await this.sendMatrixMessage(msg.messageId || randomUUID(), msg.data); - } catch (ex) { - log.error(`Failed to send message (${msg.data.roomId}, ${msg.data.sender}, ${msg.data.type})`, ex); - } - }); - } + public listen() { + this.mq.subscribe("matrix.message"); + this.mq.on("matrix.message", async (msg) => { + try { + await this.sendMatrixMessage(msg.messageId || randomUUID(), msg.data); + } catch (ex) { + log.error( + `Failed to send message (${msg.data.roomId}, ${msg.data.sender}, ${msg.data.type})`, + ex, + ); + } + }); + } - public stop() { - if (this.mq.stop) { - this.mq.stop(); - } + public stop() { + if (this.mq.stop) { + this.mq.stop(); } + } - public async sendMatrixMessage(messageId: string, msg: IMatrixSendMessage) { - const intent = msg.sender ? this.as.getIntentForUserId(msg.sender) : this.as.botIntent; - if (this.config.encryption) { - // Ensure crypto is aware of all members of this room before posting any messages, - // so that the bot can share room keys to all recipients first. - await intent.enableEncryption(); - await intent.joinRoom(msg.roomId); - await intent.underlyingClient.crypto.onRoomJoin(msg.roomId); - } else { - await intent.ensureRegisteredAndJoined(msg.roomId); - } - try { - const eventId = await intent.underlyingClient.sendEvent(msg.roomId, msg.type, msg.content); - log.info(`Sent event to room ${msg.roomId} (${msg.sender}) > ${eventId}`); - await this.mq.push({ - eventName: "response.matrix.message", - sender: "MatrixSender", - data: { - eventId, - }, - messageId, - }); - } catch (ex) { - await this.mq.push({ - eventName: "response.matrix.message", - sender: "MatrixSender", - data: { - failed: true, - }, - messageId, - }); - } + public async sendMatrixMessage(messageId: string, msg: IMatrixSendMessage) { + const intent = msg.sender + ? this.as.getIntentForUserId(msg.sender) + : this.as.botIntent; + if (this.config.encryption) { + // Ensure crypto is aware of all members of this room before posting any messages, + // so that the bot can share room keys to all recipients first. + await intent.enableEncryption(); + await intent.joinRoom(msg.roomId); + await intent.underlyingClient.crypto.onRoomJoin(msg.roomId); + } else { + await intent.ensureRegisteredAndJoined(msg.roomId); } + try { + const eventId = await intent.underlyingClient.sendEvent( + msg.roomId, + msg.type, + msg.content, + ); + log.info(`Sent event to room ${msg.roomId} (${msg.sender}) > ${eventId}`); + await this.mq.push({ + eventName: "response.matrix.message", + sender: "MatrixSender", + data: { + eventId, + }, + messageId, + }); + } catch (ex) { + await this.mq.push({ + eventName: "response.matrix.message", + sender: "MatrixSender", + data: { + failed: true, + }, + messageId, + }); + } + } } export class MessageSenderClient { - constructor(private queue: MessageQueue) { } + constructor(private queue: MessageQueue) {} - public async sendMatrixText(roomId: string, text: string, msgtype = "m.text", - sender: string|null = null): Promise { - return this.sendMatrixMessage(roomId, { - msgtype, - body: text, - }, "m.room.message", sender); - } + public async sendMatrixText( + roomId: string, + text: string, + msgtype = "m.text", + sender: string | null = null, + ): Promise { + return this.sendMatrixMessage( + roomId, + { + msgtype, + body: text, + }, + "m.room.message", + sender, + ); + } - public async sendMatrixMessage(roomId: string, - content: unknown, eventType = "m.room.message", - sender: string|null = null): Promise { - const result = await this.queue.pushWait({ - eventName: "matrix.message", - sender: "Bridge", - data: { - roomId, - type: eventType, - sender, - content: content as Record, - }, - }); + public async sendMatrixMessage( + roomId: string, + content: unknown, + eventType = "m.room.message", + sender: string | null = null, + ): Promise { + const result = await this.queue.pushWait< + IMatrixSendMessage, + IMatrixSendMessageResponse | IMatrixSendMessageFailedResponse + >({ + eventName: "matrix.message", + sender: "Bridge", + data: { + roomId, + type: eventType, + sender, + content: content as Record, + }, + }); - if ("eventId" in result) { - return result.eventId; - } - throw Error('Failed to send Matrix message'); + if ("eventId" in result) { + return result.eventId; } + throw Error("Failed to send Matrix message"); + } } diff --git a/src/MessageQueue/LocalMQ.ts b/src/MessageQueue/LocalMQ.ts index 2220b4d6a..6245c873a 100644 --- a/src/MessageQueue/LocalMQ.ts +++ b/src/MessageQueue/LocalMQ.ts @@ -1,57 +1,67 @@ import { EventEmitter } from "events"; -import { MessageQueue, MessageQueueMessage, DEFAULT_RES_TIMEOUT } from "./Types"; +import { + MessageQueue, + MessageQueueMessage, + DEFAULT_RES_TIMEOUT, +} from "./Types"; import micromatch from "micromatch"; -import { randomUUID } from 'node:crypto'; +import { randomUUID } from "node:crypto"; import Metrics from "../Metrics"; export class LocalMQ extends EventEmitter implements MessageQueue { - private subs: Set; - constructor() { - super(); - this.subs = new Set(); - } + private subs: Set; + constructor() { + super(); + this.subs = new Set(); + } - public subscribe(eventGlob: string) { - this.subs.add(eventGlob); - } + public subscribe(eventGlob: string) { + this.subs.add(eventGlob); + } - public unsubscribe(eventGlob: string) { - this.subs.delete(eventGlob); - } + public unsubscribe(eventGlob: string) { + this.subs.delete(eventGlob); + } - public async push(message: MessageQueueMessage) { - Metrics.messageQueuePushes.inc({event: message.eventName}); - if (!micromatch.match([...this.subs], message.eventName)) { - return; - } - if (!message.messageId) { - message.messageId = randomUUID(); - } - this.emit(message.eventName, message); + public async push(message: MessageQueueMessage) { + Metrics.messageQueuePushes.inc({ event: message.eventName }); + if (!micromatch.match([...this.subs], message.eventName)) { + return; } - - public async pushWait(message: MessageQueueMessage, - timeout: number = DEFAULT_RES_TIMEOUT): Promise { - let resolve: (value: X) => void; - let timer: NodeJS.Timeout; - - const p = new Promise((res, rej) => { - resolve = res; - timer = setTimeout(() => { - rej(new Error(`Timeout waiting for message queue response for ${message.eventName} / ${message.messageId}`)); - }, timeout); - }); - - const awaitResponse = (response: MessageQueueMessage) => { - if (response.messageId === message.messageId) { - clearTimeout(timer); - this.removeListener(`response.${message.eventName}`, awaitResponse); - resolve(response.data); - } - }; - - this.addListener(`response.${message.eventName}`, awaitResponse); - this.push(message); - return p; + if (!message.messageId) { + message.messageId = randomUUID(); } + this.emit(message.eventName, message); + } + + public async pushWait( + message: MessageQueueMessage, + timeout: number = DEFAULT_RES_TIMEOUT, + ): Promise { + let resolve: (value: X) => void; + let timer: NodeJS.Timeout; + + const p = new Promise((res, rej) => { + resolve = res; + timer = setTimeout(() => { + rej( + new Error( + `Timeout waiting for message queue response for ${message.eventName} / ${message.messageId}`, + ), + ); + }, timeout); + }); + + const awaitResponse = (response: MessageQueueMessage) => { + if (response.messageId === message.messageId) { + clearTimeout(timer); + this.removeListener(`response.${message.eventName}`, awaitResponse); + resolve(response.data); + } + }; + + this.addListener(`response.${message.eventName}`, awaitResponse); + this.push(message); + return p; + } } diff --git a/src/MessageQueue/MessageQueue.ts b/src/MessageQueue/MessageQueue.ts index 03dc1d17f..2be966353 100644 --- a/src/MessageQueue/MessageQueue.ts +++ b/src/MessageQueue/MessageQueue.ts @@ -4,14 +4,14 @@ import { RedisMQ } from "./RedisQueue"; import { MessageQueue } from "./Types"; const staticLocalMq = new LocalMQ(); -let staticRedisMq: RedisMQ|null = null; +let staticRedisMq: RedisMQ | null = null; export function createMessageQueue(config?: BridgeConfigQueue): MessageQueue { - if (!config) { - return staticLocalMq; - } - if (staticRedisMq === null) { - staticRedisMq = new RedisMQ(config); - } - return staticRedisMq; + if (!config) { + return staticLocalMq; + } + if (staticRedisMq === null) { + staticRedisMq = new RedisMQ(config); + } + return staticRedisMq; } diff --git a/src/MessageQueue/RedisQueue.ts b/src/MessageQueue/RedisQueue.ts index 4e88969e0..a15b9cff0 100644 --- a/src/MessageQueue/RedisQueue.ts +++ b/src/MessageQueue/RedisQueue.ts @@ -1,132 +1,148 @@ - -import { MessageQueue, MessageQueueMessage, DEFAULT_RES_TIMEOUT, MessageQueueMessageOut } from "./Types"; +import { + MessageQueue, + MessageQueueMessage, + DEFAULT_RES_TIMEOUT, + MessageQueueMessageOut, +} from "./Types"; import { Redis, default as redis } from "ioredis"; import { BridgeConfigQueue } from "../config/sections/queue"; import { EventEmitter } from "events"; import { Logger } from "matrix-appservice-bridge"; -import { randomUUID } from 'node:crypto'; +import { randomUUID } from "node:crypto"; const log = new Logger("RedisMq"); const CONSUMER_TRACK_PREFIX = "consumers."; export class RedisMQ extends EventEmitter implements MessageQueue { - - private static removePartsFromEventName(evName: string, partCount: number) { - return evName.split(".").slice(0, -partCount).join("."); - } - - private redisSub: Redis; - private redisPub: Redis; - private redis: Redis; - private myUuid: string; - constructor(config: BridgeConfigQueue) { - super(); - const uri = 'redisUri' in config ? config.redisUri : `redis://${config.host ?? 'localhost'}:${config.port ?? 6379}`; - this.redisSub = new redis(uri); - this.redisPub = new redis(uri); - this.redis = new redis(uri); - this.myUuid = randomUUID(); - this.redisSub.on("pmessage", (_: string, channel: string, message: string) => { - const msg = JSON.parse(message) as MessageQueueMessageOut; - if (msg.for && msg.for !== this.myUuid) { - log.debug(`Got message for ${msg.for}, dropping`); - return; - } - const delay = (process.hrtime()[1]) - msg.ts; - log.debug("Delay: ", delay / 1000000, "ms"); - this.emit(channel, JSON.parse(message)); - }); - this.redis.on("once", () => log.info("Redis is ready")); - } - - public async connect(): Promise { - try { - await this.redis.ping(); - } catch (ex) { - log.error('Could not ping the redis instance, is it reachable?'); - throw ex; + private static removePartsFromEventName(evName: string, partCount: number) { + return evName.split(".").slice(0, -partCount).join("."); + } + + private redisSub: Redis; + private redisPub: Redis; + private redis: Redis; + private myUuid: string; + constructor(config: BridgeConfigQueue) { + super(); + const uri = + "redisUri" in config + ? config.redisUri + : `redis://${config.host ?? "localhost"}:${config.port ?? 6379}`; + this.redisSub = new redis(uri); + this.redisPub = new redis(uri); + this.redis = new redis(uri); + this.myUuid = randomUUID(); + this.redisSub.on( + "pmessage", + (_: string, channel: string, message: string) => { + const msg = JSON.parse(message) as MessageQueueMessageOut; + if (msg.for && msg.for !== this.myUuid) { + log.debug(`Got message for ${msg.for}, dropping`); + return; } - log.info("Successfully connected"); + const delay = process.hrtime()[1] - msg.ts; + log.debug("Delay: ", delay / 1000000, "ms"); + this.emit(channel, JSON.parse(message)); + }, + ); + this.redis.on("once", () => log.info("Redis is ready")); + } + + public async connect(): Promise { + try { + await this.redis.ping(); + } catch (ex) { + log.error("Could not ping the redis instance, is it reachable?"); + throw ex; } - - public subscribe(eventGlob: string) { - this.redisSub.psubscribe(eventGlob); - const consumerName = eventGlob.endsWith("*") ? RedisMQ.removePartsFromEventName(eventGlob, 1) : eventGlob; - this.redis.sadd(`${CONSUMER_TRACK_PREFIX}${consumerName}`, this.myUuid); - } - - public unsubscribe(eventGlob: string) { - this.redisSub.punsubscribe(eventGlob); - this.redis.srem(`${CONSUMER_TRACK_PREFIX}${eventGlob}`, this.myUuid); - } - - public async push(message: MessageQueueMessage, single = false) { - if (!message.messageId) { - message.messageId = randomUUID(); - } - if (single) { - const recipient = await this.getRecipientForEvent(message.eventName); - if (!recipient) { - throw Error("Cannot find recipient for event"); - } - message.for = recipient; - } - const outMsg: MessageQueueMessageOut = { - ...message, - ts: process.hrtime()[1], - } - try { - await this.redisPub.publish(message.eventName, JSON.stringify(outMsg)); - log.debug(`Pushed ${message.eventName}`); - } catch (ex) { - log.warn("Failed to push an event:", ex); - throw Error("Failed to push message into queue"); - } + log.info("Successfully connected"); + } + + public subscribe(eventGlob: string) { + this.redisSub.psubscribe(eventGlob); + const consumerName = eventGlob.endsWith("*") + ? RedisMQ.removePartsFromEventName(eventGlob, 1) + : eventGlob; + this.redis.sadd(`${CONSUMER_TRACK_PREFIX}${consumerName}`, this.myUuid); + } + + public unsubscribe(eventGlob: string) { + this.redisSub.punsubscribe(eventGlob); + this.redis.srem(`${CONSUMER_TRACK_PREFIX}${eventGlob}`, this.myUuid); + } + + public async push(message: MessageQueueMessage, single = false) { + if (!message.messageId) { + message.messageId = randomUUID(); } - - public async pushWait(message: MessageQueueMessage, - timeout: number = DEFAULT_RES_TIMEOUT): Promise { - let resolve: (value: X) => void; - let timer: NodeJS.Timeout; - - const p = new Promise((res, rej) => { - resolve = res; - timer = setTimeout(() => { - rej(new Error("Timeout waiting for message queue response")); - }, timeout); - }); - - const awaitResponse = (response: MessageQueueMessage) => { - if (response.messageId === message.messageId) { - clearTimeout(timer); - this.removeListener(`response.${message.eventName}`, awaitResponse); - resolve(response.data); - } - }; - - this.addListener(`response.${message.eventName}`, awaitResponse); - await this.push(message); - return p; + if (single) { + const recipient = await this.getRecipientForEvent(message.eventName); + if (!recipient) { + throw Error("Cannot find recipient for event"); + } + message.for = recipient; } - - public async stop() { - await Promise.all([ - this.redisPub.quit(), - this.redisSub.quit(), - this.redis.quit()]); + const outMsg: MessageQueueMessageOut = { + ...message, + ts: process.hrtime()[1], + }; + try { + await this.redisPub.publish(message.eventName, JSON.stringify(outMsg)); + log.debug(`Pushed ${message.eventName}`); + } catch (ex) { + log.warn("Failed to push an event:", ex); + throw Error("Failed to push message into queue"); } - - private async getRecipientForEvent(eventName: string): Promise { - let recipient = null; - let parts = 0; - const totalParts = eventName.split(".").length; - // Work backwards from the event name. - while (recipient === null && parts < totalParts) { - const evName = RedisMQ.removePartsFromEventName(eventName, parts); - recipient = await this.redis.srandmember(evName) || null; - parts++; - } - return recipient; + } + + public async pushWait( + message: MessageQueueMessage, + timeout: number = DEFAULT_RES_TIMEOUT, + ): Promise { + let resolve: (value: X) => void; + let timer: NodeJS.Timeout; + + const p = new Promise((res, rej) => { + resolve = res; + timer = setTimeout(() => { + rej(new Error("Timeout waiting for message queue response")); + }, timeout); + }); + + const awaitResponse = (response: MessageQueueMessage) => { + if (response.messageId === message.messageId) { + clearTimeout(timer); + this.removeListener(`response.${message.eventName}`, awaitResponse); + resolve(response.data); + } + }; + + this.addListener(`response.${message.eventName}`, awaitResponse); + await this.push(message); + return p; + } + + public async stop() { + await Promise.all([ + this.redisPub.quit(), + this.redisSub.quit(), + this.redis.quit(), + ]); + } + + private async getRecipientForEvent( + eventName: string, + ): Promise { + let recipient = null; + let parts = 0; + const totalParts = eventName.split(".").length; + // Work backwards from the event name. + while (recipient === null && parts < totalParts) { + const evName = RedisMQ.removePartsFromEventName(eventName, parts); + recipient = (await this.redis.srandmember(evName)) || null; + parts++; } + return recipient; + } } diff --git a/src/MessageQueue/Types.ts b/src/MessageQueue/Types.ts index 76bd8c7fe..c735dacec 100644 --- a/src/MessageQueue/Types.ts +++ b/src/MessageQueue/Types.ts @@ -1,23 +1,30 @@ export interface MessageQueueMessage { - sender: string; - eventName: string; - data: T; - messageId?: string; - for?: string; + sender: string; + eventName: string; + data: T; + messageId?: string; + for?: string; } export interface MessageQueueMessageOut extends MessageQueueMessage { - ts: number; + ts: number; } export interface MessageQueue { - subscribe: (eventGlob: string) => void; - unsubscribe: (eventGlob: string) => void; - push: (data: MessageQueueMessage, single?: boolean) => Promise; - pushWait: (data: MessageQueueMessage, timeout?: number, single?: boolean) => Promise; - on: (eventName: string, cb: (data: MessageQueueMessageOut) => void) => void; - stop?(): Promise; - connect?(): Promise; + subscribe: (eventGlob: string) => void; + unsubscribe: (eventGlob: string) => void; + push: (data: MessageQueueMessage, single?: boolean) => Promise; + pushWait: ( + data: MessageQueueMessage, + timeout?: number, + single?: boolean, + ) => Promise; + on: ( + eventName: string, + cb: (data: MessageQueueMessageOut) => void, + ) => void; + stop?(): Promise; + connect?(): Promise; } -export const DEFAULT_RES_TIMEOUT = 30000; \ No newline at end of file +export const DEFAULT_RES_TIMEOUT = 30000; diff --git a/src/MessageQueue/index.ts b/src/MessageQueue/index.ts index f0118fa92..ac2c7fef6 100644 --- a/src/MessageQueue/index.ts +++ b/src/MessageQueue/index.ts @@ -1,2 +1,2 @@ export * from "./Types"; -export * from "./MessageQueue"; \ No newline at end of file +export * from "./MessageQueue"; diff --git a/src/Metrics.ts b/src/Metrics.ts index c639e6b32..f6cbce178 100644 --- a/src/Metrics.ts +++ b/src/Metrics.ts @@ -1,119 +1,217 @@ -import { Appservice, FunctionCallContext, METRIC_MATRIX_CLIENT_FAILED_FUNCTION_CALL, METRIC_MATRIX_CLIENT_SUCCESSFUL_FUNCTION_CALL } from "matrix-bot-sdk"; -import { collectDefaultMetrics, Counter, Gauge, register, Registry } from "prom-client"; +import { + Appservice, + FunctionCallContext, + METRIC_MATRIX_CLIENT_FAILED_FUNCTION_CALL, + METRIC_MATRIX_CLIENT_SUCCESSFUL_FUNCTION_CALL, +} from "matrix-bot-sdk"; +import { + collectDefaultMetrics, + Counter, + Gauge, + register, + Registry, +} from "prom-client"; import { Response, Router } from "express"; import { Logger } from "matrix-appservice-bridge"; const log = new Logger("Metrics"); export class Metrics { - public readonly expressRouter = Router(); - - public readonly webhooksHttpRequest; - public readonly provisioningHttpRequest; - - public readonly messageQueuePushes; - public readonly connectionsEventFailed; - public readonly connections; - - public readonly notificationsPush; - public readonly notificationsServiceUp; - public readonly notificationsWatchers; - - private readonly matrixApiCalls; - private readonly matrixApiCallsFailed; - - public readonly matrixAppserviceEvents; - public readonly matrixAppserviceDecryptionFailed; - - public readonly feedsCount; - public readonly feedFetchMs; - public readonly feedsFailing; - public readonly feedsCountDeprecated; - public readonly feedsFetchMsDeprecated; - public readonly feedsFailingDeprecated; - - - constructor(private registry: Registry = register) { - this.expressRouter.get('/metrics', this.metricsFunc.bind(this)); - - this.webhooksHttpRequest = new Counter({ name: "hookshot_webhooks_http_request", help: "Number of requests made to the hookshot webhooks handler", labelNames: ["path", "method"], registers: [this.registry]}); - this.provisioningHttpRequest = new Counter({ name: "hookshot_provisioning_http_request", help: "Number of requests made to the hookshot provisioner handler", labelNames: ["path", "method"], registers: [this.registry]}); - - this.messageQueuePushes = new Counter({ name: "hookshot_queue_event_pushes", help: "Number of events pushed through the queue", labelNames: ["event"], registers: [this.registry]}); - this.connectionsEventFailed = new Counter({ name: "hookshot_connection_event_failed", help: "Number of events that failed to process", labelNames: ["event", "connectionId"], registers: [this.registry]}); - this.connections = new Gauge({ name: "hookshot_connections", help: "Number of active hookshot connections", labelNames: ["service"], registers: [this.registry]}); - - this.notificationsPush = new Counter({ name: "hookshot_notifications_push", help: "Number of notifications pushed", labelNames: ["service"], registers: [this.registry]}); - this.notificationsServiceUp = new Gauge({ name: "hookshot_notifications_service_up", help: "Whether the notification service is up or down", labelNames: ["service"], registers: [this.registry]}); - this.notificationsWatchers = new Gauge({ name: "hookshot_notifications_watchers", help: "Number of notifications watchers running", labelNames: ["service"], registers: [this.registry]}); - - this.matrixApiCalls = new Counter({ name: "matrix_api_calls", help: "Number of Matrix client API calls made", labelNames: ["method"], registers: [this.registry]}); - this.matrixApiCallsFailed = new Counter({ name: "matrix_api_calls_failed", help: "Number of Matrix client API calls which failed", labelNames: ["method"], registers: [this.registry]}); - - this.matrixAppserviceEvents = new Counter({ name: "matrix_appservice_events", help: "Number of events sent over the AS API", labelNames: [], registers: [this.registry]}); - this.matrixAppserviceDecryptionFailed = new Counter({ name: "matrix_appservice_decryption_failed", help: "Number of events sent over the AS API that failed to decrypt", registers: [this.registry]}); - - this.feedsCount = new Gauge({ name: "hookshot_feeds_count", help: "Number of RSS feeds that hookshot is subscribed to", labelNames: [], registers: [this.registry]}); - this.feedFetchMs = new Gauge({ name: "hookshot_feeds_fetch_ms", help: "Time taken for hookshot to fetch all feeds", labelNames: [], registers: [this.registry]}); - this.feedsFailing = new Gauge({ name: "hookshot_feeds_failing", help: "Number of RSS feeds that hookshot is failing to read", labelNames: ["reason"], registers: [this.registry]}); - this.feedsCountDeprecated = new Gauge({ name: "feed_count", help: "(Deprecated) Number of RSS feeds that hookshot is subscribed to", labelNames: [], registers: [this.registry]}); - this.feedsFetchMsDeprecated = new Gauge({ name: "feed_fetch_ms", help: "(Deprecated) Time taken for hookshot to fetch all feeds", labelNames: [], registers: [this.registry]}); - this.feedsFailingDeprecated = new Gauge({ name: "feed_failing", help: "(Deprecated) Number of RSS feeds that hookshot is failing to read", labelNames: ["reason"], registers: [this.registry]}); - - collectDefaultMetrics({ - register: this.registry, - }) - } - - public async getMetrics() { - return this.registry.metrics(); - } - - - /** - * Registers some exported metrics that relate to operations of the embedded - * matrix-js-sdk. In particular, a metric is added that counts the number of - * calls to client API endpoints made by the client library. - */ - public registerMatrixSdkMetrics(appservice: Appservice): void { - appservice.metrics.registerListener({ - onStartMetric: () => { - // Not used yet. - }, - onEndMetric: () => { - // Not used yet. - }, - onIncrement: (metricName, context) => { - if (metricName === METRIC_MATRIX_CLIENT_SUCCESSFUL_FUNCTION_CALL) { - const ctx = context as FunctionCallContext; - this.matrixApiCalls.inc({method: ctx.functionName}); - } - if (metricName === METRIC_MATRIX_CLIENT_FAILED_FUNCTION_CALL) { - const ctx = context as FunctionCallContext; - this.matrixApiCallsFailed.inc({method: ctx.functionName}); - } - }, - onDecrement: () => { - // Not used yet. - }, - onReset: (metricName) => { - if (metricName === METRIC_MATRIX_CLIENT_SUCCESSFUL_FUNCTION_CALL) { - this.matrixApiCalls.reset(); - } - if (metricName === METRIC_MATRIX_CLIENT_FAILED_FUNCTION_CALL) { - this.matrixApiCallsFailed.reset(); - } - }, - }) - } - - private metricsFunc(_req: unknown, res: Response) { - this.getMetrics().then( - (m) => res.type('text/plain').send((m)) - ).catch((err) => { - log.error('Failed to fetch metrics: ', err); - res.status(500).send('Could not fetch metrics due to an error'); - }); - } + public readonly expressRouter = Router(); + + public readonly webhooksHttpRequest; + public readonly provisioningHttpRequest; + + public readonly messageQueuePushes; + public readonly connectionsEventFailed; + public readonly connections; + + public readonly notificationsPush; + public readonly notificationsServiceUp; + public readonly notificationsWatchers; + + private readonly matrixApiCalls; + private readonly matrixApiCallsFailed; + + public readonly matrixAppserviceEvents; + public readonly matrixAppserviceDecryptionFailed; + + public readonly feedsCount; + public readonly feedFetchMs; + public readonly feedsFailing; + public readonly feedsCountDeprecated; + public readonly feedsFetchMsDeprecated; + public readonly feedsFailingDeprecated; + + constructor(private registry: Registry = register) { + this.expressRouter.get("/metrics", this.metricsFunc.bind(this)); + + this.webhooksHttpRequest = new Counter({ + name: "hookshot_webhooks_http_request", + help: "Number of requests made to the hookshot webhooks handler", + labelNames: ["path", "method"], + registers: [this.registry], + }); + this.provisioningHttpRequest = new Counter({ + name: "hookshot_provisioning_http_request", + help: "Number of requests made to the hookshot provisioner handler", + labelNames: ["path", "method"], + registers: [this.registry], + }); + + this.messageQueuePushes = new Counter({ + name: "hookshot_queue_event_pushes", + help: "Number of events pushed through the queue", + labelNames: ["event"], + registers: [this.registry], + }); + this.connectionsEventFailed = new Counter({ + name: "hookshot_connection_event_failed", + help: "Number of events that failed to process", + labelNames: ["event", "connectionId"], + registers: [this.registry], + }); + this.connections = new Gauge({ + name: "hookshot_connections", + help: "Number of active hookshot connections", + labelNames: ["service"], + registers: [this.registry], + }); + + this.notificationsPush = new Counter({ + name: "hookshot_notifications_push", + help: "Number of notifications pushed", + labelNames: ["service"], + registers: [this.registry], + }); + this.notificationsServiceUp = new Gauge({ + name: "hookshot_notifications_service_up", + help: "Whether the notification service is up or down", + labelNames: ["service"], + registers: [this.registry], + }); + this.notificationsWatchers = new Gauge({ + name: "hookshot_notifications_watchers", + help: "Number of notifications watchers running", + labelNames: ["service"], + registers: [this.registry], + }); + + this.matrixApiCalls = new Counter({ + name: "matrix_api_calls", + help: "Number of Matrix client API calls made", + labelNames: ["method"], + registers: [this.registry], + }); + this.matrixApiCallsFailed = new Counter({ + name: "matrix_api_calls_failed", + help: "Number of Matrix client API calls which failed", + labelNames: ["method"], + registers: [this.registry], + }); + + this.matrixAppserviceEvents = new Counter({ + name: "matrix_appservice_events", + help: "Number of events sent over the AS API", + labelNames: [], + registers: [this.registry], + }); + this.matrixAppserviceDecryptionFailed = new Counter({ + name: "matrix_appservice_decryption_failed", + help: "Number of events sent over the AS API that failed to decrypt", + registers: [this.registry], + }); + + this.feedsCount = new Gauge({ + name: "hookshot_feeds_count", + help: "Number of RSS feeds that hookshot is subscribed to", + labelNames: [], + registers: [this.registry], + }); + this.feedFetchMs = new Gauge({ + name: "hookshot_feeds_fetch_ms", + help: "Time taken for hookshot to fetch all feeds", + labelNames: [], + registers: [this.registry], + }); + this.feedsFailing = new Gauge({ + name: "hookshot_feeds_failing", + help: "Number of RSS feeds that hookshot is failing to read", + labelNames: ["reason"], + registers: [this.registry], + }); + this.feedsCountDeprecated = new Gauge({ + name: "feed_count", + help: "(Deprecated) Number of RSS feeds that hookshot is subscribed to", + labelNames: [], + registers: [this.registry], + }); + this.feedsFetchMsDeprecated = new Gauge({ + name: "feed_fetch_ms", + help: "(Deprecated) Time taken for hookshot to fetch all feeds", + labelNames: [], + registers: [this.registry], + }); + this.feedsFailingDeprecated = new Gauge({ + name: "feed_failing", + help: "(Deprecated) Number of RSS feeds that hookshot is failing to read", + labelNames: ["reason"], + registers: [this.registry], + }); + + collectDefaultMetrics({ + register: this.registry, + }); + } + + public async getMetrics() { + return this.registry.metrics(); + } + + /** + * Registers some exported metrics that relate to operations of the embedded + * matrix-js-sdk. In particular, a metric is added that counts the number of + * calls to client API endpoints made by the client library. + */ + public registerMatrixSdkMetrics(appservice: Appservice): void { + appservice.metrics.registerListener({ + onStartMetric: () => { + // Not used yet. + }, + onEndMetric: () => { + // Not used yet. + }, + onIncrement: (metricName, context) => { + if (metricName === METRIC_MATRIX_CLIENT_SUCCESSFUL_FUNCTION_CALL) { + const ctx = context as FunctionCallContext; + this.matrixApiCalls.inc({ method: ctx.functionName }); + } + if (metricName === METRIC_MATRIX_CLIENT_FAILED_FUNCTION_CALL) { + const ctx = context as FunctionCallContext; + this.matrixApiCallsFailed.inc({ method: ctx.functionName }); + } + }, + onDecrement: () => { + // Not used yet. + }, + onReset: (metricName) => { + if (metricName === METRIC_MATRIX_CLIENT_SUCCESSFUL_FUNCTION_CALL) { + this.matrixApiCalls.reset(); + } + if (metricName === METRIC_MATRIX_CLIENT_FAILED_FUNCTION_CALL) { + this.matrixApiCallsFailed.reset(); + } + }, + }); + } + + private metricsFunc(_req: unknown, res: Response) { + this.getMetrics() + .then((m) => res.type("text/plain").send(m)) + .catch((err) => { + log.error("Failed to fetch metrics: ", err); + res.status(500).send("Could not fetch metrics due to an error"); + }); + } } const singleton = new Metrics(); diff --git a/src/NotificationFilters.ts b/src/NotificationFilters.ts index 08611f7c2..85efc95a3 100644 --- a/src/NotificationFilters.ts +++ b/src/NotificationFilters.ts @@ -1,88 +1,91 @@ - interface FilterContent { - users: string[]; - repos: string[]; - orgs: string[]; + users: string[]; + repos: string[]; + orgs: string[]; } export interface NotificationFilterStateContent { - filters: { - [name: string]: FilterContent; - }; - forNotifications: string[]; - forInvites: string[]; + filters: { + [name: string]: FilterContent; + }; + forNotifications: string[]; + forInvites: string[]; } /** * A notification filter is a set of keys that define what should be sent to the user. */ export class NotifFilter { - static readonly StateType = "uk.half-shot.matrix-github.notif-filter"; - static readonly LegacyStateType = "uk.half-shot.matrix-hookshot.notif-filter"; + static readonly StateType = "uk.half-shot.matrix-github.notif-filter"; + static readonly LegacyStateType = "uk.half-shot.matrix-hookshot.notif-filter"; - static getDefaultContent(): NotificationFilterStateContent { - return { - filters: {}, - forNotifications: [], - forInvites: [], - } - } + static getDefaultContent(): NotificationFilterStateContent { + return { + filters: {}, + forNotifications: [], + forInvites: [], + }; + } - public readonly forNotifications: Set; - public readonly forInvites: Set; - public filters: Record; - constructor(stateContent: NotificationFilterStateContent) { - this.forNotifications = new Set(stateContent.forNotifications); - this.forInvites = new Set(stateContent.forInvites); - this.filters = stateContent.filters; - } + public readonly forNotifications: Set; + public readonly forInvites: Set; + public filters: Record; + constructor(stateContent: NotificationFilterStateContent) { + this.forNotifications = new Set(stateContent.forNotifications); + this.forInvites = new Set(stateContent.forInvites); + this.filters = stateContent.filters; + } - public get empty() { - return Object.values(this.filters).length === 0; - } + public get empty() { + return Object.values(this.filters).length === 0; + } - public getStateContent(): NotificationFilterStateContent { - return { - filters: this.filters, - forInvites: [...this.forInvites], - forNotifications: [...this.forNotifications], - }; - } + public getStateContent(): NotificationFilterStateContent { + return { + filters: this.filters, + forInvites: [...this.forInvites], + forNotifications: [...this.forNotifications], + }; + } - public shouldInviteToRoom(): boolean { - return false; - } + public shouldInviteToRoom(): boolean { + return false; + } - public shouldSendNotification(user?: string, repo?: string, org?: string): boolean { - if (this.forNotifications.size === 0) { - // Default on. - return true; - } - for (const filterName of this.forNotifications) { - const filter = this.filters[filterName]; - if (!filter) { - // Filter with this name exists. - continue; - } - if (user && filter.users.includes(user.toLowerCase())) { - // We have a user in this notif and we are filtering on users. - return true; - } - if (repo && filter.repos.includes(repo.toLowerCase())) { - // We have a repo in this notif and we are filtering on repos. - return true; - } - if (org && filter.orgs.includes(org.toLowerCase())) { - // We have an org in this notif and we are filtering on orgs. - return true; - } - // None of the filters matched, so exclude the result. - return false; - } - return false; + public shouldSendNotification( + user?: string, + repo?: string, + org?: string, + ): boolean { + if (this.forNotifications.size === 0) { + // Default on. + return true; } - - public setFilter(name: string, filter: FilterContent) { - this.filters[name] = filter; + for (const filterName of this.forNotifications) { + const filter = this.filters[filterName]; + if (!filter) { + // Filter with this name exists. + continue; + } + if (user && filter.users.includes(user.toLowerCase())) { + // We have a user in this notif and we are filtering on users. + return true; + } + if (repo && filter.repos.includes(repo.toLowerCase())) { + // We have a repo in this notif and we are filtering on repos. + return true; + } + if (org && filter.orgs.includes(org.toLowerCase())) { + // We have an org in this notif and we are filtering on orgs. + return true; + } + // None of the filters matched, so exclude the result. + return false; } -} \ No newline at end of file + return false; + } + + public setFilter(name: string, filter: FilterContent) { + this.filters[name] = filter; + } +} diff --git a/src/Notifications/GitHubWatcher.ts b/src/Notifications/GitHubWatcher.ts index 4d05d1515..2f32186b1 100644 --- a/src/Notifications/GitHubWatcher.ts +++ b/src/Notifications/GitHubWatcher.ts @@ -11,139 +11,174 @@ const log = new Logger("GitHubWatcher"); const GH_API_THRESHOLD = 50; const GH_API_RETRY_IN = 1000 * 60; -type GitHubUserNotification = RestEndpointMethodTypes["activity"]["listNotificationsForAuthenticatedUser"]["response"]; +type GitHubUserNotification = + RestEndpointMethodTypes["activity"]["listNotificationsForAuthenticatedUser"]["response"]; -export class GitHubWatcher extends EventEmitter implements NotificationWatcherTask { - private static apiFailureCount = 0; - private static globalRetryIn = 0; +export class GitHubWatcher + extends EventEmitter + implements NotificationWatcherTask +{ + private static apiFailureCount = 0; + private static globalRetryIn = 0; - public static checkGitHubStatus() { - this.apiFailureCount = Math.min(this.apiFailureCount + 1, GH_API_THRESHOLD); - if (this.apiFailureCount < GH_API_THRESHOLD) { - log.warn(`API Failure count at ${this.apiFailureCount}`); - return; - } - // The API is actively failing. - if (this.globalRetryIn > 0) { - this.globalRetryIn = Date.now() + GH_API_RETRY_IN; - } - log.warn(`API Failure limit reached, holding off new requests for ${GH_API_RETRY_IN / 1000}s`); - Metrics.notificationsServiceUp.set({service: "github"}, 0); + public static checkGitHubStatus() { + this.apiFailureCount = Math.min(this.apiFailureCount + 1, GH_API_THRESHOLD); + if (this.apiFailureCount < GH_API_THRESHOLD) { + log.warn(`API Failure count at ${this.apiFailureCount}`); + return; } + // The API is actively failing. + if (this.globalRetryIn > 0) { + this.globalRetryIn = Date.now() + GH_API_RETRY_IN; + } + log.warn( + `API Failure limit reached, holding off new requests for ${GH_API_RETRY_IN / 1000}s`, + ); + Metrics.notificationsServiceUp.set({ service: "github" }, 0); + } - private octoKit: Octokit; - public failureCount = 0; - private interval?: NodeJS.Timeout; - public readonly type = "github"; - public readonly instanceUrl = undefined; + private octoKit: Octokit; + public failureCount = 0; + private interval?: NodeJS.Timeout; + public readonly type = "github"; + public readonly instanceUrl = undefined; - constructor(token: string, baseUrl: URL, public userId: string, public roomId: string, private lastReadTs: number, private participating = false) { - super(); - this.octoKit = GithubInstance.createUserOctokit(token, baseUrl); - } + constructor( + token: string, + baseUrl: URL, + public userId: string, + public roomId: string, + private lastReadTs: number, + private participating = false, + ) { + super(); + this.octoKit = GithubInstance.createUserOctokit(token, baseUrl); + } - public get since() { - return this.lastReadTs; - } + public get since() { + return this.lastReadTs; + } + + public start(intervalMs: number) { + log.info(`Starting for ${this.userId}`); + this.interval = setInterval(() => { + this.getNotifications(); + }, intervalMs); + this.getNotifications(); + } - public start(intervalMs: number) { - log.info(`Starting for ${this.userId}`); - this.interval = setInterval(() => { - this.getNotifications(); - }, intervalMs); - this.getNotifications(); + public stop() { + if (this.interval) { + log.info(`Stopping for ${this.userId}`); + clearInterval(this.interval); } + } - public stop() { - if (this.interval) { - log.info(`Stopping for ${this.userId}`); - clearInterval(this.interval); - } + private handleGitHubFailure(ex: RequestError) { + log.error("An error occurred getting notifications:", ex); + if (ex.status === 401 || ex.status === 404) { + log.warn( + `Got status ${ex.status} when handing user stream: ${ex.message}`, + ); + this.failureCount++; + } else if (ex.status >= 500) { + setImmediate(() => GitHubWatcher.checkGitHubStatus()); } + this.emit("fetch_failure", this); + } - private handleGitHubFailure(ex: RequestError) { - log.error("An error occurred getting notifications:", ex); - if (ex.status === 401 || ex.status === 404) { - log.warn(`Got status ${ex.status} when handing user stream: ${ex.message}`); - this.failureCount++; - } else if (ex.status >= 500) { - setImmediate(() => GitHubWatcher.checkGitHubStatus()); - } - this.emit("fetch_failure", this); + private async getNotifications() { + if ( + GitHubWatcher.globalRetryIn !== 0 && + GitHubWatcher.globalRetryIn > Date.now() + ) { + log.info( + `Not getting notifications for ${this.userId}, API is still down.`, + ); + return; + } + log.debug(`Getting notifications for ${this.userId} ${this.lastReadTs}`); + const since = + this.lastReadTs !== 0 + ? `&since=${new Date(this.lastReadTs).toISOString()}` + : ""; + let response: GitHubUserNotification; + try { + response = + await this.octoKit.activity.listNotificationsForAuthenticatedUser({ + since, + participating: this.participating, + }); + Metrics.notificationsServiceUp.set({ service: "github" }, 1); + // We were succesful, clear any timeouts. + GitHubWatcher.globalRetryIn = 0; + // To avoid a bouncing issue, gradually reduce the failure count. + GitHubWatcher.apiFailureCount = Math.max( + 0, + GitHubWatcher.apiFailureCount - 2, + ); + } catch (ex) { + await this.handleGitHubFailure(ex as RequestError); + return; } + this.lastReadTs = Date.now(); - private async getNotifications() { - if (GitHubWatcher.globalRetryIn !== 0 && GitHubWatcher.globalRetryIn > Date.now()) { - log.info(`Not getting notifications for ${this.userId}, API is still down.`); - return; - } - log.debug(`Getting notifications for ${this.userId} ${this.lastReadTs}`); - const since = this.lastReadTs !== 0 ? `&since=${new Date(this.lastReadTs).toISOString()}`: ""; - let response: GitHubUserNotification; - try { - response = await this.octoKit.activity.listNotificationsForAuthenticatedUser({since, participating: this.participating}); - Metrics.notificationsServiceUp.set({service: "github"}, 1); - // We were succesful, clear any timeouts. - GitHubWatcher.globalRetryIn = 0; - // To avoid a bouncing issue, gradually reduce the failure count. - GitHubWatcher.apiFailureCount = Math.max(0, GitHubWatcher.apiFailureCount - 2); - } catch (ex) { - await this.handleGitHubFailure(ex as RequestError); - return; + if (response.data.length) { + log.info(`Got ${response.data.length} notifications for ${this.userId}`); + } + for (const rawEvent of response.data) { + const ev = rawEvent as unknown as HSGitHubUserNotification; + try { + if (rawEvent.subject.url) { + const res = await this.octoKit.request(rawEvent.subject.url); + ev.subject.url_data = res.data; } - this.lastReadTs = Date.now(); - - if (response.data.length) { - log.info(`Got ${response.data.length} notifications for ${this.userId}`); + if (rawEvent.subject.latest_comment_url) { + const res = await this.octoKit.request( + rawEvent.subject.latest_comment_url, + ); + ev.subject.latest_comment_url_data = res.data; } - for (const rawEvent of response.data) { - const ev = rawEvent as unknown as HSGitHubUserNotification; - try { - if (rawEvent.subject.url) { - const res = await this.octoKit.request(rawEvent.subject.url); - ev.subject.url_data = res.data; - } - if (rawEvent.subject.latest_comment_url) { - const res = await this.octoKit.request(rawEvent.subject.latest_comment_url); - ev.subject.latest_comment_url_data = res.data; - } - if (rawEvent.reason === "review_requested") { - if (!ev.subject.url_data?.number) { - log.warn("review_requested was missing subject.url_data.number"); - continue; - } - if (!rawEvent.repository.owner) { - log.warn("review_requested was missing repository.owner"); - continue; - } - ev.subject.requested_reviewers = (await this.octoKit.pulls.listRequestedReviewers({ - pull_number: ev.subject.url_data.number, - owner: rawEvent.repository.owner.login, - repo: rawEvent.repository.name, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - })).data as any; - ev.subject.reviews = (await this.octoKit.pulls.listReviews({ - pull_number: ev.subject.url_data.number, - owner: rawEvent.repository.owner.login, - repo: rawEvent.repository.name, - })).data; - } - } catch (ex) { - log.warn(`Failed to pre-process ${rawEvent.id}: ${ex}`); - // We still push - } - log.debug(`Pushing ${ev.id}`); - Metrics.notificationsPush.inc({service: "github"}); - this.emit("new_events", { - eventName: "notifications.user.events", - data: { - roomId: this.roomId, - events: [ev], - lastReadTs: this.lastReadTs, - }, - sender: "GithubWebhooks", - }); + if (rawEvent.reason === "review_requested") { + if (!ev.subject.url_data?.number) { + log.warn("review_requested was missing subject.url_data.number"); + continue; + } + if (!rawEvent.repository.owner) { + log.warn("review_requested was missing repository.owner"); + continue; + } + ev.subject.requested_reviewers = ( + await this.octoKit.pulls.listRequestedReviewers({ + pull_number: ev.subject.url_data.number, + owner: rawEvent.repository.owner.login, + repo: rawEvent.repository.name, + + }) + ).data as any; + ev.subject.reviews = ( + await this.octoKit.pulls.listReviews({ + pull_number: ev.subject.url_data.number, + owner: rawEvent.repository.owner.login, + repo: rawEvent.repository.name, + }) + ).data; } + } catch (ex) { + log.warn(`Failed to pre-process ${rawEvent.id}: ${ex}`); + // We still push + } + log.debug(`Pushing ${ev.id}`); + Metrics.notificationsPush.inc({ service: "github" }); + this.emit("new_events", { + eventName: "notifications.user.events", + data: { + roomId: this.roomId, + events: [ev], + lastReadTs: this.lastReadTs, + }, + sender: "GithubWebhooks", + }); } - + } } diff --git a/src/Notifications/GitLabWatcher.ts b/src/Notifications/GitLabWatcher.ts index f18f5e9f1..f1fd4f008 100644 --- a/src/Notifications/GitLabWatcher.ts +++ b/src/Notifications/GitLabWatcher.ts @@ -5,29 +5,38 @@ import { NotificationWatcherTask } from "./NotificationWatcherTask"; const log = new Logger("GitLabWatcher"); -export class GitLabWatcher extends EventEmitter implements NotificationWatcherTask { - private client: GitLabClient; - private interval?: NodeJS.Timeout; - public readonly type = "gitlab"; - public failureCount = 0; - constructor(token: string, url: string, public userId: string, public roomId: string, public since: number) { - super(); - this.client = new GitLabClient(url, token); - } +export class GitLabWatcher + extends EventEmitter + implements NotificationWatcherTask +{ + private client: GitLabClient; + private interval?: NodeJS.Timeout; + public readonly type = "gitlab"; + public failureCount = 0; + constructor( + token: string, + url: string, + public userId: string, + public roomId: string, + public since: number, + ) { + super(); + this.client = new GitLabClient(url, token); + } - public start(intervalMs: number) { - this.interval = setInterval(() => { - this.getNotifications(); - }, intervalMs); - } + public start(intervalMs: number) { + this.interval = setInterval(() => { + this.getNotifications(); + }, intervalMs); + } - public stop() { - if (this.interval) { - clearInterval(this.interval); - } + public stop() { + if (this.interval) { + clearInterval(this.interval); } + } - private async getNotifications() { - log.info(`Fetching events from GitLab for ${this.userId}`); - } -} \ No newline at end of file + private async getNotifications() { + log.info(`Fetching events from GitLab for ${this.userId}`); + } +} diff --git a/src/Notifications/NotificationWatcherTask.ts b/src/Notifications/NotificationWatcherTask.ts index b31375d94..bc415d214 100644 --- a/src/Notifications/NotificationWatcherTask.ts +++ b/src/Notifications/NotificationWatcherTask.ts @@ -1,14 +1,14 @@ import { EventEmitter } from "events"; -type NotificationTypes = "github"|"gitlab"; +type NotificationTypes = "github" | "gitlab"; export interface NotificationWatcherTask extends EventEmitter { - userId: string; - type: NotificationTypes; - instanceUrl?: string; - roomId: string; - failureCount: number; - since: number; - start(intervalMs: number): void; - stop(): void; -} \ No newline at end of file + userId: string; + type: NotificationTypes; + instanceUrl?: string; + roomId: string; + failureCount: number; + since: number; + start(intervalMs: number): void; + stop(): void; +} diff --git a/src/Notifications/UserNotificationWatcher.ts b/src/Notifications/UserNotificationWatcher.ts index ef2b950dd..8f3561262 100644 --- a/src/Notifications/UserNotificationWatcher.ts +++ b/src/Notifications/UserNotificationWatcher.ts @@ -1,6 +1,13 @@ -import { NotificationsDisableEvent, NotificationsEnableEvent } from "../Webhooks"; +import { + NotificationsDisableEvent, + NotificationsEnableEvent, +} from "../Webhooks"; import { Logger } from "matrix-appservice-bridge"; -import { createMessageQueue, MessageQueue, MessageQueueMessage } from "../MessageQueue"; +import { + createMessageQueue, + MessageQueue, + MessageQueueMessage, +} from "../MessageQueue"; import { MessageSenderClient } from "../MatrixSender"; import { NotificationWatcherTask } from "./NotificationWatcherTask"; import { GitHubWatcher } from "./GitHubWatcher"; @@ -9,9 +16,9 @@ import { GitLabWatcher } from "./GitLabWatcher"; import { BridgeConfig, BridgePermissionLevel } from "../config/Config"; import Metrics from "../Metrics"; export interface UserNotificationsEvent { - roomId: string; - lastReadTs: number; - events: GitHubUserNotification[]; + roomId: string; + lastReadTs: number; + events: GitHubUserNotification[]; } const MIN_INTERVAL_MS = 15000; @@ -20,87 +27,137 @@ const FAILURE_THRESHOLD = 50; const log = new Logger("UserNotificationWatcher"); export class UserNotificationWatcher { - /* Key: userId:type:instanceUrl */ - private userIntervals = new Map(); - private matrixMessageSender: MessageSenderClient; - private queue: MessageQueue; + /* Key: userId:type:instanceUrl */ + private userIntervals = new Map(); + private matrixMessageSender: MessageSenderClient; + private queue: MessageQueue; - constructor(private readonly config: BridgeConfig) { - this.queue = createMessageQueue(config.queue); - this.matrixMessageSender = new MessageSenderClient(this.queue); - } + constructor(private readonly config: BridgeConfig) { + this.queue = createMessageQueue(config.queue); + this.matrixMessageSender = new MessageSenderClient(this.queue); + } - private static constructMapKey(userId: string, type: "github"|"gitlab", instanceUrl?: string) { - return `${userId}:${type}:${instanceUrl || ""}`; - } + private static constructMapKey( + userId: string, + type: "github" | "gitlab", + instanceUrl?: string, + ) { + return `${userId}:${type}:${instanceUrl || ""}`; + } - public start() { - this.queue.subscribe("notifications.user.*"); - this.queue.on("notifications.user.enable", (msg: MessageQueueMessage) => { - this.addUser(msg.data); - }); - this.queue.on("notifications.user.disable", (msg: MessageQueueMessage) => { - this.removeUser(msg.data.userId, msg.data.type, msg.data.instanceUrl); - }); - } + public start() { + this.queue.subscribe("notifications.user.*"); + this.queue.on( + "notifications.user.enable", + (msg: MessageQueueMessage) => { + this.addUser(msg.data); + }, + ); + this.queue.on( + "notifications.user.disable", + (msg: MessageQueueMessage) => { + this.removeUser(msg.data.userId, msg.data.type, msg.data.instanceUrl); + }, + ); + } - public stop() { - [...this.userIntervals.values()].forEach((v) => { - v.stop(); - }); - } + public stop() { + [...this.userIntervals.values()].forEach((v) => { + v.stop(); + }); + } - public removeUser(userId: string, type: "github"|"gitlab", instanceUrl?: string) { - const key = UserNotificationWatcher.constructMapKey(userId, type, instanceUrl); - const task = this.userIntervals.get(key); - if (task) { - task.stop(); - this.userIntervals.delete(key); - log.info(`Removed ${key} from the notif queue`); - } - Metrics.notificationsWatchers.set({service: type}, this.userIntervals.size); + public removeUser( + userId: string, + type: "github" | "gitlab", + instanceUrl?: string, + ) { + const key = UserNotificationWatcher.constructMapKey( + userId, + type, + instanceUrl, + ); + const task = this.userIntervals.get(key); + if (task) { + task.stop(); + this.userIntervals.delete(key); + log.info(`Removed ${key} from the notif queue`); } + Metrics.notificationsWatchers.set( + { service: type }, + this.userIntervals.size, + ); + } - private onFetchFailure(task: NotificationWatcherTask) { - if (task.failureCount > FAILURE_THRESHOLD) { - this.removeUser(task.userId, task.type, task.instanceUrl); - this.matrixMessageSender.sendMatrixText( - task.roomId, -`The bridge has been unable to process your notification stream for some time, and has disabled notifications. -Check your token is still valid, and then turn notifications back on.`, "m.notice", - ); - } + private onFetchFailure(task: NotificationWatcherTask) { + if (task.failureCount > FAILURE_THRESHOLD) { + this.removeUser(task.userId, task.type, task.instanceUrl); + this.matrixMessageSender.sendMatrixText( + task.roomId, + `The bridge has been unable to process your notification stream for some time, and has disabled notifications. +Check your token is still valid, and then turn notifications back on.`, + "m.notice", + ); } + } - public addUser(data: NotificationsEnableEvent) { - if (!this.config.checkPermission(data.userId, data.type, BridgePermissionLevel.notifications)) { - throw Error('User does not have permission enable notifications'); - } - let task: NotificationWatcherTask; - const key = UserNotificationWatcher.constructMapKey(data.userId, data.type, data.instanceUrl); - const existing = this.userIntervals.get(key); - const since = data.since || existing?.since; - if (since === undefined) { - throw Error('`since` value missing from data payload, and no previous since value exists'); - } - if (data.type === "github") { - if (!this.config.github) { - throw Error('GitHub is not configured'); - } - task = new GitHubWatcher(data.token, this.config.github.baseUrl, data.userId, data.roomId, since, data.filterParticipating); - } else if (data.type === "gitlab" && data.instanceUrl) { - task = new GitLabWatcher(data.token, data.instanceUrl, data.userId, data.roomId, since); - } else { - throw Error('Notification type not known'); - } - this.userIntervals.get(key)?.stop(); - task.start(MIN_INTERVAL_MS); - task.on("fetch_failure", this.onFetchFailure.bind(this)); - task.on("new_events", (payload) => { - this.queue.push(payload); - }); - this.userIntervals.set(key, task); - Metrics.notificationsWatchers.set({service: data.type}, this.userIntervals.size); - log.info(`Inserted ${key} into the notif queue`); + public addUser(data: NotificationsEnableEvent) { + if ( + !this.config.checkPermission( + data.userId, + data.type, + BridgePermissionLevel.notifications, + ) + ) { + throw Error("User does not have permission enable notifications"); + } + let task: NotificationWatcherTask; + const key = UserNotificationWatcher.constructMapKey( + data.userId, + data.type, + data.instanceUrl, + ); + const existing = this.userIntervals.get(key); + const since = data.since || existing?.since; + if (since === undefined) { + throw Error( + "`since` value missing from data payload, and no previous since value exists", + ); + } + if (data.type === "github") { + if (!this.config.github) { + throw Error("GitHub is not configured"); + } + task = new GitHubWatcher( + data.token, + this.config.github.baseUrl, + data.userId, + data.roomId, + since, + data.filterParticipating, + ); + } else if (data.type === "gitlab" && data.instanceUrl) { + task = new GitLabWatcher( + data.token, + data.instanceUrl, + data.userId, + data.roomId, + since, + ); + } else { + throw Error("Notification type not known"); } + this.userIntervals.get(key)?.stop(); + task.start(MIN_INTERVAL_MS); + task.on("fetch_failure", this.onFetchFailure.bind(this)); + task.on("new_events", (payload) => { + this.queue.push(payload); + }); + this.userIntervals.set(key, task); + Metrics.notificationsWatchers.set( + { service: data.type }, + this.userIntervals.size, + ); + log.info(`Inserted ${key} into the notif queue`); + } } diff --git a/src/NotificationsProcessor.ts b/src/NotificationsProcessor.ts index 640e85cb8..67aab0ac6 100644 --- a/src/NotificationsProcessor.ts +++ b/src/NotificationsProcessor.ts @@ -5,7 +5,13 @@ import { Logger } from "matrix-appservice-bridge"; import { AdminRoom } from "./AdminRoom"; import markdown from "markdown-it"; import { FormatUtil } from "./FormatUtil"; -import { PullGetResponseData, IssuesGetResponseData, PullsListRequestedReviewersResponseData, PullsListReviewsResponseData, IssuesGetCommentResponseData } from "./github/Types"; +import { + PullGetResponseData, + IssuesGetResponseData, + PullsListRequestedReviewersResponseData, + PullsListReviewsResponseData, + IssuesGetCommentResponseData, +} from "./github/Types"; import { GitHubUserNotification } from "./github/Types"; import { components } from "@octokit/openapi-types/types"; import { NotifFilter } from "./NotificationFilters"; @@ -14,263 +20,319 @@ const log = new Logger("NotificationProcessor"); const md = new markdown(); export interface IssueDiff { - state: null|string; - assignee: null|(components["schemas"]["nullable-simple-user"][]); - title: null|string; - merged: boolean; - mergedBy: null|{ - login: string; - html_url: string; - }; - user: { - login: string; - html_url: string; - }; + state: null | string; + assignee: null | components["schemas"]["nullable-simple-user"][]; + title: null | string; + merged: boolean; + mergedBy: null | { + login: string; + html_url: string; + }; + user: { + login: string; + html_url: string; + }; } export interface CachedReviewData { - requested_reviewers: PullsListRequestedReviewersResponseData; - reviews: PullsListReviewsResponseData; + requested_reviewers: PullsListRequestedReviewersResponseData; + reviews: PullsListReviewsResponseData; } -type PROrIssue = IssuesGetResponseData|PullGetResponseData; +type PROrIssue = IssuesGetResponseData | PullGetResponseData; export class NotificationProcessor { - private static formatUser(user: {login: string, html_url: string}) { - return `**[${user.login}](${user.html_url})**`; - } + private static formatUser(user: { login: string; html_url: string }) { + return `**[${user.login}](${user.html_url})**`; + } - private static formatNotification(notif: GitHubUserNotification, diff: IssueDiff|null, newComment: boolean) { - const user = diff ? ` by ${this.formatUser(diff?.user)}` : ""; - let plain = -`${this.getEmojiForNotifType(notif)} [${notif.subject.title}](${notif.subject.url_data?.html_url})${user}`; - const issueNumber = notif.subject.url_data?.number; - if (issueNumber) { - plain += ` #${issueNumber}`; - } - if (notif.repository) { - plain += ` for **[${notif.repository.full_name}](${notif.repository.html_url})**`; - } - if (diff) { - plain += "\n\n "; - if (diff.merged) { - plain += `\n\n PR was merged by ${diff.mergedBy ? NotificationProcessor.formatUser(diff.mergedBy) : ""}`; - } else if (diff.state) { - const state = diff.state[0].toUpperCase() + diff.state.slice(1).toLowerCase(); - plain += `\n\n State changed to: ${state}`; - } - if (diff.title) { - plain += `\n\n Title changed to: ${diff.title}`; - } - if (diff.assignee) { - plain += `\n\n Assigned to: ${diff.assignee.map(l => l?.login).join(", ")}`; - } - } - if (newComment) { - const comment = notif.subject.latest_comment_url_data as IssuesGetCommentResponseData; - const user = comment.user ? NotificationProcessor.formatUser(comment.user) : 'user'; - if (comment.body) { - plain += `\n\n ${user}:\n\n > ${comment.body}`; - } else { - plain += `\n\n ${user}:\n\n posted with no body`; - } - } - return { - plain, - html: md.render(plain), - }; + private static formatNotification( + notif: GitHubUserNotification, + diff: IssueDiff | null, + newComment: boolean, + ) { + const user = diff ? ` by ${this.formatUser(diff?.user)}` : ""; + let plain = `${this.getEmojiForNotifType(notif)} [${notif.subject.title}](${notif.subject.url_data?.html_url})${user}`; + const issueNumber = notif.subject.url_data?.number; + if (issueNumber) { + plain += ` #${issueNumber}`; } - - private static getEmojiForNotifType(notif: GitHubUserNotification): string { - let reasonFlag = ""; - switch (notif.reason) { - case "review_requested": - reasonFlag = "🚩"; - break; - } - switch (notif.subject.type) { - case "Issue": - return "📝"; - case "PullRequest": - return `⤵+${reasonFlag}`; - case "RepositoryVulnerabilityAlert": - return "⚠️"; - default: - return "🔔"; - } + if (notif.repository) { + plain += ` for **[${notif.repository.full_name}](${notif.repository.html_url})**`; } + if (diff) { + plain += "\n\n "; + if (diff.merged) { + plain += `\n\n PR was merged by ${diff.mergedBy ? NotificationProcessor.formatUser(diff.mergedBy) : ""}`; + } else if (diff.state) { + const state = + diff.state[0].toUpperCase() + diff.state.slice(1).toLowerCase(); + plain += `\n\n State changed to: ${state}`; + } + if (diff.title) { + plain += `\n\n Title changed to: ${diff.title}`; + } + if (diff.assignee) { + plain += `\n\n Assigned to: ${diff.assignee.map((l) => l?.login).join(", ")}`; + } + } + if (newComment) { + const comment = notif.subject + .latest_comment_url_data as IssuesGetCommentResponseData; + const user = comment.user + ? NotificationProcessor.formatUser(comment.user) + : "user"; + if (comment.body) { + plain += `\n\n ${user}:\n\n > ${comment.body}`; + } else { + plain += `\n\n ${user}:\n\n posted with no body`; + } + } + return { + plain, + html: md.render(plain), + }; + } - constructor(private storage: IBridgeStorageProvider, private matrixSender: MessageSenderClient) { - + private static getEmojiForNotifType(notif: GitHubUserNotification): string { + let reasonFlag = ""; + switch (notif.reason) { + case "review_requested": + reasonFlag = "🚩"; + break; + } + switch (notif.subject.type) { + case "Issue": + return "📝"; + case "PullRequest": + return `⤵+${reasonFlag}`; + case "RepositoryVulnerabilityAlert": + return "⚠️"; + default: + return "🔔"; } + } - public async onUserEvents(msg: UserNotificationsEvent, adminRoom: AdminRoom) { - log.info(`Got new events for ${adminRoom.userId} ${msg.events.length}`); - for (const event of msg.events) { - const isIssueOrPR = event.subject.type === "Issue" || event.subject.type === "PullRequest"; - try { - await this.handleUserNotification(msg.roomId, event, adminRoom.notifFilter); - if (isIssueOrPR && event.subject.url_data) { - const issueNumber = event.subject.url_data.number.toString(); - await this.storage.setGithubIssue( - event.repository.full_name, - issueNumber, - event.subject.url_data, - msg.roomId, - ); - if (event.subject.latest_comment_url) { - await this.storage.setLastNotifCommentUrl( - event.repository.full_name, - issueNumber, - event.subject.latest_comment_url, - msg.roomId, - ); - } + constructor( + private storage: IBridgeStorageProvider, + private matrixSender: MessageSenderClient, + ) {} - if (event.subject.requested_reviewers && event.subject.reviews) { - await this.storage.setPRReviewData( - event.repository.full_name, - issueNumber, - event.subject as CachedReviewData, - msg.roomId, - ); - } - } + public async onUserEvents(msg: UserNotificationsEvent, adminRoom: AdminRoom) { + log.info(`Got new events for ${adminRoom.userId} ${msg.events.length}`); + for (const event of msg.events) { + const isIssueOrPR = + event.subject.type === "Issue" || event.subject.type === "PullRequest"; + try { + await this.handleUserNotification( + msg.roomId, + event, + adminRoom.notifFilter, + ); + if (isIssueOrPR && event.subject.url_data) { + const issueNumber = event.subject.url_data.number.toString(); + await this.storage.setGithubIssue( + event.repository.full_name, + issueNumber, + event.subject.url_data, + msg.roomId, + ); + if (event.subject.latest_comment_url) { + await this.storage.setLastNotifCommentUrl( + event.repository.full_name, + issueNumber, + event.subject.latest_comment_url, + msg.roomId, + ); + } - } catch (ex) { - log.warn("Failed to handle event:", ex); - } - } - try { - await adminRoom.setNotifSince("github", msg.lastReadTs); - } catch (ex) { - log.error("Failed to update stream position for notifications:", ex); + if (event.subject.requested_reviewers && event.subject.reviews) { + await this.storage.setPRReviewData( + event.repository.full_name, + issueNumber, + event.subject as CachedReviewData, + msg.roomId, + ); + } } + } catch (ex) { + log.warn("Failed to handle event:", ex); + } + } + try { + await adminRoom.setNotifSince("github", msg.lastReadTs); + } catch (ex) { + log.error("Failed to update stream position for notifications:", ex); } + } - // private async diffReviewChanges(roomId: string, notif: UserNotification) { - // const issueNumber = notif.subject.url_data!.number.toString(); - // const diff = { - // newReviewers: [] as string[], - // removedReviewers: [] as string[], - // completedReviews: [] as string[], - // }; + // private async diffReviewChanges(roomId: string, notif: UserNotification) { + // const issueNumber = notif.subject.url_data!.number.toString(); + // const diff = { + // newReviewers: [] as string[], + // removedReviewers: [] as string[], + // completedReviews: [] as string[], + // }; - // const existingData: CachedReviewData|null = await this.storage.getPRReviewData( - // notif.repository.full_name, - // issueNumber, - // roomId, - // ); + // const existingData: CachedReviewData|null = await this.storage.getPRReviewData( + // notif.repository.full_name, + // issueNumber, + // roomId, + // ); - // const newData = notif.subject as CachedReviewData; + // const newData = notif.subject as CachedReviewData; - // if (existingData === null) { - // // Treat everyone as new. - // diff.newReviewers = diff.newReviewers.concat( - // notif.subject.requested_reviewers!.users.map((u) => u.login), - // notif.subject.requested_reviewers!.teams.map((t) => t.name) - // ); - // return diff; - // } - // } + // if (existingData === null) { + // // Treat everyone as new. + // diff.newReviewers = diff.newReviewers.concat( + // notif.subject.requested_reviewers!.users.map((u) => u.login), + // notif.subject.requested_reviewers!.teams.map((t) => t.name) + // ); + // return diff; + // } + // } - private formatSecurityAlert(notif: GitHubUserNotification) { - const body = `⚠️ ${notif.subject.title} - ` - + `for **[${notif.repository.full_name}](${notif.repository.html_url})**`; - return { - ...FormatUtil.getPartialBodyForGithubRepo(notif.repository), - msgtype: "m.text", - body, - formatted_body: md.render(body), - format: "org.matrix.custom.html", - }; - } + private formatSecurityAlert(notif: GitHubUserNotification) { + const body = + `⚠️ ${notif.subject.title} - ` + + `for **[${notif.repository.full_name}](${notif.repository.html_url})**`; + return { + ...FormatUtil.getPartialBodyForGithubRepo(notif.repository), + msgtype: "m.text", + body, + formatted_body: md.render(body), + format: "org.matrix.custom.html", + }; + } - private diffIssueChanges(curr: PROrIssue, prev: PROrIssue): IssueDiff { - let merged = false; - let mergedBy = null; - if ((curr as PullGetResponseData).merged !== (prev as PullGetResponseData).merged) { - merged = true; - mergedBy = (curr as PullGetResponseData).merged_by; - } - if (!curr.user) { - throw Error('No user for issue'); - } - const diff: IssueDiff = { - state: curr.state === prev.state ? null : curr.state, - merged, - mergedBy, - assignee: curr.assignee?.id === prev.assignee?.id ? null : [curr.assignee], - title: curr.title === prev.title ? null : curr.title, - user: curr.user, - }; - return diff; + private diffIssueChanges(curr: PROrIssue, prev: PROrIssue): IssueDiff { + let merged = false; + let mergedBy = null; + if ( + (curr as PullGetResponseData).merged !== + (prev as PullGetResponseData).merged + ) { + merged = true; + mergedBy = (curr as PullGetResponseData).merged_by; } + if (!curr.user) { + throw Error("No user for issue"); + } + const diff: IssueDiff = { + state: curr.state === prev.state ? null : curr.state, + merged, + mergedBy, + assignee: + curr.assignee?.id === prev.assignee?.id ? null : [curr.assignee], + title: curr.title === prev.title ? null : curr.title, + user: curr.user, + }; + return diff; + } - private async formatIssueOrPullRequest(roomId: string, notif: GitHubUserNotification) { - const issueNumber = notif.subject.url_data?.number.toString(); - let diff = null; - if (issueNumber) { - const prevIssue: IssuesGetResponseData|null = await this.storage.getGithubIssue( - notif.repository.full_name, issueNumber, roomId); - if (prevIssue && notif.subject.url_data) { - diff = this.diffIssueChanges(notif.subject.url_data, prevIssue); - } - } + private async formatIssueOrPullRequest( + roomId: string, + notif: GitHubUserNotification, + ) { + const issueNumber = notif.subject.url_data?.number.toString(); + let diff = null; + if (issueNumber) { + const prevIssue: IssuesGetResponseData | null = + await this.storage.getGithubIssue( + notif.repository.full_name, + issueNumber, + roomId, + ); + if (prevIssue && notif.subject.url_data) { + diff = this.diffIssueChanges(notif.subject.url_data, prevIssue); + } + } - const newComment = !!notif.subject.latest_comment_url && !!issueNumber && notif.subject.latest_comment_url !== - (await this.storage.getLastNotifCommentUrl(notif.repository.full_name, issueNumber, roomId)); + const newComment = + !!notif.subject.latest_comment_url && + !!issueNumber && + notif.subject.latest_comment_url !== + (await this.storage.getLastNotifCommentUrl( + notif.repository.full_name, + issueNumber, + roomId, + )); - const formatted = NotificationProcessor.formatNotification(notif, diff, newComment); - let body = { - msgtype: "m.text", - body: formatted.plain, - formatted_body: formatted.html, - format: "org.matrix.custom.html", - }; - if (newComment && notif.subject.latest_comment_url_data && notif.repository) { - // Get the details - body = { - ...body, - ...FormatUtil.getPartialBodyForComment( - notif.subject.latest_comment_url_data, - notif.repository, - notif.subject.url_data, - ), - }; - } else if (notif.subject.url_data && notif.repository) { - body = { - ...body, - ...FormatUtil.getPartialBodyForGithubIssue( - notif.repository, - notif.subject.url_data, - ), - }; - } - return this.matrixSender.sendMatrixMessage(roomId, body); + const formatted = NotificationProcessor.formatNotification( + notif, + diff, + newComment, + ); + let body = { + msgtype: "m.text", + body: formatted.plain, + formatted_body: formatted.html, + format: "org.matrix.custom.html", + }; + if ( + newComment && + notif.subject.latest_comment_url_data && + notif.repository + ) { + // Get the details + body = { + ...body, + ...FormatUtil.getPartialBodyForComment( + notif.subject.latest_comment_url_data, + notif.repository, + notif.subject.url_data, + ), + }; + } else if (notif.subject.url_data && notif.repository) { + body = { + ...body, + ...FormatUtil.getPartialBodyForGithubIssue( + notif.repository, + notif.subject.url_data, + ), + }; } + return this.matrixSender.sendMatrixMessage(roomId, body); + } - private async handleUserNotification(roomId: string, notif: GitHubUserNotification, filter: NotifFilter) { - log.debug("New notification event:", notif); - if (!filter.shouldSendNotification( - notif.subject.latest_comment_url_data?.user?.login, - notif.repository.full_name, - notif.repository.owner?.login)) { - log.debug(`Dropping notification because user is filtering it out`) - return; - } - if (notif.reason === "security_alert") { - return this.matrixSender.sendMatrixMessage(roomId, this.formatSecurityAlert(notif)); - } else if (notif.subject.type === "Issue" || notif.subject.type === "PullRequest") { - return this.formatIssueOrPullRequest(roomId, notif); - } - // We don't understand this type yet - const genericNotif = NotificationProcessor.formatNotification(notif, null, false); - return this.matrixSender.sendMatrixMessage(roomId, { - msgtype: "m.text", - body: genericNotif.plain, - formatted_body: genericNotif.html, - format: "org.matrix.custom.html", - }); + private async handleUserNotification( + roomId: string, + notif: GitHubUserNotification, + filter: NotifFilter, + ) { + log.debug("New notification event:", notif); + if ( + !filter.shouldSendNotification( + notif.subject.latest_comment_url_data?.user?.login, + notif.repository.full_name, + notif.repository.owner?.login, + ) + ) { + log.debug(`Dropping notification because user is filtering it out`); + return; + } + if (notif.reason === "security_alert") { + return this.matrixSender.sendMatrixMessage( + roomId, + this.formatSecurityAlert(notif), + ); + } else if ( + notif.subject.type === "Issue" || + notif.subject.type === "PullRequest" + ) { + return this.formatIssueOrPullRequest(roomId, notif); } -} \ No newline at end of file + // We don't understand this type yet + const genericNotif = NotificationProcessor.formatNotification( + notif, + null, + false, + ); + return this.matrixSender.sendMatrixMessage(roomId, { + msgtype: "m.text", + body: genericNotif.plain, + formatted_body: genericNotif.html, + format: "org.matrix.custom.html", + }); + } +} diff --git a/src/PromiseUtil.ts b/src/PromiseUtil.ts index 44094495a..566ffe69d 100644 --- a/src/PromiseUtil.ts +++ b/src/PromiseUtil.ts @@ -4,7 +4,7 @@ const SLEEP_TIME_MS = 1000; const EXPONENT_DIVISOR = 20; const DEFAULT_RETRY = () => true; -type RetryFn = (error: Error) => boolean|number; +type RetryFn = (error: Error) => boolean | number; /** * Checks errors returned from a Matrix API request, and determines @@ -14,14 +14,16 @@ type RetryFn = (error: Error) => boolean|number; * - A `number` if the action should be retried with a specific wait period. * - `false` if the action should not be retried.. */ -export function retryMatrixErrorFilter(err: Error|{statusCode: number, retryAfterMs?: number}) { - if ('statusCode' in err && err.statusCode >= 400 && err.statusCode <= 499) { - if (err.statusCode === StatusCodes.TOO_MANY_REQUESTS) { - return err.retryAfterMs ?? true; - } - return false; +export function retryMatrixErrorFilter( + err: Error | { statusCode: number; retryAfterMs?: number }, +) { + if ("statusCode" in err && err.statusCode >= 400 && err.statusCode <= 499) { + if (err.statusCode === StatusCodes.TOO_MANY_REQUESTS) { + return err.retryAfterMs ?? true; } - return true; + return false; + } + return true; } /** @@ -33,27 +35,31 @@ export function retryMatrixErrorFilter(err: Error|{statusCode: number, retryAfte * @returns The result of actionFn * @throws If the `maxAttempts` limit is exceeded, or the `filterFn` returns false. */ -export async function retry(actionFn: () => PromiseLike, - maxAttempts: number, - waitFor: number = SLEEP_TIME_MS, - filterFn: RetryFn = DEFAULT_RETRY): Promise { - let attempts = 0; - while (attempts < maxAttempts) { - attempts++; - try { - return await actionFn(); - } catch (ex) { - const shouldRetry = filterFn(ex); - if (shouldRetry) { - // If the filter returns a retry ms, use that. - const timeMs = typeof shouldRetry === "number" ? - // - shouldRetry : Math.pow(waitFor, 1 + (attempts / EXPONENT_DIVISOR)); - await new Promise((r) => setTimeout(r, timeMs)); - } else { - throw ex; - } - } +export async function retry( + actionFn: () => PromiseLike, + maxAttempts: number, + waitFor: number = SLEEP_TIME_MS, + filterFn: RetryFn = DEFAULT_RETRY, +): Promise { + let attempts = 0; + while (attempts < maxAttempts) { + attempts++; + try { + return await actionFn(); + } catch (ex) { + const shouldRetry = filterFn(ex); + if (shouldRetry) { + // If the filter returns a retry ms, use that. + const timeMs = + typeof shouldRetry === "number" + ? // + shouldRetry + : Math.pow(waitFor, 1 + attempts / EXPONENT_DIVISOR); + await new Promise((r) => setTimeout(r, timeMs)); + } else { + throw ex; + } } - throw Error("Timed out"); -} \ No newline at end of file + } + throw Error("Timed out"); +} diff --git a/src/Stores/MemoryStorageProvider.ts b/src/Stores/MemoryStorageProvider.ts index b92ece1c9..ea4f3230e 100644 --- a/src/Stores/MemoryStorageProvider.ts +++ b/src/Stores/MemoryStorageProvider.ts @@ -6,157 +6,231 @@ import QuickLRU from "@alloc/quick-lru"; import { SerializedGitlabDiscussionThreads } from "../Gitlab/Types"; import { OpenProjectWorkPackageCacheState } from "../openproject/state"; -export class MemoryStorageProvider extends MSP implements IBridgeStorageProvider { - private issues: Map = new Map(); - private issuesLastComment: Map = new Map(); - private reviewData: Map = new Map(); - private figmaCommentIds: Map = new Map(); - private widgetSessions: Map = new Map(); - private storedFiles = new QuickLRU({ maxSize: 128 }); - private gitlabDiscussionThreads = new Map(); - private feedGuids = new Map>(); - private houndActivityIds = new Map>(); - private houndActivityIdToEvent = new Map(); - private hasGenericHookWarnedExpiry = new Set(); - private readonly openProjectWorkPackageState = new Map(); - - constructor() { - super(); - } - - async storeFeedGuids(url: string, ...guids: string[]): Promise { - let set = this.feedGuids.get(url); - if (!set) { - set = [] - this.feedGuids.set(url, set); - } - set.unshift(...guids); - while (set.length > MAX_FEED_ITEMS) { - set.pop(); - } - } - - async hasSeenFeed(url: string): Promise { - return this.feedGuids.has(url); - } - - async hasSeenFeedGuids(url: string, ...guids: string[]): Promise { - const existing = this.feedGuids.get(url); - return existing ? guids.filter((existingGuid) => existing.includes(existingGuid)) : []; - } - - public async setGithubIssue(repo: string, issueNumber: string, data: IssuesGetResponseData, scope = "") { - this.issues.set(`${scope}${repo}/${issueNumber}`, data); - } - - public async getGithubIssue(repo: string, issueNumber: string, scope = "") { - return this.issues.get(`${scope}${repo}/${issueNumber}`) || null; - } - - public async setLastNotifCommentUrl(repo: string, issueNumber: string, url: string, scope = "") { - this.issuesLastComment.set(`${scope}${repo}/${issueNumber}`, url); - } - - public async getLastNotifCommentUrl(repo: string, issueNumber: string, scope = "") { - return this.issuesLastComment.get(`${scope}${repo}/${issueNumber}`) || null; - } - - public async setPRReviewData(repo: string, issueNumber: string, data: any, scope = "") { - const key = `${scope}:${repo}/${issueNumber}`; - this.reviewData.set(key, data); - } - - public async getPRReviewData(repo: string, issueNumber: string, scope = "") { - const key = `${scope}:${repo}/${issueNumber}`; - return this.reviewData.get(key) || null; - } - - private static figmaCommentKey(roomId: string, figmaCommentId: string) { - return `${roomId}:${figmaCommentId}`; - } - - public async setFigmaCommentEventId(roomId: string, figmaCommentId: string, eventId: string) { - this.figmaCommentIds.set(MemoryStorageProvider.figmaCommentKey(roomId, figmaCommentId), eventId); - } - - public async getFigmaCommentEventId(roomId: string, figmaCommentId: string) { - return this.figmaCommentIds.get(MemoryStorageProvider.figmaCommentKey(roomId, figmaCommentId)) || null; - } - - public async getSessionForToken(token: string) { - return this.widgetSessions.get(token) || null; - } - public async createSession(session: ProvisionSession) { - this.widgetSessions.set(session.token, session); - } - public async deleteSession(token: string) { - this.widgetSessions.delete(token); - } - public async deleteAllSessions(userId: string) { - [...this.widgetSessions.values()] - .filter(s => s.userId === userId) - .forEach(s => this.widgetSessions.delete(s.token)); - } - - public async getStoredTempFile(key: string): Promise { - return this.storedFiles.get(key) ?? null; - } - - public async setStoredTempFile(key: string, value: string) { - this.storedFiles.set(key, value); - } - - public async getGitlabDiscussionThreads(connectionId: string): Promise { - return this.gitlabDiscussionThreads.get(connectionId) ?? []; - } - - public async setGitlabDiscussionThreads(connectionId: string, value: SerializedGitlabDiscussionThreads): Promise { - this.gitlabDiscussionThreads.set(connectionId, value); - } - - async storeHoundActivity(challengeId: string, ...activityIds: string[]): Promise { - let set = this.houndActivityIds.get(challengeId); - if (!set) { - set = [] - this.houndActivityIds.set(challengeId, set); - } - set.unshift(...activityIds); - while (set.length > MAX_FEED_ITEMS) { - set.pop(); - } - } - - async hasSeenHoundActivity(challengeId: string, ...activityIds: string[]): Promise { - const existing = this.houndActivityIds.get(challengeId); - return existing ? activityIds.filter((existingGuid) => existing.includes(existingGuid)) : []; - } - - public async hasSeenHoundChallenge(challengeId: string): Promise { - return this.houndActivityIds.has(challengeId); - } - - public async storeHoundActivityEvent(challengeId: string, activityId: string, eventId: string): Promise { - this.houndActivityIdToEvent.set(`${challengeId}.${activityId}`, eventId); - } - - public async getHoundActivity(challengeId: string, activityId: string): Promise { - return this.houndActivityIdToEvent.get(`${challengeId}.${activityId}`) ?? null; - } - - public async getHasGenericHookWarnedExpiry(hookId: string): Promise { - return this.hasGenericHookWarnedExpiry.has(hookId); - } - - public async setHasGenericHookWarnedExpiry(hookId: string, hasWarned: boolean): Promise { - this.hasGenericHookWarnedExpiry[hasWarned ? "add" : "delete"](hookId); - } - - public async getOpenProjectWorkPackageState(projectId: number, workPackageId: number): Promise { - return this.openProjectWorkPackageState.get(`${projectId}:${workPackageId}`) ?? null; - } - - public async setOpenProjectWorkPackageState(state: OpenProjectWorkPackageCacheState, id: number){ - this.openProjectWorkPackageState.set(`${state.project}:${id}`, state); - } - +export class MemoryStorageProvider + extends MSP + implements IBridgeStorageProvider +{ + private issues: Map = new Map(); + private issuesLastComment: Map = new Map(); + private reviewData: Map = new Map(); + private figmaCommentIds: Map = new Map(); + private widgetSessions: Map = new Map(); + private storedFiles = new QuickLRU({ maxSize: 128 }); + private gitlabDiscussionThreads = new Map< + string, + SerializedGitlabDiscussionThreads + >(); + private feedGuids = new Map>(); + private houndActivityIds = new Map>(); + private houndActivityIdToEvent = new Map(); + private hasGenericHookWarnedExpiry = new Set(); + private readonly openProjectWorkPackageState = new Map< + string, + OpenProjectWorkPackageCacheState + >(); + + constructor() { + super(); + } + + async storeFeedGuids(url: string, ...guids: string[]): Promise { + let set = this.feedGuids.get(url); + if (!set) { + set = []; + this.feedGuids.set(url, set); + } + set.unshift(...guids); + while (set.length > MAX_FEED_ITEMS) { + set.pop(); + } + } + + async hasSeenFeed(url: string): Promise { + return this.feedGuids.has(url); + } + + async hasSeenFeedGuids(url: string, ...guids: string[]): Promise { + const existing = this.feedGuids.get(url); + return existing + ? guids.filter((existingGuid) => existing.includes(existingGuid)) + : []; + } + + public async setGithubIssue( + repo: string, + issueNumber: string, + data: IssuesGetResponseData, + scope = "", + ) { + this.issues.set(`${scope}${repo}/${issueNumber}`, data); + } + + public async getGithubIssue(repo: string, issueNumber: string, scope = "") { + return this.issues.get(`${scope}${repo}/${issueNumber}`) || null; + } + + public async setLastNotifCommentUrl( + repo: string, + issueNumber: string, + url: string, + scope = "", + ) { + this.issuesLastComment.set(`${scope}${repo}/${issueNumber}`, url); + } + + public async getLastNotifCommentUrl( + repo: string, + issueNumber: string, + scope = "", + ) { + return this.issuesLastComment.get(`${scope}${repo}/${issueNumber}`) || null; + } + + public async setPRReviewData( + repo: string, + issueNumber: string, + data: any, + scope = "", + ) { + const key = `${scope}:${repo}/${issueNumber}`; + this.reviewData.set(key, data); + } + + public async getPRReviewData(repo: string, issueNumber: string, scope = "") { + const key = `${scope}:${repo}/${issueNumber}`; + return this.reviewData.get(key) || null; + } + + private static figmaCommentKey(roomId: string, figmaCommentId: string) { + return `${roomId}:${figmaCommentId}`; + } + + public async setFigmaCommentEventId( + roomId: string, + figmaCommentId: string, + eventId: string, + ) { + this.figmaCommentIds.set( + MemoryStorageProvider.figmaCommentKey(roomId, figmaCommentId), + eventId, + ); + } + + public async getFigmaCommentEventId(roomId: string, figmaCommentId: string) { + return ( + this.figmaCommentIds.get( + MemoryStorageProvider.figmaCommentKey(roomId, figmaCommentId), + ) || null + ); + } + + public async getSessionForToken(token: string) { + return this.widgetSessions.get(token) || null; + } + public async createSession(session: ProvisionSession) { + this.widgetSessions.set(session.token, session); + } + public async deleteSession(token: string) { + this.widgetSessions.delete(token); + } + public async deleteAllSessions(userId: string) { + [...this.widgetSessions.values()] + .filter((s) => s.userId === userId) + .forEach((s) => this.widgetSessions.delete(s.token)); + } + + public async getStoredTempFile(key: string): Promise { + return this.storedFiles.get(key) ?? null; + } + + public async setStoredTempFile(key: string, value: string) { + this.storedFiles.set(key, value); + } + + public async getGitlabDiscussionThreads( + connectionId: string, + ): Promise { + return this.gitlabDiscussionThreads.get(connectionId) ?? []; + } + + public async setGitlabDiscussionThreads( + connectionId: string, + value: SerializedGitlabDiscussionThreads, + ): Promise { + this.gitlabDiscussionThreads.set(connectionId, value); + } + + async storeHoundActivity( + challengeId: string, + ...activityIds: string[] + ): Promise { + let set = this.houndActivityIds.get(challengeId); + if (!set) { + set = []; + this.houndActivityIds.set(challengeId, set); + } + set.unshift(...activityIds); + while (set.length > MAX_FEED_ITEMS) { + set.pop(); + } + } + + async hasSeenHoundActivity( + challengeId: string, + ...activityIds: string[] + ): Promise { + const existing = this.houndActivityIds.get(challengeId); + return existing + ? activityIds.filter((existingGuid) => existing.includes(existingGuid)) + : []; + } + + public async hasSeenHoundChallenge(challengeId: string): Promise { + return this.houndActivityIds.has(challengeId); + } + + public async storeHoundActivityEvent( + challengeId: string, + activityId: string, + eventId: string, + ): Promise { + this.houndActivityIdToEvent.set(`${challengeId}.${activityId}`, eventId); + } + + public async getHoundActivity( + challengeId: string, + activityId: string, + ): Promise { + return ( + this.houndActivityIdToEvent.get(`${challengeId}.${activityId}`) ?? null + ); + } + + public async getHasGenericHookWarnedExpiry(hookId: string): Promise { + return this.hasGenericHookWarnedExpiry.has(hookId); + } + + public async setHasGenericHookWarnedExpiry( + hookId: string, + hasWarned: boolean, + ): Promise { + this.hasGenericHookWarnedExpiry[hasWarned ? "add" : "delete"](hookId); + } + + public async getOpenProjectWorkPackageState( + projectId: number, + workPackageId: number, + ): Promise { + return ( + this.openProjectWorkPackageState.get(`${projectId}:${workPackageId}`) ?? + null + ); + } + + public async setOpenProjectWorkPackageState( + state: OpenProjectWorkPackageCacheState, + id: number, + ) { + this.openProjectWorkPackageState.set(`${state.project}:${id}`, state); + } } diff --git a/src/Stores/RedisStorageProvider.ts b/src/Stores/RedisStorageProvider.ts index 7ef541e87..f55bdae24 100644 --- a/src/Stores/RedisStorageProvider.ts +++ b/src/Stores/RedisStorageProvider.ts @@ -26,7 +26,6 @@ const ISSUES_EXPIRE_AFTER = 7 * 24 * 60 * 60; // 7 days const ISSUES_LAST_COMMENT_EXPIRE_AFTER = 14 * 24 * 60 * 60; // 7 days const HOUND_EVENT_CACHE = 90 * 24 * 60 * 60; // 90 days - const WIDGET_TOKENS = "widgets.tokens."; const WIDGET_USER_TOKENS = "widgets.user-tokens."; @@ -41,269 +40,359 @@ const OPENPROJECT_WORKPACKAGE_KEY = "openproject.workpackagecache."; const log = new Logger("RedisASProvider"); export class RedisStorageContextualProvider implements IStorageProvider { - - constructor(protected readonly redis: Redis, protected readonly contextSuffix = '') { } - - public setSyncToken(token: string|null){ - if (token === null) { - this.redis.del(BOT_SYNC_TOKEN_KEY + this.contextSuffix); - } else { - this.redis.set(BOT_SYNC_TOKEN_KEY + this.contextSuffix, token); - } - } - - public getSyncToken() { - return this.redis.get(BOT_SYNC_TOKEN_KEY + this.contextSuffix); - } - - public setFilter(filter: IFilterInfo) { - this.redis.set(BOT_FILTER_KEY + this.contextSuffix, JSON.stringify(filter)); - } - - public async getFilter() { - const value = await this.redis.get(BOT_FILTER_KEY + this.contextSuffix); - return value && JSON.parse(value); - } - - public storeValue(key: string, value: string) { - this.redis.set(`${BOT_VALUE_KEY}${this.contextSuffix}.${key}`, value); - } - - public readValue(key: string) { - return this.redis.get(`${BOT_VALUE_KEY}${this.contextSuffix}.${key}`); - } - + constructor( + protected readonly redis: Redis, + protected readonly contextSuffix = "", + ) {} + + public setSyncToken(token: string | null) { + if (token === null) { + this.redis.del(BOT_SYNC_TOKEN_KEY + this.contextSuffix); + } else { + this.redis.set(BOT_SYNC_TOKEN_KEY + this.contextSuffix, token); + } + } + + public getSyncToken() { + return this.redis.get(BOT_SYNC_TOKEN_KEY + this.contextSuffix); + } + + public setFilter(filter: IFilterInfo) { + this.redis.set(BOT_FILTER_KEY + this.contextSuffix, JSON.stringify(filter)); + } + + public async getFilter() { + const value = await this.redis.get(BOT_FILTER_KEY + this.contextSuffix); + return value && JSON.parse(value); + } + + public storeValue(key: string, value: string) { + this.redis.set(`${BOT_VALUE_KEY}${this.contextSuffix}.${key}`, value); + } + + public readValue(key: string) { + return this.redis.get(`${BOT_VALUE_KEY}${this.contextSuffix}.${key}`); + } } - -export class RedisStorageProvider extends RedisStorageContextualProvider implements IBridgeStorageProvider { - constructor(cacheConfig: BridgeConfigCache, contextSuffix = '') { - super(new redis(cacheConfig.redisUri), contextSuffix); - this.redis.expire(COMPLETED_TRANSACTIONS_KEY, COMPLETED_TRANSACTIONS_EXPIRE_AFTER).catch((ex) => { - log.warn("Failed to set expiry time on as.completed_transactions", ex); - }); - this.redis.expire(STORED_FILES_KEY, STORED_FILES_EXPIRE_AFTER).catch((ex) => { - log.warn(`Failed to set expiry time on ${STORED_FILES_KEY}`, ex); - }); - } - - public async connect(): Promise { - try { - await this.redis.ping(); - } catch (ex) { - log.error('Could not ping the redis instance, is it reachable?'); - throw ex; - } - log.info("Successfully connected"); - try { - await this.redis.expire(COMPLETED_TRANSACTIONS_KEY, COMPLETED_TRANSACTIONS_EXPIRE_AFTER); - } catch (ex) { - log.warn("Failed to set expiry time on as.completed_transactions", ex); - } - } - - public async disconnect(): Promise { - await this.redis.quit(); - } - - public async addRegisteredUser(userId: string) { - await this.redis.sadd(REGISTERED_USERS_KEY, [userId]); - } - - public async isUserRegistered(userId: string): Promise { - return (await this.redis.sismember(REGISTERED_USERS_KEY, userId)) === 1; - } - - public async setTransactionCompleted(transactionId: string) { - await this.redis.sadd(COMPLETED_TRANSACTIONS_KEY, [transactionId]); - } - - public async isTransactionCompleted(transactionId: string): Promise { - return (await this.redis.sismember(COMPLETED_TRANSACTIONS_KEY, transactionId)) === 1; - } - - public async setGithubIssue(repo: string, issueNumber: string, data: IssuesGetResponseData, scope = "") { - const key = `${scope}${GH_ISSUES_KEY}:${repo}/${issueNumber}`; - await this.redis.set(key, JSON.stringify(data)); - await this.redis.expire(key, ISSUES_EXPIRE_AFTER); - } - - public async getGithubIssue(repo: string, issueNumber: string, scope = "") { - const res = await this.redis.get(`${scope}:${GH_ISSUES_KEY}:${repo}/${issueNumber}`); - return res ? JSON.parse(res) : null; - } - - public async setLastNotifCommentUrl(repo: string, issueNumber: string, url: string, scope = "") { - const key = `${scope}${GH_ISSUES_LAST_COMMENT_KEY}:${repo}/${issueNumber}`; - await this.redis.set(key, url); - await this.redis.expire(key, ISSUES_LAST_COMMENT_EXPIRE_AFTER); - } - - public async getLastNotifCommentUrl(repo: string, issueNumber: string, scope = "") { - const res = await this.redis.get(`${scope}:${GH_ISSUES_LAST_COMMENT_KEY}:${repo}/${issueNumber}`); - return res ? res : null; - } - - public async setPRReviewData(repo: string, issueNumber: string, url: string, scope = "") { - const key = `${scope}${GH_ISSUES_REVIEW_DATA_KEY}:${repo}/${issueNumber}`; - await this.redis.set(key, url); - await this.redis.expire(key, ISSUES_LAST_COMMENT_EXPIRE_AFTER); - } - - public async getPRReviewData(repo: string, issueNumber: string, scope = "") { - const res = await this.redis.get(`${scope}:${GH_ISSUES_REVIEW_DATA_KEY}:${repo}/${issueNumber}`); - return res ? res : null; - } - - private static figmaCommentKey(roomId: string, figmaCommentId: string) { - return `${FIGMA_EVENT_COMMENT_ID}:${roomId}:${figmaCommentId}`; - } - - public async setFigmaCommentEventId(roomId: string, figmaCommentId: string, eventId: string) { - await this.redis.set(RedisStorageProvider.figmaCommentKey(roomId, figmaCommentId), eventId); - } - - public async getFigmaCommentEventId(roomId: string, figmaCommentId: string) { - return this.redis.get(RedisStorageProvider.figmaCommentKey(roomId, figmaCommentId)); - } - - // Widget API sessions - - async getSessionForToken(token: string) { - const json = await this.redis.get(`${WIDGET_TOKENS}${token}`); - if (json) { - return { - ...JSON.parse(json), - token, - } as ProvisionSession; - } - return null; - } - - async createSession(session: ProvisionSession) { - await this.redis.set(`${WIDGET_TOKENS}${session.token}`, JSON.stringify({ userId: session.userId, expiresTs: session.expiresTs })); - await this.redis.sadd(`${WIDGET_USER_TOKENS}${session.userId}`, session.token); - } - - async deleteSession(token: string) { - await this.redis.del(`${WIDGET_TOKENS}${token}`); - await this.redis.srem(`${WIDGET_USER_TOKENS}${token}`, token); - } - - async deleteAllSessions(userId: string) { - let token = await this.redis.spop(`${WIDGET_USER_TOKENS}${userId}`); - while (token) { - await this.redis.del(`${WIDGET_TOKENS}${token}`); - token = await this.redis.spop(`${WIDGET_USER_TOKENS}${userId}`); - } - } - - storageForUser(userId: string) { - const newContext = [userId]; - if (this.contextSuffix) { - newContext.push(this.contextSuffix); - } - return new RedisStorageContextualProvider(this.redis, newContext.join(".")); - } - - public async getStoredTempFile(key: string) { - return this.redis.get(STORED_FILES_KEY + key); - } - - public async setStoredTempFile(key: string, value: string) { - await this.redis.set(STORED_FILES_KEY + key, value); - } - - public async getGitlabDiscussionThreads(connectionId: string): Promise { - const key = `${GL_DISCUSSIONTHREADS_KEY}:${connectionId}`; - return JSON.parse(await this.redis.get(key) ?? '[]'); - } - - public async setGitlabDiscussionThreads(connectionId: string, value: SerializedGitlabDiscussionThreads): Promise { - const key = `${GL_DISCUSSIONTHREADS_KEY}:${connectionId}`; - await this.redis.set(key, JSON.stringify(value)); - } - - public async storeFeedGuids(url: string, ...guids: string[]): Promise { - const feedKey = `${FEED_GUIDS}${url}`; - await this.redis.lpush(feedKey, ...guids); - await this.redis.ltrim(feedKey, 0, MAX_FEED_ITEMS); - } - - public async hasSeenFeed(url: string): Promise { - return (await this.redis.exists(`${FEED_GUIDS}${url}`)) === 1; - } - - public async hasSeenFeedGuids(url: string, ...guids: string[]): Promise { - let multi = this.redis.multi(); - const feedKey = `${FEED_GUIDS}${url}`; - - for (const guid of guids) { - multi = multi.lpos(feedKey, guid); - } - const res = await multi.exec(); - if (res === null) { - // Just assume we've seen none. - return []; - } - return guids.filter((_guid, index) => res[index][1] !== null); - } - - public async storeHoundActivity(challengeId: string, ...activityHashes: string[]): Promise { - if (activityHashes.length === 0) { - return; - } - const key = `${HOUND_GUIDS}${challengeId}`; - await this.redis.lpush(key, ...activityHashes); - await this.redis.ltrim(key, 0, MAX_FEED_ITEMS); - } - - public async hasSeenHoundChallenge(challengeId: string): Promise { - const key = `${HOUND_GUIDS}${challengeId}`; - return (await this.redis.exists(key)) === 1; - } - - public async hasSeenHoundActivity(challengeId: string, ...activityHashes: string[]): Promise { - let multi = this.redis.multi(); - const key = `${HOUND_GUIDS}${challengeId}`; - - for (const guid of activityHashes) { - multi = multi.lpos(key, guid); - } - const res = await multi.exec(); - if (res === null) { - // Just assume we've seen none. - return []; - } - return activityHashes.filter((_guid, index) => res[index][1] !== null); - } - - public async storeHoundActivityEvent(challengeId: string, activityId: string, eventId: string): Promise { - const key = `${HOUND_EVENTS}${challengeId}.${activityId}`; - await this.redis.set(key, eventId); - this.redis.expire(key, HOUND_EVENT_CACHE).catch((ex) => { - log.warn(`Failed to set expiry time on ${key}`, ex); - }); - } - - public async getHoundActivity(challengeId: string, activityId: string): Promise { - return this.redis.get(`${HOUND_EVENTS}${challengeId}.${activityId}`); - } - - public async getHasGenericHookWarnedExpiry(hookId: string): Promise { - return await this.redis.sismember(GENERIC_HOOK_HAS_WARNED, hookId) === 1; - } - - public async setHasGenericHookWarnedExpiry(hookId: string, hasWarned: boolean): Promise { - await this.redis[hasWarned ? "sadd" : "srem"](GENERIC_HOOK_HAS_WARNED, hookId); - } - - public async getOpenProjectWorkPackageState(projectId: number, id: number): Promise { - const jsonStr = await this.redis.get(`${OPENPROJECT_WORKPACKAGE_KEY}:${projectId}.${id}`); - return jsonStr && JSON.parse(jsonStr); - } - public async setOpenProjectWorkPackageState(state: OpenProjectWorkPackageCacheState, id: number) { - const key = `${OPENPROJECT_WORKPACKAGE_KEY}:${state.project}.${id}`; - await this.redis.set(key, JSON.stringify(state)); - } +export class RedisStorageProvider + extends RedisStorageContextualProvider + implements IBridgeStorageProvider +{ + constructor(cacheConfig: BridgeConfigCache, contextSuffix = "") { + super(new redis(cacheConfig.redisUri), contextSuffix); + this.redis + .expire(COMPLETED_TRANSACTIONS_KEY, COMPLETED_TRANSACTIONS_EXPIRE_AFTER) + .catch((ex) => { + log.warn("Failed to set expiry time on as.completed_transactions", ex); + }); + this.redis + .expire(STORED_FILES_KEY, STORED_FILES_EXPIRE_AFTER) + .catch((ex) => { + log.warn(`Failed to set expiry time on ${STORED_FILES_KEY}`, ex); + }); + } + + public async connect(): Promise { + try { + await this.redis.ping(); + } catch (ex) { + log.error("Could not ping the redis instance, is it reachable?"); + throw ex; + } + log.info("Successfully connected"); + try { + await this.redis.expire( + COMPLETED_TRANSACTIONS_KEY, + COMPLETED_TRANSACTIONS_EXPIRE_AFTER, + ); + } catch (ex) { + log.warn("Failed to set expiry time on as.completed_transactions", ex); + } + } + + public async disconnect(): Promise { + await this.redis.quit(); + } + + public async addRegisteredUser(userId: string) { + await this.redis.sadd(REGISTERED_USERS_KEY, [userId]); + } + + public async isUserRegistered(userId: string): Promise { + return (await this.redis.sismember(REGISTERED_USERS_KEY, userId)) === 1; + } + + public async setTransactionCompleted(transactionId: string) { + await this.redis.sadd(COMPLETED_TRANSACTIONS_KEY, [transactionId]); + } + + public async isTransactionCompleted(transactionId: string): Promise { + return ( + (await this.redis.sismember( + COMPLETED_TRANSACTIONS_KEY, + transactionId, + )) === 1 + ); + } + + public async setGithubIssue( + repo: string, + issueNumber: string, + data: IssuesGetResponseData, + scope = "", + ) { + const key = `${scope}${GH_ISSUES_KEY}:${repo}/${issueNumber}`; + await this.redis.set(key, JSON.stringify(data)); + await this.redis.expire(key, ISSUES_EXPIRE_AFTER); + } + + public async getGithubIssue(repo: string, issueNumber: string, scope = "") { + const res = await this.redis.get( + `${scope}:${GH_ISSUES_KEY}:${repo}/${issueNumber}`, + ); + return res ? JSON.parse(res) : null; + } + + public async setLastNotifCommentUrl( + repo: string, + issueNumber: string, + url: string, + scope = "", + ) { + const key = `${scope}${GH_ISSUES_LAST_COMMENT_KEY}:${repo}/${issueNumber}`; + await this.redis.set(key, url); + await this.redis.expire(key, ISSUES_LAST_COMMENT_EXPIRE_AFTER); + } + + public async getLastNotifCommentUrl( + repo: string, + issueNumber: string, + scope = "", + ) { + const res = await this.redis.get( + `${scope}:${GH_ISSUES_LAST_COMMENT_KEY}:${repo}/${issueNumber}`, + ); + return res ? res : null; + } + + public async setPRReviewData( + repo: string, + issueNumber: string, + url: string, + scope = "", + ) { + const key = `${scope}${GH_ISSUES_REVIEW_DATA_KEY}:${repo}/${issueNumber}`; + await this.redis.set(key, url); + await this.redis.expire(key, ISSUES_LAST_COMMENT_EXPIRE_AFTER); + } + + public async getPRReviewData(repo: string, issueNumber: string, scope = "") { + const res = await this.redis.get( + `${scope}:${GH_ISSUES_REVIEW_DATA_KEY}:${repo}/${issueNumber}`, + ); + return res ? res : null; + } + + private static figmaCommentKey(roomId: string, figmaCommentId: string) { + return `${FIGMA_EVENT_COMMENT_ID}:${roomId}:${figmaCommentId}`; + } + + public async setFigmaCommentEventId( + roomId: string, + figmaCommentId: string, + eventId: string, + ) { + await this.redis.set( + RedisStorageProvider.figmaCommentKey(roomId, figmaCommentId), + eventId, + ); + } + + public async getFigmaCommentEventId(roomId: string, figmaCommentId: string) { + return this.redis.get( + RedisStorageProvider.figmaCommentKey(roomId, figmaCommentId), + ); + } + + // Widget API sessions + + async getSessionForToken(token: string) { + const json = await this.redis.get(`${WIDGET_TOKENS}${token}`); + if (json) { + return { + ...JSON.parse(json), + token, + } as ProvisionSession; + } + return null; + } + + async createSession(session: ProvisionSession) { + await this.redis.set( + `${WIDGET_TOKENS}${session.token}`, + JSON.stringify({ userId: session.userId, expiresTs: session.expiresTs }), + ); + await this.redis.sadd( + `${WIDGET_USER_TOKENS}${session.userId}`, + session.token, + ); + } + + async deleteSession(token: string) { + await this.redis.del(`${WIDGET_TOKENS}${token}`); + await this.redis.srem(`${WIDGET_USER_TOKENS}${token}`, token); + } + + async deleteAllSessions(userId: string) { + let token = await this.redis.spop(`${WIDGET_USER_TOKENS}${userId}`); + while (token) { + await this.redis.del(`${WIDGET_TOKENS}${token}`); + token = await this.redis.spop(`${WIDGET_USER_TOKENS}${userId}`); + } + } + + storageForUser(userId: string) { + const newContext = [userId]; + if (this.contextSuffix) { + newContext.push(this.contextSuffix); + } + return new RedisStorageContextualProvider(this.redis, newContext.join(".")); + } + + public async getStoredTempFile(key: string) { + return this.redis.get(STORED_FILES_KEY + key); + } + + public async setStoredTempFile(key: string, value: string) { + await this.redis.set(STORED_FILES_KEY + key, value); + } + + public async getGitlabDiscussionThreads( + connectionId: string, + ): Promise { + const key = `${GL_DISCUSSIONTHREADS_KEY}:${connectionId}`; + return JSON.parse((await this.redis.get(key)) ?? "[]"); + } + + public async setGitlabDiscussionThreads( + connectionId: string, + value: SerializedGitlabDiscussionThreads, + ): Promise { + const key = `${GL_DISCUSSIONTHREADS_KEY}:${connectionId}`; + await this.redis.set(key, JSON.stringify(value)); + } + + public async storeFeedGuids(url: string, ...guids: string[]): Promise { + const feedKey = `${FEED_GUIDS}${url}`; + await this.redis.lpush(feedKey, ...guids); + await this.redis.ltrim(feedKey, 0, MAX_FEED_ITEMS); + } + + public async hasSeenFeed(url: string): Promise { + return (await this.redis.exists(`${FEED_GUIDS}${url}`)) === 1; + } + + public async hasSeenFeedGuids( + url: string, + ...guids: string[] + ): Promise { + let multi = this.redis.multi(); + const feedKey = `${FEED_GUIDS}${url}`; + + for (const guid of guids) { + multi = multi.lpos(feedKey, guid); + } + const res = await multi.exec(); + if (res === null) { + // Just assume we've seen none. + return []; + } + return guids.filter((_guid, index) => res[index][1] !== null); + } + + public async storeHoundActivity( + challengeId: string, + ...activityHashes: string[] + ): Promise { + if (activityHashes.length === 0) { + return; + } + const key = `${HOUND_GUIDS}${challengeId}`; + await this.redis.lpush(key, ...activityHashes); + await this.redis.ltrim(key, 0, MAX_FEED_ITEMS); + } + + public async hasSeenHoundChallenge(challengeId: string): Promise { + const key = `${HOUND_GUIDS}${challengeId}`; + return (await this.redis.exists(key)) === 1; + } + + public async hasSeenHoundActivity( + challengeId: string, + ...activityHashes: string[] + ): Promise { + let multi = this.redis.multi(); + const key = `${HOUND_GUIDS}${challengeId}`; + + for (const guid of activityHashes) { + multi = multi.lpos(key, guid); + } + const res = await multi.exec(); + if (res === null) { + // Just assume we've seen none. + return []; + } + return activityHashes.filter((_guid, index) => res[index][1] !== null); + } + + public async storeHoundActivityEvent( + challengeId: string, + activityId: string, + eventId: string, + ): Promise { + const key = `${HOUND_EVENTS}${challengeId}.${activityId}`; + await this.redis.set(key, eventId); + this.redis.expire(key, HOUND_EVENT_CACHE).catch((ex) => { + log.warn(`Failed to set expiry time on ${key}`, ex); + }); + } + + public async getHoundActivity( + challengeId: string, + activityId: string, + ): Promise { + return this.redis.get(`${HOUND_EVENTS}${challengeId}.${activityId}`); + } + + public async getHasGenericHookWarnedExpiry(hookId: string): Promise { + return (await this.redis.sismember(GENERIC_HOOK_HAS_WARNED, hookId)) === 1; + } + + public async setHasGenericHookWarnedExpiry( + hookId: string, + hasWarned: boolean, + ): Promise { + await this.redis[hasWarned ? "sadd" : "srem"]( + GENERIC_HOOK_HAS_WARNED, + hookId, + ); + } + + public async getOpenProjectWorkPackageState( + projectId: number, + id: number, + ): Promise { + const jsonStr = await this.redis.get( + `${OPENPROJECT_WORKPACKAGE_KEY}:${projectId}.${id}`, + ); + return jsonStr && JSON.parse(jsonStr); + } + public async setOpenProjectWorkPackageState( + state: OpenProjectWorkPackageCacheState, + id: number, + ) { + const key = `${OPENPROJECT_WORKPACKAGE_KEY}:${state.project}.${id}`; + await this.redis.set(key, JSON.stringify(state)); + } } diff --git a/src/Stores/StorageProvider.ts b/src/Stores/StorageProvider.ts index 203c935b8..b9f3c0f21 100644 --- a/src/Stores/StorageProvider.ts +++ b/src/Stores/StorageProvider.ts @@ -12,36 +12,98 @@ import { OpenProjectWorkPackageCacheState } from "../openproject/state"; export const MAX_FEED_ITEMS = 10_000; export const MAX_HOUND_ITEMS = 100; +export interface IBridgeStorageProvider + extends IAppserviceStorageProvider, + IStorageProvider, + ProvisioningStore { + connect?(): Promise; + disconnect?(): Promise; + setGithubIssue( + repo: string, + issueNumber: string, + data: IssuesGetResponseData, + scope?: string, + ): Promise; + getGithubIssue( + repo: string, + issueNumber: string, + scope?: string, + ): Promise; + setLastNotifCommentUrl( + repo: string, + issueNumber: string, + url: string, + scope?: string, + ): Promise; + getLastNotifCommentUrl( + repo: string, + issueNumber: string, + scope?: string, + ): Promise; + setPRReviewData( + repo: string, + issueNumber: string, + data: unknown, + scope?: string, + ): Promise; + getPRReviewData( + repo: string, + issueNumber: string, + scope?: string, + ): Promise; + setFigmaCommentEventId( + roomId: string, + figmaCommentId: string, + eventId: string, + ): Promise; + getFigmaCommentEventId( + roomId: string, + figmaCommentId: string, + ): Promise; + getStoredTempFile(key: string): Promise; + setStoredTempFile(key: string, value: string): Promise; + getGitlabDiscussionThreads( + connectionId: string, + ): Promise; + setGitlabDiscussionThreads( + connectionId: string, + value: SerializedGitlabDiscussionThreads, + ): Promise; + storeFeedGuids(url: string, ...guids: string[]): Promise; + hasSeenFeed(url: string): Promise; + hasSeenFeedGuids(url: string, ...guids: string[]): Promise; -export interface IBridgeStorageProvider extends IAppserviceStorageProvider, IStorageProvider, ProvisioningStore { - connect?(): Promise; - disconnect?(): Promise; - setGithubIssue(repo: string, issueNumber: string, data: IssuesGetResponseData, scope?: string): Promise; - getGithubIssue(repo: string, issueNumber: string, scope?: string): Promise; - setLastNotifCommentUrl(repo: string, issueNumber: string, url: string, scope?: string): Promise; - getLastNotifCommentUrl(repo: string, issueNumber: string, scope?: string): Promise; - setPRReviewData(repo: string, issueNumber: string, data: unknown, scope?: string): Promise; - getPRReviewData(repo: string, issueNumber: string, scope?: string): Promise; - setFigmaCommentEventId(roomId: string, figmaCommentId: string, eventId: string): Promise; - getFigmaCommentEventId(roomId: string, figmaCommentId: string): Promise; - getStoredTempFile(key: string): Promise; - setStoredTempFile(key: string, value: string): Promise; - getGitlabDiscussionThreads(connectionId: string): Promise; - setGitlabDiscussionThreads(connectionId: string, value: SerializedGitlabDiscussionThreads): Promise; - storeFeedGuids(url: string, ...guids: string[]): Promise; - hasSeenFeed(url: string): Promise; - hasSeenFeedGuids(url: string, ...guids: string[]): Promise; + storeHoundActivity( + challengeId: string, + ...activityHashes: string[] + ): Promise; + hasSeenHoundChallenge(challengeId: string): Promise; + hasSeenHoundActivity( + challengeId: string, + ...activityHashes: string[] + ): Promise; + storeHoundActivityEvent( + challengeId: string, + activityId: string, + eventId: string, + ): Promise; + getHoundActivity( + challengeId: string, + activityId: string, + ): Promise; - storeHoundActivity(challengeId: string, ...activityHashes: string[]): Promise; - hasSeenHoundChallenge(challengeId: string): Promise; - hasSeenHoundActivity(challengeId: string, ...activityHashes: string[]): Promise; - storeHoundActivityEvent(challengeId: string, activityId: string, eventId: string): Promise; - getHoundActivity(challengeId: string, activityId: string): Promise; + getHasGenericHookWarnedExpiry(hookId: string): Promise; + setHasGenericHookWarnedExpiry( + hookId: string, + hasWarned: boolean, + ): Promise; - getHasGenericHookWarnedExpiry(hookId: string): Promise; - setHasGenericHookWarnedExpiry(hookId: string, hasWarned: boolean): Promise; - - getOpenProjectWorkPackageState(projectId: number, workPackageId: number): Promise; - setOpenProjectWorkPackageState(state: OpenProjectWorkPackageCacheState, id: number): Promise; - -} \ No newline at end of file + getOpenProjectWorkPackageState( + projectId: number, + workPackageId: number, + ): Promise; + setOpenProjectWorkPackageState( + state: OpenProjectWorkPackageCacheState, + id: number, + ): Promise; +} diff --git a/src/Webhooks.ts b/src/Webhooks.ts index a6ac67077..24afea20e 100644 --- a/src/Webhooks.ts +++ b/src/Webhooks.ts @@ -1,4 +1,3 @@ - /* eslint-disable camelcase */ import { BridgeConfig } from "./config/Config"; import { Router, default as express, Request, Response } from "express"; @@ -7,8 +6,16 @@ import { MessageQueue, createMessageQueue } from "./MessageQueue"; import { Logger } from "matrix-appservice-bridge"; import qs from "querystring"; import axios from "axios"; -import { IGitLabWebhookEvent, IGitLabWebhookIssueStateEvent, IGitLabWebhookMREvent, IGitLabWebhookReleaseEvent } from "./Gitlab/WebhookTypes"; -import { EmitterWebhookEvent, Webhooks as OctokitWebhooks } from "@octokit/webhooks" +import { + IGitLabWebhookEvent, + IGitLabWebhookIssueStateEvent, + IGitLabWebhookMREvent, + IGitLabWebhookReleaseEvent, +} from "./Gitlab/WebhookTypes"; +import { + EmitterWebhookEvent, + Webhooks as OctokitWebhooks, +} from "@octokit/webhooks"; import { IJiraWebhookEvent } from "./jira/WebhookTypes"; import { JiraWebhooksRouter } from "./jira/Router"; import { GitHubOAuthTokenResponse } from "./github/Types"; @@ -25,319 +32,425 @@ import { OAuthRequest } from "./tokens/oauth"; const log = new Logger("Webhooks"); export interface NotificationsEnableEvent { - userId: string; - roomId: string; - since?: number; - token: string; - filterParticipating: boolean; - type: "github"|"gitlab"; - instanceUrl?: string; + userId: string; + roomId: string; + since?: number; + token: string; + filterParticipating: boolean; + type: "github" | "gitlab"; + instanceUrl?: string; } export interface NotificationsDisableEvent { - userId: string; - type: "github"|"gitlab"; - instanceUrl?: string; + userId: string; + type: "github" | "gitlab"; + instanceUrl?: string; } export interface OAuthPageParams { - service?: string; - result?: string; - 'oauth-kind'?: 'account'|'organisation'; - 'error'?: string; - 'errcode'?: ErrCode; + service?: string; + result?: string; + "oauth-kind"?: "account" | "organisation"; + error?: string; + errcode?: ErrCode; } interface GitHubRequestData { - payload: string; - signature: string; + payload: string; + signature: string; } interface WebhooksExpressRequest extends Request { - github?: GitHubRequestData; + github?: GitHubRequestData; } export class Webhooks extends EventEmitter { - - public readonly expressRouter = Router(); - private readonly queue: MessageQueue; - private readonly ghWebhooks?: OctokitWebhooks; - private readonly handledGuids = new QuickLRU({ maxAge: 5000, maxSize: 100 }); - private readonly jira?: JiraWebhooksRouter; - constructor(private config: BridgeConfig) { - super(); - this.expressRouter.use((req, _res, next) => { - Metrics.webhooksHttpRequest.inc({path: req.path, method: req.method}); - next(); - }); - if (config.github?.webhook.secret) { - this.ghWebhooks = new OctokitWebhooks({ - secret: config.github.webhook.secret, - }); - this.ghWebhooks.onAny(e => this.onGitHubPayload(e)); - } - - // TODO: Move these - this.expressRouter.get("/oauth", this.onGitHubGetOauth.bind(this)); - this.queue = createMessageQueue(config.queue); - if (this.config.jira) { - this.jira = new JiraWebhooksRouter(this.queue, this.config.jira.webhook.secret); - this.expressRouter.use("/jira", this.jira.getRouter()); - } - if (this.config.figma) { - this.expressRouter.use('/figma', new FigmaWebhooksRouter(this.config.figma, this.queue).getRouter()); - } - if (this.config.generic) { - this.expressRouter.use('/webhook', new GenericWebhooksRouter(this.queue, false, this.config.generic.enableHttpGet).getRouter()); - // TODO: Remove old deprecated endpoint - this.expressRouter.use(new GenericWebhooksRouter(this.queue, true, this.config.generic.enableHttpGet).getRouter()); - } - if (this.config.openProject) { - this.expressRouter.use('/openproject', new OpenProjectWebhooksRouter(this.config.openProject, this.queue).getRouter()); - - } - this.expressRouter.use(express.json({ - verify: this.verifyRequest.bind(this), - limit: '10mb', - })); - this.expressRouter.post("/", this.onPayload.bind(this)); + public readonly expressRouter = Router(); + private readonly queue: MessageQueue; + private readonly ghWebhooks?: OctokitWebhooks; + private readonly handledGuids = new QuickLRU({ + maxAge: 5000, + maxSize: 100, + }); + private readonly jira?: JiraWebhooksRouter; + constructor(private config: BridgeConfig) { + super(); + this.expressRouter.use((req, _res, next) => { + Metrics.webhooksHttpRequest.inc({ path: req.path, method: req.method }); + next(); + }); + if (config.github?.webhook.secret) { + this.ghWebhooks = new OctokitWebhooks({ + secret: config.github.webhook.secret, + }); + this.ghWebhooks.onAny((e) => this.onGitHubPayload(e)); } - public stop() { - if (this.queue.stop) { - this.queue.stop(); - } + // TODO: Move these + this.expressRouter.get("/oauth", this.onGitHubGetOauth.bind(this)); + this.queue = createMessageQueue(config.queue); + if (this.config.jira) { + this.jira = new JiraWebhooksRouter( + this.queue, + this.config.jira.webhook.secret, + ); + this.expressRouter.use("/jira", this.jira.getRouter()); + } + if (this.config.figma) { + this.expressRouter.use( + "/figma", + new FigmaWebhooksRouter(this.config.figma, this.queue).getRouter(), + ); + } + if (this.config.generic) { + this.expressRouter.use( + "/webhook", + new GenericWebhooksRouter( + this.queue, + false, + this.config.generic.enableHttpGet, + ).getRouter(), + ); + // TODO: Remove old deprecated endpoint + this.expressRouter.use( + new GenericWebhooksRouter( + this.queue, + true, + this.config.generic.enableHttpGet, + ).getRouter(), + ); + } + if (this.config.openProject) { + this.expressRouter.use( + "/openproject", + new OpenProjectWebhooksRouter( + this.config.openProject, + this.queue, + ).getRouter(), + ); } + this.expressRouter.use( + express.json({ + verify: this.verifyRequest.bind(this), + limit: "10mb", + }), + ); + this.expressRouter.post("/", this.onPayload.bind(this)); + } - private onGitLabPayload(body: IGitLabWebhookEvent) { - if (body.object_kind === "merge_request") { - const action = (body as unknown as IGitLabWebhookMREvent).object_attributes.action; - if (!action) { - log.warn("Got gitlab.merge_request but no action field, which usually means someone pressed the test webhooks button."); - return null; - } - return `gitlab.merge_request.${action}`; - } else if (body.object_kind === "issue") { - const action = (body as unknown as IGitLabWebhookIssueStateEvent).object_attributes.action; - if (!action) { - log.warn("Got gitlab.issue but no action field, which usually means someone pressed the test webhooks button."); - return null; - } - return `gitlab.issue.${action}`; - } else if (body.object_kind === "note") { - return `gitlab.note.created`; - } else if (body.object_kind === "tag_push") { - return "gitlab.tag_push"; - } else if (body.object_kind === "wiki_page") { - return "gitlab.wiki_page"; - } else if (body.object_kind === "release") { - const action = (body as unknown as IGitLabWebhookReleaseEvent).action; - if (!action) { - log.warn("Got gitlab.release but no action field, which usually means someone pressed the test webhooks button."); - return null; - } - return `gitlab.release.${action}`; - } else if (body.object_kind === "push") { - return `gitlab.push`; - } else { - return null; - } + public stop() { + if (this.queue.stop) { + this.queue.stop(); } + } - private onJiraPayload(body: IJiraWebhookEvent) { - body.webhookEvent = body.webhookEvent.replace("jira:", ""); - log.debug(`onJiraPayload ${body.webhookEvent}:`, body); - return `jira.${body.webhookEvent}`; + private onGitLabPayload(body: IGitLabWebhookEvent) { + if (body.object_kind === "merge_request") { + const action = (body as unknown as IGitLabWebhookMREvent) + .object_attributes.action; + if (!action) { + log.warn( + "Got gitlab.merge_request but no action field, which usually means someone pressed the test webhooks button.", + ); + return null; + } + return `gitlab.merge_request.${action}`; + } else if (body.object_kind === "issue") { + const action = (body as unknown as IGitLabWebhookIssueStateEvent) + .object_attributes.action; + if (!action) { + log.warn( + "Got gitlab.issue but no action field, which usually means someone pressed the test webhooks button.", + ); + return null; + } + return `gitlab.issue.${action}`; + } else if (body.object_kind === "note") { + return `gitlab.note.created`; + } else if (body.object_kind === "tag_push") { + return "gitlab.tag_push"; + } else if (body.object_kind === "wiki_page") { + return "gitlab.wiki_page"; + } else if (body.object_kind === "release") { + const action = (body as unknown as IGitLabWebhookReleaseEvent).action; + if (!action) { + log.warn( + "Got gitlab.release but no action field, which usually means someone pressed the test webhooks button.", + ); + return null; + } + return `gitlab.release.${action}`; + } else if (body.object_kind === "push") { + return `gitlab.push`; + } else { + return null; } + } - private async onGitHubPayload({id, name, payload}: EmitterWebhookEvent) { - const action = (payload as unknown as {action: string|undefined}).action; - const eventName = `github.${name}${action ? `.${action}` : ""}`; - log.debug(`Got GitHub webhook event ${id} ${eventName}`, payload); - try { - await this.queue.push({ - eventName, - sender: "Webhooks", - data: payload, - }); - } catch (err) { - log.error(`Failed to emit payload ${id}: ${err}`); - } + private onJiraPayload(body: IJiraWebhookEvent) { + body.webhookEvent = body.webhookEvent.replace("jira:", ""); + log.debug(`onJiraPayload ${body.webhookEvent}:`, body); + return `jira.${body.webhookEvent}`; + } + + private async onGitHubPayload({ id, name, payload }: EmitterWebhookEvent) { + const action = (payload as unknown as { action: string | undefined }) + .action; + const eventName = `github.${name}${action ? `.${action}` : ""}`; + log.debug(`Got GitHub webhook event ${id} ${eventName}`, payload); + try { + await this.queue.push({ + eventName, + sender: "Webhooks", + data: payload, + }); + } catch (err) { + log.error(`Failed to emit payload ${id}: ${err}`); } + } - private onPayload(req: WebhooksExpressRequest, res: Response) { - try { - let eventName: string|null = null; - const body = req.body; - const githubGuid = req.headers['x-github-delivery'] as string|undefined; - if (githubGuid) { - if (!this.ghWebhooks) { - log.warn(`Not configured for GitHub webhooks, but got a GitHub event`) - res.sendStatus(500); - return; - } - res.sendStatus(200); - if (this.handledGuids.has(githubGuid)) { - return; - } - this.handledGuids.set(githubGuid); - const githubData = req.github as GitHubRequestData; - if (!githubData) { - throw Error('Expected github data to be set on request'); - } - this.ghWebhooks.verifyAndReceive({ - id: githubGuid as string, - name: req.headers["x-github-event"] as WebhookEventName, - payload: githubData.payload, - signature: githubData.signature, - }).catch((err) => { - log.error(`Failed handle GitHubEvent: ${err}`); - }); - return; - } else if (req.headers['x-gitlab-token']) { - res.sendStatus(200); - eventName = this.onGitLabPayload(body); - } else if (JiraWebhooksRouter.IsJIRARequest(req)) { - res.sendStatus(200); - eventName = this.onJiraPayload(body); - } - if (eventName) { - this.queue.push({ - eventName, - sender: "GithubWebhooks", - data: body, - }).catch((err) => { - log.error(`Failed to emit payload: ${err}`); - }); - } else { - log.debug("Unknown event:", req.body); - throw new ApiError("Unable to handle webhook payload. Service may not be configured.", ErrCode.Unroutable); - } - } catch (ex) { - if (ex instanceof ApiError) { - throw ex; - } - log.error("Failed to emit message", ex); - throw new ApiError("Unknown error handling webhook.", ErrCode.Unknown); + private onPayload(req: WebhooksExpressRequest, res: Response) { + try { + let eventName: string | null = null; + const body = req.body; + const githubGuid = req.headers["x-github-delivery"] as string | undefined; + if (githubGuid) { + if (!this.ghWebhooks) { + log.warn( + `Not configured for GitHub webhooks, but got a GitHub event`, + ); + res.sendStatus(500); + return; + } + res.sendStatus(200); + if (this.handledGuids.has(githubGuid)) { + return; } + this.handledGuids.set(githubGuid); + const githubData = req.github as GitHubRequestData; + if (!githubData) { + throw Error("Expected github data to be set on request"); + } + this.ghWebhooks + .verifyAndReceive({ + id: githubGuid as string, + name: req.headers["x-github-event"] as WebhookEventName, + payload: githubData.payload, + signature: githubData.signature, + }) + .catch((err) => { + log.error(`Failed handle GitHubEvent: ${err}`); + }); + return; + } else if (req.headers["x-gitlab-token"]) { + res.sendStatus(200); + eventName = this.onGitLabPayload(body); + } else if (JiraWebhooksRouter.IsJIRARequest(req)) { + res.sendStatus(200); + eventName = this.onJiraPayload(body); + } + if (eventName) { + this.queue + .push({ + eventName, + sender: "GithubWebhooks", + data: body, + }) + .catch((err) => { + log.error(`Failed to emit payload: ${err}`); + }); + } else { + log.debug("Unknown event:", req.body); + throw new ApiError( + "Unable to handle webhook payload. Service may not be configured.", + ErrCode.Unroutable, + ); + } + } catch (ex) { + if (ex instanceof ApiError) { + throw ex; + } + log.error("Failed to emit message", ex); + throw new ApiError("Unknown error handling webhook.", ErrCode.Unknown); } + } - public async onGitHubGetOauth(req: Request , res: Response) { - const oauthResultParams: OAuthPageParams = { - service: "github" - }; + public async onGitHubGetOauth( + req: Request< + unknown, + unknown, + unknown, + { + error?: string; + error_description?: string; + code?: string; + state?: string; + setup_action?: "install"; + } + >, + res: Response, + ) { + const oauthResultParams: OAuthPageParams = { + service: "github", + }; - const { setup_action, state } = req.query; - log.info("Got new oauth request", { state, setup_action }); - try { - if (!this.config.github || !this.config.github.oauth) { - throw new ApiError('Bridge is not configured with OAuth support', ErrCode.DisabledFeature); - } - if (req.query.error) { - throw new ApiError(`GitHub Error: ${req.query.error} ${req.query.error_description}`, ErrCode.Unknown); - } - if(setup_action === 'install') { - // GitHub App successful install. - oauthResultParams["oauth-kind"] = 'organisation'; - oauthResultParams.result = "success"; - } else if (setup_action === 'request') { - // GitHub App install is pending - oauthResultParams["oauth-kind"] = 'organisation'; - oauthResultParams.result = "pending"; - } else if (setup_action) { - // GitHub App install is in another, unknown state. - oauthResultParams["oauth-kind"] = 'organisation'; - oauthResultParams.result = setup_action; - } - else { - // This is a user account setup flow. - oauthResultParams['oauth-kind'] = "account"; - if (!state) { - throw new ApiError(`Missing state`, ErrCode.BadValue); - } - if (!req.query.code) { - throw new ApiError(`Missing code`, ErrCode.BadValue); - } - const exists = await this.queue.pushWait({ - eventName: "github.oauth.response", - sender: "GithubWebhooks", - data: { - state, - code: req.query.code, - }, - }); - if (!exists) { - throw new ApiError(`Could not find user which authorised this request. Has it timed out?`, undefined, 404); - } - const accessTokenUrl = GithubInstance.generateOAuthUrl(this.config.github.baseUrl, "access_token", { - client_id: this.config.github.oauth.client_id, - client_secret: this.config.github.oauth.client_secret, - code: req.query.code as string, - redirect_uri: this.config.github.oauth.redirect_uri, - state: req.query.state as string, - }); - const accessTokenRes = await axios.post(accessTokenUrl); - const result = qs.parse(accessTokenRes.data) as GitHubOAuthTokenResponse|{error: string, error_description: string, error_uri: string}; - if ("error" in result) { - throw new ApiError(`GitHub Error: ${result.error} ${result.error_description}`, ErrCode.Unknown); - } - oauthResultParams.result = 'success'; - await this.queue.push({ - eventName: "github.oauth.tokens", - sender: "GithubWebhooks", - data: { ...result, state: req.query.state as string }, - }); - } - } catch (ex) { - if (ex instanceof ApiError) { - oauthResultParams.result = 'error'; - oauthResultParams.error = ex.error; - oauthResultParams.errcode = ex.errcode; - } else { - log.error("Failed to handle oauth request:", ex); - return res.status(500).send('Failed to handle oauth request'); - } + const { setup_action, state } = req.query; + log.info("Got new oauth request", { state, setup_action }); + try { + if (!this.config.github || !this.config.github.oauth) { + throw new ApiError( + "Bridge is not configured with OAuth support", + ErrCode.DisabledFeature, + ); + } + if (req.query.error) { + throw new ApiError( + `GitHub Error: ${req.query.error} ${req.query.error_description}`, + ErrCode.Unknown, + ); + } + if (setup_action === "install") { + // GitHub App successful install. + oauthResultParams["oauth-kind"] = "organisation"; + oauthResultParams.result = "success"; + } else if (setup_action === "request") { + // GitHub App install is pending + oauthResultParams["oauth-kind"] = "organisation"; + oauthResultParams.result = "pending"; + } else if (setup_action) { + // GitHub App install is in another, unknown state. + oauthResultParams["oauth-kind"] = "organisation"; + oauthResultParams.result = setup_action; + } else { + // This is a user account setup flow. + oauthResultParams["oauth-kind"] = "account"; + if (!state) { + throw new ApiError(`Missing state`, ErrCode.BadValue); } - const oauthUrl = this.config.widgets && new URL("oauth.html", this.config.widgets.parsedPublicUrl); - if (oauthUrl) { - // If we're serving widgets, do something prettier. - Object.entries(oauthResultParams).forEach(([key, value]) => oauthUrl.searchParams.set(key, value)); - return res.redirect(oauthUrl.toString()); - } else { - if (oauthResultParams.result === 'success') { - return res.send(`

Your ${oauthResultParams.service} ${oauthResultParams["oauth-kind"]} has been bridged

`); - } else if (oauthResultParams.result === 'error') { - return res.status(500).send(`

There was an error bridging your ${oauthResultParams.service} ${oauthResultParams["oauth-kind"]}. ${oauthResultParams.error} ${oauthResultParams.errcode}

`); - } else { - return res.status(500).send(`

Your ${oauthResultParams.service} ${oauthResultParams["oauth-kind"]} is in state ${oauthResultParams.result}

`); - } + if (!req.query.code) { + throw new ApiError(`Missing code`, ErrCode.BadValue); } + const exists = await this.queue.pushWait({ + eventName: "github.oauth.response", + sender: "GithubWebhooks", + data: { + state, + code: req.query.code, + }, + }); + if (!exists) { + throw new ApiError( + `Could not find user which authorised this request. Has it timed out?`, + undefined, + 404, + ); + } + const accessTokenUrl = GithubInstance.generateOAuthUrl( + this.config.github.baseUrl, + "access_token", + { + client_id: this.config.github.oauth.client_id, + client_secret: this.config.github.oauth.client_secret, + code: req.query.code as string, + redirect_uri: this.config.github.oauth.redirect_uri, + state: req.query.state as string, + }, + ); + const accessTokenRes = await axios.post(accessTokenUrl); + const result = qs.parse(accessTokenRes.data) as + | GitHubOAuthTokenResponse + | { error: string; error_description: string; error_uri: string }; + if ("error" in result) { + throw new ApiError( + `GitHub Error: ${result.error} ${result.error_description}`, + ErrCode.Unknown, + ); + } + oauthResultParams.result = "success"; + await this.queue.push({ + eventName: "github.oauth.tokens", + sender: "GithubWebhooks", + data: { ...result, state: req.query.state as string }, + }); + } + } catch (ex) { + if (ex instanceof ApiError) { + oauthResultParams.result = "error"; + oauthResultParams.error = ex.error; + oauthResultParams.errcode = ex.errcode; + } else { + log.error("Failed to handle oauth request:", ex); + return res.status(500).send("Failed to handle oauth request"); + } } + const oauthUrl = + this.config.widgets && + new URL("oauth.html", this.config.widgets.parsedPublicUrl); + if (oauthUrl) { + // If we're serving widgets, do something prettier. + Object.entries(oauthResultParams).forEach(([key, value]) => + oauthUrl.searchParams.set(key, value), + ); + return res.redirect(oauthUrl.toString()); + } else { + if (oauthResultParams.result === "success") { + return res.send( + `

Your ${oauthResultParams.service} ${oauthResultParams["oauth-kind"]} has been bridged

`, + ); + } else if (oauthResultParams.result === "error") { + return res + .status(500) + .send( + `

There was an error bridging your ${oauthResultParams.service} ${oauthResultParams["oauth-kind"]}. ${oauthResultParams.error} ${oauthResultParams.errcode}

`, + ); + } else { + return res + .status(500) + .send( + `

Your ${oauthResultParams.service} ${oauthResultParams["oauth-kind"]} is in state ${oauthResultParams.result}

`, + ); + } + } + } - private verifyRequest(req: WebhooksExpressRequest, _res: Response, buffer: Buffer, encoding: BufferEncoding): void { - if (this.config.gitlab && req.headers['x-gitlab-token']) { - if (req.headers['x-gitlab-token'] !== this.config.gitlab.webhook.secret) { - throw new ApiError("Could not handle GitLab request. Token did not match.", ErrCode.BadValue); - } - return; - } else if (this.ghWebhooks && req.headers["x-hub-signature-256"]) { - if (typeof req.headers["x-hub-signature-256"] !== "string") { - throw new ApiError("Could not handle GitHub request. Unexpected multiple headers for x-hub-signature-256", ErrCode.BadValue); - } - try { - const jsonStr = buffer.toString(encoding); - req.github = { - payload: jsonStr, - signature: req.headers["x-hub-signature-256"] - }; - } catch (ex) { - log.warn("GitHub signature could not be decoded", ex); - throw new ApiError("Could not handle GitHub request. Signature could not be decoded", ErrCode.BadValue); - } - return; - } else if (this.jira && JiraWebhooksRouter.IsJIRARequest(req)) { - this.jira.verifyWebhookRequest(req, buffer); - return; - } + private verifyRequest( + req: WebhooksExpressRequest, + _res: Response, + buffer: Buffer, + encoding: BufferEncoding, + ): void { + if (this.config.gitlab && req.headers["x-gitlab-token"]) { + if (req.headers["x-gitlab-token"] !== this.config.gitlab.webhook.secret) { + throw new ApiError( + "Could not handle GitLab request. Token did not match.", + ErrCode.BadValue, + ); + } + return; + } else if (this.ghWebhooks && req.headers["x-hub-signature-256"]) { + if (typeof req.headers["x-hub-signature-256"] !== "string") { + throw new ApiError( + "Could not handle GitHub request. Unexpected multiple headers for x-hub-signature-256", + ErrCode.BadValue, + ); + } + try { + const jsonStr = buffer.toString(encoding); + req.github = { + payload: jsonStr, + signature: req.headers["x-hub-signature-256"], + }; + } catch (ex) { + log.warn("GitHub signature could not be decoded", ex); + throw new ApiError( + "Could not handle GitHub request. Signature could not be decoded", + ErrCode.BadValue, + ); + } + return; + } else if (this.jira && JiraWebhooksRouter.IsJIRARequest(req)) { + this.jira.verifyWebhookRequest(req, buffer); + return; } + } } diff --git a/src/Widgets/BridgeWidgetApi.ts b/src/Widgets/BridgeWidgetApi.ts index db4754f06..fa3807f60 100644 --- a/src/Widgets/BridgeWidgetApi.ts +++ b/src/Widgets/BridgeWidgetApi.ts @@ -3,354 +3,526 @@ import { AdminRoom } from "../AdminRoom"; import { Logger } from "matrix-appservice-bridge"; import { ApiError, ErrCode } from "../api"; import { BridgeConfig } from "../config/Config"; -import { GetAuthPollResponse, GetAuthResponse, GetConnectionsForServiceResponse } from "./BridgeWidgetInterface"; +import { + GetAuthPollResponse, + GetAuthResponse, + GetConnectionsForServiceResponse, +} from "./BridgeWidgetInterface"; import { ProvisioningApi, ProvisioningRequest } from "matrix-appservice-bridge"; import { IBridgeStorageProvider } from "../Stores/StorageProvider"; import { ConnectionManager } from "../ConnectionManager"; -import BotUsersManager, {BotUser} from "../Managers/BotUsersManager"; +import BotUsersManager, { BotUser } from "../Managers/BotUsersManager"; import { assertUserPermissionsInRoom, GetConnectionsResponseItem } from "./api"; import { Appservice, PowerLevelsEvent } from "matrix-bot-sdk"; -import { GithubInstance } from '../github/GithubInstance'; -import { AllowedTokenTypes, TokenType, UserTokenStore } from '../tokens/UserTokenStore'; +import { GithubInstance } from "../github/GithubInstance"; +import { + AllowedTokenTypes, + TokenType, + UserTokenStore, +} from "../tokens/UserTokenStore"; import { OpenProjectWidgetAPI } from "../openproject/widgetApi"; const log = new Logger("BridgeWidgetApi"); export class BridgeWidgetApi extends ProvisioningApi { - constructor( - private adminRooms: Map, - private readonly config: BridgeConfig, - storageProvider: IBridgeStorageProvider, - expressApp: Application, - private readonly connMan: ConnectionManager, - private readonly botUsersManager: BotUsersManager, - private readonly as: Appservice, - private readonly tokenStore: UserTokenStore, - private readonly github?: GithubInstance, - ) { - super( - storageProvider, - { - apiPrefix: "/widgetapi", - widgetFrontendLocation: "public", - expressApp, - widgetTokenPrefix: "hookshot_", - disallowedIpRanges: config.widgets?.disallowedIpRanges, - openIdOverride: config.widgets?.openIdOverrides, - }); - - const wrapHandler = (handler: (req: ProvisioningRequest, res: Response, next?: NextFunction) => Promise) => { - return async (req: ProvisioningRequest, res: Response) => { - await handler.call(this, req, res); - } - } - this.addRoute("get", "/v1/state", wrapHandler(this.getRoomState)); - this.addRoute("get", '/v1/config/sections', wrapHandler(this.getConfigSections)); - this.addRoute("get", '/v1/service/:service/config', wrapHandler(this.getServiceConfig)); - this.addRoute("get", '/v1/:roomId/connections', wrapHandler(this.getConnections)); - this.addRoute("get", '/v1/:roomId/connections/:service', wrapHandler(this.getConnectionsForService)); - this.addRoute("post", '/v1/:roomId/connections/:type', wrapHandler(this.createConnection)); - this.addRoute("put", '/v1/:roomId/connections/:connectionId', wrapHandler(this.updateConnection)); - this.addRoute("patch", '/v1/:roomId/connections/:connectionId', wrapHandler(this.updateConnection)); - this.addRoute("delete", '/v1/:roomId/connections/:connectionId', wrapHandler(this.deleteConnection)); - this.addRoute("get", '/v1/targets/:type', wrapHandler(this.getConnectionTargets)); - this.addRoute('get', '/v1/service/:service/auth', wrapHandler(this.getAuth)); - this.addRoute('get', '/v1/service/:service/auth/:state', wrapHandler(this.getAuthPoll)); - this.addRoute('post', '/v1/service/:service/auth/logout', wrapHandler(this.postAuthLogout)); + constructor( + private adminRooms: Map, + private readonly config: BridgeConfig, + storageProvider: IBridgeStorageProvider, + expressApp: Application, + private readonly connMan: ConnectionManager, + private readonly botUsersManager: BotUsersManager, + private readonly as: Appservice, + private readonly tokenStore: UserTokenStore, + private readonly github?: GithubInstance, + ) { + super(storageProvider, { + apiPrefix: "/widgetapi", + widgetFrontendLocation: "public", + expressApp, + widgetTokenPrefix: "hookshot_", + disallowedIpRanges: config.widgets?.disallowedIpRanges, + openIdOverride: config.widgets?.openIdOverrides, + }); + + const wrapHandler = ( + handler: ( + req: ProvisioningRequest, + res: Response, + next?: NextFunction, + ) => Promise, + ) => { + return async (req: ProvisioningRequest, res: Response) => { + await handler.call(this, req, res); + }; + }; + this.addRoute("get", "/v1/state", wrapHandler(this.getRoomState)); + this.addRoute( + "get", + "/v1/config/sections", + wrapHandler(this.getConfigSections), + ); + this.addRoute( + "get", + "/v1/service/:service/config", + wrapHandler(this.getServiceConfig), + ); + this.addRoute( + "get", + "/v1/:roomId/connections", + wrapHandler(this.getConnections), + ); + this.addRoute( + "get", + "/v1/:roomId/connections/:service", + wrapHandler(this.getConnectionsForService), + ); + this.addRoute( + "post", + "/v1/:roomId/connections/:type", + wrapHandler(this.createConnection), + ); + this.addRoute( + "put", + "/v1/:roomId/connections/:connectionId", + wrapHandler(this.updateConnection), + ); + this.addRoute( + "patch", + "/v1/:roomId/connections/:connectionId", + wrapHandler(this.updateConnection), + ); + this.addRoute( + "delete", + "/v1/:roomId/connections/:connectionId", + wrapHandler(this.deleteConnection), + ); + this.addRoute( + "get", + "/v1/targets/:type", + wrapHandler(this.getConnectionTargets), + ); + this.addRoute( + "get", + "/v1/service/:service/auth", + wrapHandler(this.getAuth), + ); + this.addRoute( + "get", + "/v1/service/:service/auth/:state", + wrapHandler(this.getAuthPoll), + ); + this.addRoute( + "post", + "/v1/service/:service/auth/logout", + wrapHandler(this.postAuthLogout), + ); + } + + private async getBotUserInRoom( + roomId: string, + serviceType?: string, + ): Promise { + let botUser = this.botUsersManager.getBotUserInRoom(roomId, serviceType); + if (!botUser) { + // Not bot in the room...yet. Let's try an ensure join. + const intent = + (serviceType && + this.botUsersManager.getBotUserForService(serviceType)?.intent) || + this.as.botIntent; + try { + await intent.ensureJoined(roomId); + } catch (ex) { + // Just fail with this, we couldn't join. + throw new ApiError( + "Bot was not invited to the room.", + ErrCode.NotInRoom, + ); + } + botUser = this.botUsersManager.getBotUserInRoom(roomId, serviceType); + if (!botUser) { + throw new ApiError("Bot is not joined to the room.", ErrCode.NotInRoom); + } } + return botUser; + } - private async getBotUserInRoom(roomId: string, serviceType?: string): Promise { - let botUser = this.botUsersManager.getBotUserInRoom(roomId, serviceType); - if (!botUser) { - // Not bot in the room...yet. Let's try an ensure join. - const intent = (serviceType && this.botUsersManager.getBotUserForService(serviceType)?.intent) || this.as.botIntent; - try { - await intent.ensureJoined(roomId); - } catch (ex) { - // Just fail with this, we couldn't join. - throw new ApiError("Bot was not invited to the room.", ErrCode.NotInRoom); - } - botUser = this.botUsersManager.getBotUserInRoom(roomId, serviceType); - if (!botUser) { - throw new ApiError("Bot is not joined to the room.", ErrCode.NotInRoom); - } - } - return botUser; + private async getRoomFromRequest( + req: ProvisioningRequest, + ): Promise { + const room = [...this.adminRooms.values()].find( + (r) => r.userId === req.userId, + ); + if (!room) { + throw new ApiError("No room found for access token", ErrCode.BadToken); } + return room; + } - private async getRoomFromRequest(req: ProvisioningRequest): Promise { - const room = [...this.adminRooms.values()].find(r => r.userId === req.userId); - if (!room) { - throw new ApiError("No room found for access token", ErrCode.BadToken); - } - return room; + private async getRoomState(req: ProvisioningRequest, res: Response) { + try { + const room = await this.getRoomFromRequest(req); + res.send(await room.getBridgeState()); + } catch (ex) { + log.error(`Failed to get room state:`, ex); + throw new ApiError( + "An error occurred when getting room state", + ErrCode.Unknown, + ); } + } - private async getRoomState(req: ProvisioningRequest, res: Response) { - try { - const room = await this.getRoomFromRequest(req); - res.send(await room.getBridgeState()); - } catch (ex) { - log.error(`Failed to get room state:`, ex); - throw new ApiError("An error occurred when getting room state", ErrCode.Unknown); - } + private async getConfigSections( + req: ProvisioningRequest, + res: Response<{ [section: string]: boolean }>, + ) { + res.send({ + general: true, + github: !!this.config.github, + gitlab: !!this.config.gitlab, + generic: !!this.config.generic?.enabled, + genericOutbound: !!this.config.generic?.outbound, + jira: !!this.config.jira, + figma: !!this.config.figma, + feeds: !!this.config.feeds?.enabled, + openproject: !!this.config.openProject, + }); + } + + private async getServiceConfig( + req: ProvisioningRequest, + res: Response, + ) { + // GitHub is a special case because it depends on live config. + if (req.params.service === "github") { + res.send(this.config.github?.publicConfig(this.github)); + } else { + res.send(await this.config.getPublicConfigForService(req.params.service)); } + } - private async getConfigSections(req: ProvisioningRequest, res: Response<{[section: string]: boolean}>) { - res.send({ - general: true, - github: !!this.config.github, - gitlab: !!this.config.gitlab, - generic: !!this.config.generic?.enabled, - genericOutbound: !!this.config.generic?.outbound, - jira: !!this.config.jira, - figma: !!this.config.figma, - feeds: !!this.config.feeds?.enabled, - openproject: !!this.config.openProject, - }); + private async getConnectionsForRequest(req: ProvisioningRequest) { + if (!req.userId) { + throw Error("Cannot get connections without a valid userId"); } + const roomId = req.params.roomId; + const serviceType = req.params.service; - private async getServiceConfig(req: ProvisioningRequest, res: Response) { - // GitHub is a special case because it depends on live config. - if (req.params.service === 'github') { - res.send(this.config.github?.publicConfig(this.github)); - } else { - res.send(await this.config.getPublicConfigForService(req.params.service)); - } + const botUser = await this.getBotUserInRoom(roomId, serviceType); + await assertUserPermissionsInRoom( + req.userId, + roomId, + "read", + botUser.intent, + ); + const allConnections = this.connMan.getAllConnectionsForRoom(roomId); + const powerlevel = new PowerLevelsEvent({ + content: await botUser.intent.underlyingClient.getRoomStateEvent( + roomId, + "m.room.power_levels", + "", + ), + }); + const serviceFilter = req.params.service; + const connections = allConnections + .map((c) => c.getProvisionerDetails?.(true)) + .filter((c) => !!c) + // If we have a service filter. + .filter( + (c) => + typeof serviceFilter !== "string" || c?.service === serviceFilter, + ) as GetConnectionsResponseItem[]; + const userPl = + powerlevel.content.users?.[req.userId] || powerlevel.defaultUserLevel; + const botPl = + powerlevel.content.users?.[botUser.userId] || powerlevel.defaultUserLevel; + for (const c of connections) { + // TODO: What about crypto? + const requiredPlForEdit = Math.max( + powerlevel.content.events?.[c.type] ?? 0, + powerlevel.defaultStateEventLevel, + ); + const requiredPlForMessages = Math.max( + powerlevel.content.events?.["m.room.message"] ?? + powerlevel.content.events_default ?? + 0, + ); + c.canEdit = userPl >= requiredPlForEdit; + c.canSendMessages = botPl >= requiredPlForMessages; + if (!c.canEdit) { + delete c.secrets; + } } - private async getConnectionsForRequest(req: ProvisioningRequest) { - if (!req.userId) { - throw Error('Cannot get connections without a valid userId'); - } - const roomId = req.params.roomId; - const serviceType = req.params.service; + return { + connections, + canEdit: userPl >= powerlevel.defaultStateEventLevel, + }; + } - const botUser = await this.getBotUserInRoom(roomId, serviceType); - await assertUserPermissionsInRoom(req.userId, roomId, "read", botUser.intent); - const allConnections = this.connMan.getAllConnectionsForRoom(roomId); - const powerlevel = new PowerLevelsEvent({content: await botUser.intent.underlyingClient.getRoomStateEvent(roomId, "m.room.power_levels", "")}); - const serviceFilter = req.params.service; - const connections = allConnections.map(c => c.getProvisionerDetails?.(true)) - .filter(c => !!c) - // If we have a service filter. - .filter(c => typeof serviceFilter !== "string" || c?.service === serviceFilter) as GetConnectionsResponseItem[]; - const userPl = powerlevel.content.users?.[req.userId] || powerlevel.defaultUserLevel; - const botPl = powerlevel.content.users?.[botUser.userId] || powerlevel.defaultUserLevel; - for (const c of connections) { - // TODO: What about crypto? - const requiredPlForEdit = Math.max(powerlevel.content.events?.[c.type] ?? 0, powerlevel.defaultStateEventLevel); - const requiredPlForMessages = Math.max(powerlevel.content.events?.["m.room.message"] ?? powerlevel.content.events_default ?? 0); - c.canEdit = userPl >= requiredPlForEdit; - c.canSendMessages = botPl >= requiredPlForMessages; - if (!c.canEdit) { - delete c.secrets; - } - } + private async getConnections( + req: ProvisioningRequest, + res: Response, + ) { + res.send((await this.getConnectionsForRequest(req)).connections); + } - return { - connections, - canEdit: userPl >= powerlevel.defaultStateEventLevel, - }; - } + private async getConnectionsForService( + req: ProvisioningRequest, + res: Response>, + ) { + res.send(await this.getConnectionsForRequest(req)); + } - private async getConnections(req: ProvisioningRequest, res: Response) { - res.send((await this.getConnectionsForRequest(req)).connections); + private async createConnection( + req: ProvisioningRequest, + res: Response, + ) { + if (!req.userId) { + throw Error("Cannot get connections without a valid userId"); } - - private async getConnectionsForService(req: ProvisioningRequest, res: Response>) { - res.send(await this.getConnectionsForRequest(req)); + const roomId = req.params.roomId; + const eventType = req.params.type; + const connectionType = + this.connMan.getConnectionTypeForEventType(eventType); + if (!connectionType) { + throw new ApiError("Unknown event type", ErrCode.NotFound); } + const serviceType = connectionType.ServiceCategory; - private async createConnection(req: ProvisioningRequest, res: Response) { - if (!req.userId) { - throw Error('Cannot get connections without a valid userId'); - } - const roomId = req.params.roomId; - const eventType = req.params.type; - const connectionType = this.connMan.getConnectionTypeForEventType(eventType); - if (!connectionType) { - throw new ApiError("Unknown event type", ErrCode.NotFound); - } - const serviceType = connectionType.ServiceCategory; + const botUser = await this.getBotUserInRoom(roomId, serviceType); + await assertUserPermissionsInRoom( + req.userId, + roomId, + "write", + botUser.intent, + ); + try { + if (!req.body || typeof req.body !== "object") { + throw new ApiError("A JSON body must be provided", ErrCode.BadValue); + } + this.connMan.validateCommandPrefix(req.params.roomId, req.body); + const result = await this.connMan.provisionConnection( + roomId, + botUser.intent, + req.userId, + connectionType, + req.body, + ); + if (!result.connection.getProvisionerDetails) { + throw new Error( + "Connection supported provisioning but not getProvisionerDetails", + ); + } + res.send({ + ...result.connection.getProvisionerDetails(true), + warning: result.warning, + }); + } catch (ex) { + log.error(`Failed to create connection for ${req.params.roomId}`, ex); + throw ex; + } + } - const botUser = await this.getBotUserInRoom(roomId, serviceType); - await assertUserPermissionsInRoom(req.userId, roomId, "write", botUser.intent); - try { - if (!req.body || typeof req.body !== "object") { - throw new ApiError("A JSON body must be provided", ErrCode.BadValue); - } - this.connMan.validateCommandPrefix(req.params.roomId, req.body); - const result = await this.connMan.provisionConnection(roomId, botUser.intent, req.userId, connectionType, req.body); - if (!result.connection.getProvisionerDetails) { - throw new Error('Connection supported provisioning but not getProvisionerDetails'); - } - res.send({ - ...result.connection.getProvisionerDetails(true), - warning: result.warning, - }); - } catch (ex) { - log.error(`Failed to create connection for ${req.params.roomId}`, ex); - throw ex; - } + private async updateConnection( + req: ProvisioningRequest, + res: Response, + ) { + if (!req.userId) { + throw Error("Cannot get connections without a valid userId"); } + const roomId = req.params.roomId; + const serviceType = req.params.type; + const connectionId = req.params.connectionId; - private async updateConnection(req: ProvisioningRequest, res: Response) { - if (!req.userId) { - throw Error('Cannot get connections without a valid userId'); - } - const roomId = req.params.roomId; - const serviceType = req.params.type; - const connectionId = req.params.connectionId; + const botUser = await this.getBotUserInRoom(roomId, serviceType); + await assertUserPermissionsInRoom( + req.userId, + roomId, + "write", + botUser.intent, + ); + const connection = this.connMan.getConnectionById(roomId, connectionId); + if (!connection) { + throw new ApiError("Connection does not exist", ErrCode.NotFound); + } + if ( + !connection.provisionerUpdateConfig || + !connection.getProvisionerDetails + ) { + throw new ApiError( + "Connection type does not support updates", + ErrCode.UnsupportedOperation, + ); + } + this.connMan.validateCommandPrefix(roomId, req.body, connection); + await connection.provisionerUpdateConfig(req.userId, req.body); + res.send(connection.getProvisionerDetails(true)); + } - const botUser = await this.getBotUserInRoom(roomId, serviceType); - await assertUserPermissionsInRoom(req.userId, roomId, "write", botUser.intent); - const connection = this.connMan.getConnectionById(roomId, connectionId); - if (!connection) { - throw new ApiError("Connection does not exist", ErrCode.NotFound); - } - if (!connection.provisionerUpdateConfig || !connection.getProvisionerDetails) { - throw new ApiError("Connection type does not support updates", ErrCode.UnsupportedOperation); - } - this.connMan.validateCommandPrefix(roomId, req.body, connection); - await connection.provisionerUpdateConfig(req.userId, req.body); - res.send(connection.getProvisionerDetails(true)); + private async deleteConnection( + req: ProvisioningRequest, + res: Response<{ ok: true }>, + ) { + if (!req.userId) { + throw Error("Cannot get connections without a valid userId"); } + const roomId = req.params.roomId; + const serviceType = req.params.type; + const connectionId = req.params.connectionId; - private async deleteConnection(req: ProvisioningRequest, res: Response<{ok: true}>) { - if (!req.userId) { - throw Error('Cannot get connections without a valid userId'); - } - const roomId = req.params.roomId; - const serviceType = req.params.type; - const connectionId = req.params.connectionId; + const botUser = await this.getBotUserInRoom(roomId, serviceType); + await assertUserPermissionsInRoom( + req.userId, + roomId, + "write", + botUser.intent, + ); + const connection = this.connMan.getConnectionById(roomId, connectionId); + if (!connection) { + throw new ApiError("Connection does not exist", ErrCode.NotFound); + } + if (!connection.onRemove) { + throw new ApiError( + "Connection does not support removal", + ErrCode.UnsupportedOperation, + ); + } + await this.connMan.purgeConnection(roomId, connectionId); + res.send({ ok: true }); + } - const botUser = await this.getBotUserInRoom(roomId, serviceType); - await assertUserPermissionsInRoom(req.userId, roomId, "write", botUser.intent); - const connection = this.connMan.getConnectionById(roomId, connectionId); - if (!connection) { - throw new ApiError("Connection does not exist", ErrCode.NotFound); - } - if (!connection.onRemove) { - throw new ApiError("Connection does not support removal", ErrCode.UnsupportedOperation); - } - await this.connMan.purgeConnection(roomId, connectionId); - res.send({ok: true}); + private async getConnectionTargets(req: ProvisioningRequest, res: Response) { + if (!req.userId) { + throw Error("Cannot get connections without a valid userId"); } + const type = req.params.type; + const connections = await this.connMan.getConnectionTargets( + req.userId, + type, + req.query, + ); + res.send(connections); + } - private async getConnectionTargets(req: ProvisioningRequest, res: Response) { - if (!req.userId) { - throw Error('Cannot get connections without a valid userId'); - } - const type = req.params.type; - const connections = await this.connMan.getConnectionTargets(req.userId, type, req.query); - res.send(connections); + private async getAuth( + req: ProvisioningRequest, + res: Response, + ) { + if (!req.userId) { + throw Error("Expected userId on request"); + } + const service = req.params.service; + if (!service) { + throw Error("Expected service in parameters"); } + // TODO: Should this be part of the GitHub module code. + if (service === "github") { + if (!this.config.github || !this.config.github.oauth) { + throw new ApiError( + "GitHub oauth is not configured", + ErrCode.DisabledFeature, + ); + } - private async getAuth(req: ProvisioningRequest, res: Response) { - if (!req.userId) { - throw Error('Expected userId on request'); - } - const service = req.params.service; - if (!service) { - throw Error('Expected service in parameters'); + let user; + try { + const octokit = await this.tokenStore.getOctokitForUser(req.userId); + if (octokit !== null) { + const me = await octokit.users.getAuthenticated(); + user = { + name: me.data.login, + }; } + } catch (e) { + // Need to authenticate + } - // TODO: Should this be part of the GitHub module code. - if (service === 'github') { - if (!this.config.github || !this.config.github.oauth) { - throw new ApiError('GitHub oauth is not configured', ErrCode.DisabledFeature); - } + if (user) { + return res.json({ + authenticated: true, + user, + }); + } else { + const state = this.tokenStore.createStateForOAuth(req.userId); + const authUrl = GithubInstance.generateOAuthUrl( + this.config.github.baseUrl, + "authorize", + { + state, + client_id: this.config.github.oauth.client_id, + redirect_uri: this.config.github.oauth.redirect_uri, + }, + ); + return res.json({ + authenticated: false, + stateId: state, + authUrl, + }); + } + } else if (service === "openproject") { + return await OpenProjectWidgetAPI.getAuth(req, res, this.tokenStore); + } else { + throw new ApiError("Service not found", ErrCode.NotFound); + } + } - let user; - try { - const octokit = await this.tokenStore.getOctokitForUser(req.userId); - if (octokit !== null) { - const me = await octokit.users.getAuthenticated(); - user = { - name: me.data.login, - }; - } - } catch (e) { - // Need to authenticate - } + private async getAuthPoll( + req: ProvisioningRequest, + res: Response, + ) { + if (!req.userId) { + throw Error("Expected userId on request"); + } + const { service, state } = req.params; - if (user) { - return res.json({ - authenticated: true, - user - }); - } else { - const state = this.tokenStore.createStateForOAuth(req.userId); - const authUrl = GithubInstance.generateOAuthUrl( - this.config.github.baseUrl, - 'authorize', - { - state, - client_id: this.config.github.oauth.client_id, - redirect_uri: this.config.github.oauth.redirect_uri, - } - ); - return res.json({ - authenticated: false, - stateId: state, - authUrl - }); - } - } else if (service === 'openproject') { - return await OpenProjectWidgetAPI.getAuth(req, res, this.tokenStore); - } else { - throw new ApiError('Service not found', ErrCode.NotFound); - } + if (!service) { + throw Error("Expected service in parameters"); } - private async getAuthPoll(req: ProvisioningRequest, res: Response) { - if (!req.userId) { - throw Error('Expected userId on request'); - } - const { service, state } = req.params; - - if (!service) { - throw Error('Expected service in parameters'); - } - - // N.B. Service isn't really used. - const stateUserId = this.tokenStore.getUserIdForOAuthState(state, false); + // N.B. Service isn't really used. + const stateUserId = this.tokenStore.getUserIdForOAuthState(state, false); - if (!stateUserId || req.userId !== stateUserId) { - // If the state isn't found then either the state has been completed or the key is wrong. - // We don't actually know, so we assume the sender knows what they are doing. - res.send({ - state: 'complete', - }); - return; - } - res.send({ - state: 'waiting', - }); - return; + if (!stateUserId || req.userId !== stateUserId) { + // If the state isn't found then either the state has been completed or the key is wrong. + // We don't actually know, so we assume the sender knows what they are doing. + res.send({ + state: "complete", + }); + return; } + res.send({ + state: "waiting", + }); + return; + } - private async postAuthLogout(req: ProvisioningRequest, res: Response<{ok: true}>) { - if (!req.userId) { - throw Error('Expected userId on request'); - } - const { service } = req.params; - - if (!service) { - throw Error('Expected service in parameters'); - } + private async postAuthLogout( + req: ProvisioningRequest, + res: Response<{ ok: true }>, + ) { + if (!req.userId) { + throw Error("Expected userId on request"); + } + const { service } = req.params; - if (AllowedTokenTypes.includes(service)) { - const result = await this.tokenStore.clearUserToken(service as TokenType, req.userId); - if (result) { - res.send({ok: true}); - } else { - throw new ApiError("You are not logged in", ErrCode.NotFound); - } - } else { - throw new ApiError('Service not found', ErrCode.NotFound); - } + if (!service) { + throw Error("Expected service in parameters"); + } + + if (AllowedTokenTypes.includes(service)) { + const result = await this.tokenStore.clearUserToken( + service as TokenType, + req.userId, + ); + if (result) { + res.send({ ok: true }); + } else { + throw new ApiError("You are not logged in", ErrCode.NotFound); + } + } else { + throw new ApiError("Service not found", ErrCode.NotFound); } + } } diff --git a/src/Widgets/BridgeWidgetInterface.ts b/src/Widgets/BridgeWidgetInterface.ts index 7725950a8..6b381d6f1 100644 --- a/src/Widgets/BridgeWidgetInterface.ts +++ b/src/Widgets/BridgeWidgetInterface.ts @@ -1,59 +1,61 @@ import { GetConnectionsResponseItem } from "./api"; export interface BridgeRoomStateGitHub { - enabled: boolean; - tokenStored: boolean; - identity: { - name: string|null; - avatarUrl: string|null; - }|null; - notifications: boolean; + enabled: boolean; + tokenStored: boolean; + identity: { + name: string | null; + avatarUrl: string | null; + } | null; + notifications: boolean; } export interface BridgeRoomState { - title: string; - github: BridgeRoomStateGitHub; + title: string; + github: BridgeRoomStateGitHub; } export enum WidgetConfigurationType { - String, - OAuthUrl, + String, + OAuthUrl, } export interface WidgetConfigurationOption { - key: string; - type: WidgetConfigurationType, - currentValue: string|null; - defaultValue?: string; - additionalData?: Record; + key: string; + type: WidgetConfigurationType; + currentValue: string | null; + defaultValue?: string; + additionalData?: Record; } export interface WidgetConfigurationSection { - name: string; - options: WidgetConfigurationOption[]; + name: string; + options: WidgetConfigurationOption[]; } -export interface GetConnectionsForServiceResponse { - connections: T[]; - canEdit: boolean; +export interface GetConnectionsForServiceResponse< + T extends GetConnectionsResponseItem, +> { + connections: T[]; + canEdit: boolean; } - export interface GetAuthResponseAuthenticated { - authenticated: true; - user: { - name: string; - } + authenticated: true; + user: { + name: string; + }; } export interface GetAuthResponseUnauthenticated { - authenticated: false; - authUrl: string; - stateId: string; + authenticated: false; + authUrl: string; + stateId: string; } - -export type GetAuthResponse = GetAuthResponseAuthenticated|GetAuthResponseUnauthenticated; +export type GetAuthResponse = + | GetAuthResponseAuthenticated + | GetAuthResponseUnauthenticated; export interface GetAuthPollResponse { - state: 'complete'|'waiting'; -} \ No newline at end of file + state: "complete" | "waiting"; +} diff --git a/src/Widgets/SetupWidget.ts b/src/Widgets/SetupWidget.ts index d7ef4776b..0c9f961ad 100644 --- a/src/Widgets/SetupWidget.ts +++ b/src/Widgets/SetupWidget.ts @@ -1,4 +1,3 @@ - import { Intent } from "matrix-bot-sdk"; import { BridgeWidgetConfig } from "../config/Config"; import { Logger } from "matrix-appservice-bridge"; @@ -7,77 +6,115 @@ import { HookshotWidgetKind } from "./WidgetKind"; const log = new Logger("SetupWidget"); export class SetupWidget { - - static async SetupAdminRoomConfigWidget(roomId: string, botIntent: Intent, config: BridgeWidgetConfig): Promise { - if (await SetupWidget.createWidgetInRoom(roomId, botIntent, config, HookshotWidgetKind.Settings, "bridge_control")) { - await botIntent.sendText(roomId, `If your client supports it, you can open the "${config.branding.widgetTitle}" widget to configure hookshot.`); - return true; - } - return false; + static async SetupAdminRoomConfigWidget( + roomId: string, + botIntent: Intent, + config: BridgeWidgetConfig, + ): Promise { + if ( + await SetupWidget.createWidgetInRoom( + roomId, + botIntent, + config, + HookshotWidgetKind.Settings, + "bridge_control", + ) + ) { + await botIntent.sendText( + roomId, + `If your client supports it, you can open the "${config.branding.widgetTitle}" widget to configure hookshot.`, + ); + return true; } + return false; + } - static async SetupRoomConfigWidget(roomId: string, botIntent: Intent, config: BridgeWidgetConfig, serviceTypes: string[]): Promise { - // If this is for a single service, scope the widget - const serviceScope = serviceTypes.length === 1 ? serviceTypes[0] : undefined; - if (await SetupWidget.createWidgetInRoom( - roomId, - botIntent, - config, - HookshotWidgetKind.RoomConfiguration, - `hookshot_room_config_${config.parsedPublicUrl.host}${serviceScope ? '_' + serviceScope : ''}`, - serviceScope, - )) { - await botIntent.sendText(roomId, `Please open the ${config.branding.widgetTitle} widget to set up integrations.`); - return true; - } - return false; + static async SetupRoomConfigWidget( + roomId: string, + botIntent: Intent, + config: BridgeWidgetConfig, + serviceTypes: string[], + ): Promise { + // If this is for a single service, scope the widget + const serviceScope = + serviceTypes.length === 1 ? serviceTypes[0] : undefined; + if ( + await SetupWidget.createWidgetInRoom( + roomId, + botIntent, + config, + HookshotWidgetKind.RoomConfiguration, + `hookshot_room_config_${config.parsedPublicUrl.host}${serviceScope ? "_" + serviceScope : ""}`, + serviceScope, + ) + ) { + await botIntent.sendText( + roomId, + `Please open the ${config.branding.widgetTitle} widget to set up integrations.`, + ); + return true; } + return false; + } - private static async createWidgetInRoom( - roomId: string, - botIntent: Intent, - config: BridgeWidgetConfig, - kind: HookshotWidgetKind, - stateKey: string, - serviceScope?: string, - ): Promise { - log.info(`Running SetupRoomConfigWidget for ${roomId}`); - if (!await botIntent.underlyingClient.userHasPowerLevelFor(botIntent.userId, roomId, "im.vector.modular.widgets", true)) { - throw new CommandError("Bot lacks power level to set room state", "I do not have permission to create a widget in this room. Please promote me to an Admin/Moderator."); - } - try { - const res = await botIntent.underlyingClient.getRoomStateEvent( - roomId, - "im.vector.modular.widgets", - stateKey - ); - // Deleted widgets are empty objects - if (res && Object.keys(res).length > 0) { - log.debug(`Widget for ${roomId} exists, not creating`); - // No-op - // Validate? - return false; - } - } catch (ex) { - // Didn't exist, create it. - } - log.debug(`Generating widget state event for ${roomId}`); - await botIntent.underlyingClient.sendStateEvent( - roomId, - "im.vector.modular.widgets", - stateKey, - { - "creatorUserId": botIntent.userId, - "data": { - "title": serviceScope ? serviceScope : config.branding.widgetTitle, - }, - "id": stateKey, - "name": config.branding.widgetTitle, - "type": "m.custom", - "url": new URL(`#/?kind=${kind}&roomId=$matrix_room_id&widgetId=$matrix_widget_id${serviceScope ? `&serviceScope=${serviceScope}` : ''}`, config.parsedPublicUrl).href, - "waitForIframeLoad": true, - } - ); - return true; + private static async createWidgetInRoom( + roomId: string, + botIntent: Intent, + config: BridgeWidgetConfig, + kind: HookshotWidgetKind, + stateKey: string, + serviceScope?: string, + ): Promise { + log.info(`Running SetupRoomConfigWidget for ${roomId}`); + if ( + !(await botIntent.underlyingClient.userHasPowerLevelFor( + botIntent.userId, + roomId, + "im.vector.modular.widgets", + true, + )) + ) { + throw new CommandError( + "Bot lacks power level to set room state", + "I do not have permission to create a widget in this room. Please promote me to an Admin/Moderator.", + ); + } + try { + const res = await botIntent.underlyingClient.getRoomStateEvent( + roomId, + "im.vector.modular.widgets", + stateKey, + ); + // Deleted widgets are empty objects + if (res && Object.keys(res).length > 0) { + log.debug(`Widget for ${roomId} exists, not creating`); + // No-op + // Validate? + return false; + } + } catch (ex) { + // Didn't exist, create it. } + log.debug(`Generating widget state event for ${roomId}`); + await botIntent.underlyingClient.sendStateEvent( + roomId, + "im.vector.modular.widgets", + stateKey, + { + creatorUserId: botIntent.userId, + data: { + title: serviceScope ? serviceScope : config.branding.widgetTitle, + }, + id: stateKey, + name: config.branding.widgetTitle, + type: "m.custom", + url: new URL( + `#/?kind=${kind}&roomId=$matrix_room_id&widgetId=$matrix_widget_id${serviceScope ? `&serviceScope=${serviceScope}` : ""}`, + config.parsedPublicUrl, + ).href, + waitForIframeLoad: true, + }, + ); + return true; + } } diff --git a/src/Widgets/WidgetKind.ts b/src/Widgets/WidgetKind.ts index 623293953..4d7b93270 100644 --- a/src/Widgets/WidgetKind.ts +++ b/src/Widgets/WidgetKind.ts @@ -1,5 +1,5 @@ export enum HookshotWidgetKind { - Settings = "admin", - BridgeInvites = "invite", - RoomConfiguration = "roomConfig", -} \ No newline at end of file + Settings = "admin", + BridgeInvites = "invite", + RoomConfiguration = "roomConfig", +} diff --git a/src/Widgets/api.ts b/src/Widgets/api.ts index 1f6e76cac..78068dd97 100644 --- a/src/Widgets/api.ts +++ b/src/Widgets/api.ts @@ -1,94 +1,138 @@ -import { Intent, MatrixError, MembershipEventContent, PowerLevelsEventContent } from "matrix-bot-sdk"; +import { + Intent, + MatrixError, + MembershipEventContent, + PowerLevelsEventContent, +} from "matrix-bot-sdk"; import { ApiError, ErrCode } from "../api"; import { Logger } from "matrix-appservice-bridge"; export interface GetConnectionTypeResponseItem { - eventType: string; - type: string; - service: string; - botUserId: string; + eventType: string; + type: string; + service: string; + botUserId: string; } export interface ConnectionWarning { - header: string, - message: string, + header: string; + message: string; } -export interface GetConnectionsResponseItem extends GetConnectionTypeResponseItem { - id: string; - config: Config; - secrets?: Secrets; - canSendMessages?: boolean; - canEdit?: boolean; - warning?: ConnectionWarning; +export interface GetConnectionsResponseItem + extends GetConnectionTypeResponseItem { + id: string; + config: Config; + secrets?: Secrets; + canSendMessages?: boolean; + canEdit?: boolean; + warning?: ConnectionWarning; } const log = new Logger("Provisioner.api"); -export async function assertUserPermissionsInRoom(userId: string, roomId: string, requiredPermission: "read"|"write", intent: Intent) { - // Always do an ensureJoined to clear any possible invites. - await intent.ensureJoined(roomId); - try { - const membership = await intent.underlyingClient.getRoomStateEvent(roomId, "m.room.member", intent.userId) as MembershipEventContent; - if (membership.membership === "invite") { - await intent.underlyingClient.joinRoom(roomId); - } else if (membership.membership !== "join") { - throw new ApiError("Bot is not joined to the room.", ErrCode.NotInRoom); - } - } catch (ex) { - if (ex instanceof MatrixError && ex.errcode === "M_FORBIDDEN") { - throw new ApiError(`User ${intent.userId} is not invited to the room.`, ErrCode.NotInRoom); - } - if (isNotFoundError(ex)) { - throw new ApiError("User is not joined to the room.", ErrCode.NotInRoom); - } - log.warn(`Failed to find member event for ${userId} in room ${roomId}`, ex); - throw new ApiError(`Could not determine if the user is in the room.`, ErrCode.NotInRoom); +export async function assertUserPermissionsInRoom( + userId: string, + roomId: string, + requiredPermission: "read" | "write", + intent: Intent, +) { + // Always do an ensureJoined to clear any possible invites. + await intent.ensureJoined(roomId); + try { + const membership = (await intent.underlyingClient.getRoomStateEvent( + roomId, + "m.room.member", + intent.userId, + )) as MembershipEventContent; + if (membership.membership === "invite") { + await intent.underlyingClient.joinRoom(roomId); + } else if (membership.membership !== "join") { + throw new ApiError("Bot is not joined to the room.", ErrCode.NotInRoom); } - // If the user just wants to read, just ensure they are in the room. - try { - const membership = await intent.underlyingClient.getRoomStateEvent(roomId, "m.room.member", userId) as MembershipEventContent; - if (membership.membership !== "join") { - throw new ApiError("User is not joined to the room.", ErrCode.NotInRoom); - } - } catch (ex) { - if (isNotFoundError(ex)) { - throw new ApiError("User is not joined to the room.", ErrCode.NotInRoom); - } - log.warn(`Failed to find member event for ${userId} in room ${roomId}`, ex); - throw new ApiError(`Could not determine if the user is in the room.`, ErrCode.NotInRoom); + } catch (ex) { + if (ex instanceof MatrixError && ex.errcode === "M_FORBIDDEN") { + throw new ApiError( + `User ${intent.userId} is not invited to the room.`, + ErrCode.NotInRoom, + ); } - if (requiredPermission === "read") { - return true; + if (isNotFoundError(ex)) { + throw new ApiError("User is not joined to the room.", ErrCode.NotInRoom); } - let pls: PowerLevelsEventContent; - try { - pls = await intent.underlyingClient.getRoomStateEvent(roomId, "m.room.power_levels", "") as PowerLevelsEventContent; - } catch (ex) { - log.warn(`Failed to find PL event for room ${roomId}`, ex); - throw new ApiError(`Could not get power levels for ${roomId}. Is the bot invited?`, ErrCode.NotInRoom); + log.warn(`Failed to find member event for ${userId} in room ${roomId}`, ex); + throw new ApiError( + `Could not determine if the user is in the room.`, + ErrCode.NotInRoom, + ); + } + // If the user just wants to read, just ensure they are in the room. + try { + const membership = (await intent.underlyingClient.getRoomStateEvent( + roomId, + "m.room.member", + userId, + )) as MembershipEventContent; + if (membership.membership !== "join") { + throw new ApiError("User is not joined to the room.", ErrCode.NotInRoom); } - - // TODO: Decide what PL consider "write" permissions - const botPl = pls.users?.[intent.userId] || pls.users_default || 0; - const userPl = pls.users?.[userId] || pls.users_default || 0; - const requiredPl = pls.state_default || 50; - - // Check the bot's permissions - if (botPl < requiredPl) { - throw new ApiError(`Bot has a PL of ${botPl} but needs at least ${requiredPl}.`, ErrCode.ForbiddenBot); + } catch (ex) { + if (isNotFoundError(ex)) { + throw new ApiError("User is not joined to the room.", ErrCode.NotInRoom); } + log.warn(`Failed to find member event for ${userId} in room ${roomId}`, ex); + throw new ApiError( + `Could not determine if the user is in the room.`, + ErrCode.NotInRoom, + ); + } + if (requiredPermission === "read") { + return true; + } + let pls: PowerLevelsEventContent; + try { + pls = (await intent.underlyingClient.getRoomStateEvent( + roomId, + "m.room.power_levels", + "", + )) as PowerLevelsEventContent; + } catch (ex) { + log.warn(`Failed to find PL event for room ${roomId}`, ex); + throw new ApiError( + `Could not get power levels for ${roomId}. Is the bot invited?`, + ErrCode.NotInRoom, + ); + } - // Now check the users - if (userPl >= requiredPl) { - return true; - } else { - throw new ApiError(`User has a PL of ${userPl} but needs at least ${requiredPl}.`, ErrCode.ForbiddenUser); - } + // TODO: Decide what PL consider "write" permissions + const botPl = pls.users?.[intent.userId] || pls.users_default || 0; + const userPl = pls.users?.[userId] || pls.users_default || 0; + const requiredPl = pls.state_default || 50; + + // Check the bot's permissions + if (botPl < requiredPl) { + throw new ApiError( + `Bot has a PL of ${botPl} but needs at least ${requiredPl}.`, + ErrCode.ForbiddenBot, + ); + } + + // Now check the users + if (userPl >= requiredPl) { + return true; + } else { + throw new ApiError( + `User has a PL of ${userPl} but needs at least ${requiredPl}.`, + ErrCode.ForbiddenUser, + ); + } } // TODO Use MatrixError as a type once matrix-bot-sdk is updated to a version that exports it // eslint-disable-next-line @typescript-eslint/no-explicit-any function isNotFoundError(ex: any) { - return "M_NOT_FOUND" == (ex instanceof ApiError ? ex.jsonBody.errcode : ex.body?.errcode ?? ""); -} \ No newline at end of file + return ( + "M_NOT_FOUND" == + (ex instanceof ApiError ? ex.jsonBody.errcode : (ex.body?.errcode ?? "")) + ); +} diff --git a/src/api/error.ts b/src/api/error.ts index 95f2b0f0f..36c68d642 100644 --- a/src/api/error.ts +++ b/src/api/error.ts @@ -5,130 +5,138 @@ import { IApiError } from "matrix-appservice-bridge"; import { Logger } from "matrix-appservice-bridge"; export enum ErrCode { - // Errors are prefixed with HS_ - /** - * Generic failure, unknown reason - */ - Unknown = "HS_UNKNOWN", - /** - * The resource was not found - */ - NotFound = "HS_NOTFOUND", - /** - * The operation was not supported by this connection - */ - UnsupportedOperation = "HS_UNSUPPORTED_OPERATION", - /** - * The target user does not have permissions to perform this action in the room. - */ - ForbiddenUser = "HS_FORBIDDEN_USER", - /** - * The bot does not have permissions to perform this action in the room. - */ - ForbiddenBot = "HS_FORBIDDEN_BOT", - /** - * The bot or user could not be confirmed to be in the room. - */ - NotInRoom = "HS_NOT_IN_ROOM", - /** - * A bad value was given to the API. - */ - BadValue = "HS_BAD_VALUE", - /** - * The secret token provided to the API was invalid or not given. - */ - BadToken = "HS_BAD_TOKEN", - /** - * The requested feature is not enabled in the bridge. - */ - DisabledFeature = "HS_DISABLED_FEATURE", - /** - * The operation action requires an additional action from the requestor. - */ - AdditionalActionRequired = "HS_ADDITIONAL_ACTION_REQUIRED", - /** - * A connection with similar configuration exists - */ - ConflictingConnection = "HS_CONFLICTING_CONNECTION", + // Errors are prefixed with HS_ + /** + * Generic failure, unknown reason + */ + Unknown = "HS_UNKNOWN", + /** + * The resource was not found + */ + NotFound = "HS_NOTFOUND", + /** + * The operation was not supported by this connection + */ + UnsupportedOperation = "HS_UNSUPPORTED_OPERATION", + /** + * The target user does not have permissions to perform this action in the room. + */ + ForbiddenUser = "HS_FORBIDDEN_USER", + /** + * The bot does not have permissions to perform this action in the room. + */ + ForbiddenBot = "HS_FORBIDDEN_BOT", + /** + * The bot or user could not be confirmed to be in the room. + */ + NotInRoom = "HS_NOT_IN_ROOM", + /** + * A bad value was given to the API. + */ + BadValue = "HS_BAD_VALUE", + /** + * The secret token provided to the API was invalid or not given. + */ + BadToken = "HS_BAD_TOKEN", + /** + * The requested feature is not enabled in the bridge. + */ + DisabledFeature = "HS_DISABLED_FEATURE", + /** + * The operation action requires an additional action from the requestor. + */ + AdditionalActionRequired = "HS_ADDITIONAL_ACTION_REQUIRED", + /** + * A connection with similar configuration exists + */ + ConflictingConnection = "HS_CONFLICTING_CONNECTION", - MethodNotAllowed = "HS_METHOD_NOT_ALLOWED", - /** - * The webhook could not be routed. - */ - Unroutable = "HS_UNROUTABLE", + MethodNotAllowed = "HS_METHOD_NOT_ALLOWED", + /** + * The webhook could not be routed. + */ + Unroutable = "HS_UNROUTABLE", } const ErrCodeToStatusCode: Record = { - HS_UNKNOWN: StatusCodes.INTERNAL_SERVER_ERROR, - HS_NOTFOUND: StatusCodes.NOT_FOUND, - HS_UNSUPPORTED_OPERATION: StatusCodes.BAD_REQUEST, - HS_FORBIDDEN_USER: StatusCodes.FORBIDDEN, - HS_FORBIDDEN_BOT: StatusCodes.FORBIDDEN, - HS_NOT_IN_ROOM: StatusCodes.FORBIDDEN, - HS_BAD_VALUE: StatusCodes.BAD_REQUEST, - HS_BAD_TOKEN: StatusCodes.UNAUTHORIZED, - HS_DISABLED_FEATURE: StatusCodes.INTERNAL_SERVER_ERROR, - HS_ADDITIONAL_ACTION_REQUIRED: StatusCodes.BAD_REQUEST, - HS_CONFLICTING_CONNECTION: StatusCodes.CONFLICT, - HS_METHOD_NOT_ALLOWED: StatusCodes.METHOD_NOT_ALLOWED, - HS_UNROUTABLE: StatusCodes.BAD_REQUEST, -} + HS_UNKNOWN: StatusCodes.INTERNAL_SERVER_ERROR, + HS_NOTFOUND: StatusCodes.NOT_FOUND, + HS_UNSUPPORTED_OPERATION: StatusCodes.BAD_REQUEST, + HS_FORBIDDEN_USER: StatusCodes.FORBIDDEN, + HS_FORBIDDEN_BOT: StatusCodes.FORBIDDEN, + HS_NOT_IN_ROOM: StatusCodes.FORBIDDEN, + HS_BAD_VALUE: StatusCodes.BAD_REQUEST, + HS_BAD_TOKEN: StatusCodes.UNAUTHORIZED, + HS_DISABLED_FEATURE: StatusCodes.INTERNAL_SERVER_ERROR, + HS_ADDITIONAL_ACTION_REQUIRED: StatusCodes.BAD_REQUEST, + HS_CONFLICTING_CONNECTION: StatusCodes.CONFLICT, + HS_METHOD_NOT_ALLOWED: StatusCodes.METHOD_NOT_ALLOWED, + HS_UNROUTABLE: StatusCodes.BAD_REQUEST, +}; export class ApiError extends Error implements IApiError { - static readonly GenericFailure = new ApiError("An internal error occurred"); + static readonly GenericFailure = new ApiError("An internal error occurred"); - constructor( - public readonly error: string, - public readonly errcode = ErrCode.Unknown, - public readonly statusCode: number|StatusCodes = -1, - public readonly additionalContent: Record = {}, - ) { - super(`API error ${errcode}: ${error}`); - if (statusCode === -1) { - this.statusCode = ErrCodeToStatusCode[errcode]; - } + constructor( + public readonly error: string, + public readonly errcode = ErrCode.Unknown, + public readonly statusCode: number | StatusCodes = -1, + public readonly additionalContent: Record = {}, + ) { + super(`API error ${errcode}: ${error}`); + if (statusCode === -1) { + this.statusCode = ErrCodeToStatusCode[errcode]; } + } - get jsonBody() { - return { - errcode: this.errcode, - error: this.error, - ...this.additionalContent, - } - } + get jsonBody() { + return { + errcode: this.errcode, + error: this.error, + ...this.additionalContent, + }; + } - public apply(response: Response) { - return response.status(this.statusCode).send(this.jsonBody); - } + public apply(response: Response) { + return response.status(this.statusCode).send(this.jsonBody); + } } export class ValidatorApiError extends ApiError { - constructor(errors?: ErrorObject[]|null) { - if (!errors) { - throw Error('ValidatorApiError thrown but no errors were found. This is possibly a bug.') - } - const errorStrings = errors.map(e => `${e.instancePath}: ${e.message}`).join(", "); - super(`Failed to validate: ${errorStrings}`, ErrCode.BadValue, -1, { - validationErrors: errors.map(e => ({message: e.message, path: e.instancePath})) - }); + constructor(errors?: ErrorObject[] | null) { + if (!errors) { + throw Error( + "ValidatorApiError thrown but no errors were found. This is possibly a bug.", + ); } + const errorStrings = errors + .map((e) => `${e.instancePath}: ${e.message}`) + .join(", "); + super(`Failed to validate: ${errorStrings}`, ErrCode.BadValue, -1, { + validationErrors: errors.map((e) => ({ + message: e.message, + path: e.instancePath, + })), + }); + } } - export function errorMiddleware(log: Logger) { - return (err: unknown, req: Request, res: Response, next: NextFunction) => { - if (!err) { - next(); - return; - } - const apiError = err instanceof ApiError ? err : ApiError.GenericFailure; - // Log a reduced error on info - log.info(`${req.method} ${req.path} ${apiError.statusCode} - ${apiError.errcode} - ${apiError.error}`); - // Only show full error on debug level. - log.debug(err); - if (res.headersSent) { - return; - } - apiError.apply(res); + return (err: unknown, req: Request, res: Response, next: NextFunction) => { + if (!err) { + next(); + return; + } + const apiError = err instanceof ApiError ? err : ApiError.GenericFailure; + // Log a reduced error on info + log.info( + `${req.method} ${req.path} ${apiError.statusCode} - ${apiError.errcode} - ${apiError.error}`, + ); + // Only show full error on debug level. + log.debug(err); + if (res.headersSent) { + return; } -} \ No newline at end of file + apiError.apply(res); + }; +} diff --git a/src/api/index.ts b/src/api/index.ts index 637eccf28..0ad13d914 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -1 +1 @@ -export * from "./error"; \ No newline at end of file +export * from "./error"; diff --git a/src/appservice.ts b/src/appservice.ts index 1437bdd7a..21688f62c 100644 --- a/src/appservice.ts +++ b/src/appservice.ts @@ -1,5 +1,11 @@ import { Logger } from "matrix-appservice-bridge"; -import { Appservice, IAppserviceCryptoStorageProvider, IAppserviceRegistration, RustSdkAppserviceCryptoStorageProvider, RustSdkCryptoStoreType } from "matrix-bot-sdk"; +import { + Appservice, + IAppserviceCryptoStorageProvider, + IAppserviceRegistration, + RustSdkAppserviceCryptoStorageProvider, + RustSdkCryptoStoreType, +} from "matrix-bot-sdk"; import { BridgeConfig } from "./config/Config"; import Metrics from "./Metrics"; import { MemoryStorageProvider } from "./Stores/MemoryStorageProvider"; @@ -7,50 +13,58 @@ import { RedisStorageProvider } from "./Stores/RedisStorageProvider"; import { IBridgeStorageProvider } from "./Stores/StorageProvider"; const log = new Logger("Appservice"); -export function getAppservice(config: BridgeConfig, registration: IAppserviceRegistration) { - let storage: IBridgeStorageProvider; - if (config.cache) { - log.info(`Initialising Redis storage`); - storage = new RedisStorageProvider(config.cache); - } else { - log.info('Initialising memory storage'); - storage = new MemoryStorageProvider(); - } +export function getAppservice( + config: BridgeConfig, + registration: IAppserviceRegistration, +) { + let storage: IBridgeStorageProvider; + if (config.cache) { + log.info(`Initialising Redis storage`); + storage = new RedisStorageProvider(config.cache); + } else { + log.info("Initialising memory storage"); + storage = new MemoryStorageProvider(); + } - let cryptoStorage: IAppserviceCryptoStorageProvider | undefined; - if (config.encryption?.storagePath) { - log.info('Initialising crypto storage') - cryptoStorage = new RustSdkAppserviceCryptoStorageProvider( - config.encryption.storagePath, - 0, - ); - } + let cryptoStorage: IAppserviceCryptoStorageProvider | undefined; + if (config.encryption?.storagePath) { + log.info("Initialising crypto storage"); + cryptoStorage = new RustSdkAppserviceCryptoStorageProvider( + config.encryption.storagePath, + 0, + ); + } - const appservice = new Appservice({ - homeserverName: config.bridge.domain, - homeserverUrl: config.bridge.url, - port: config.bridge.port, - bindAddress: config.bridge.bindAddress, - registration: { - ...registration, - namespaces: { - // Support multiple users - users: [{ - regex: '(' + registration.namespaces.users.map((r) => r.regex).join(')|(') + ')', - exclusive: true, - }], - aliases: registration.namespaces.aliases, - rooms: registration.namespaces.rooms, - } - }, - storage: storage, - intentOptions: { - encryption: !!cryptoStorage, - }, - cryptoStorage: cryptoStorage, - }); + const appservice = new Appservice({ + homeserverName: config.bridge.domain, + homeserverUrl: config.bridge.url, + port: config.bridge.port, + bindAddress: config.bridge.bindAddress, + registration: { + ...registration, + namespaces: { + // Support multiple users + users: [ + { + regex: + "(" + + registration.namespaces.users.map((r) => r.regex).join(")|(") + + ")", + exclusive: true, + }, + ], + aliases: registration.namespaces.aliases, + rooms: registration.namespaces.rooms, + }, + }, + storage: storage, + intentOptions: { + encryption: !!cryptoStorage, + }, + cryptoStorage: cryptoStorage, + }); - Metrics.registerMatrixSdkMetrics(appservice); + Metrics.registerMatrixSdkMetrics(appservice); - return {appservice, storage, cryptoStorage}; -} \ No newline at end of file + return { appservice, storage, cryptoStorage }; +} diff --git a/src/config/Config.ts b/src/config/Config.ts index eaddb5e1a..4e0b9d8b3 100644 --- a/src/config/Config.ts +++ b/src/config/Config.ts @@ -2,7 +2,11 @@ import YAML from "yaml"; import { promises as fs } from "fs"; -import { IAppserviceRegistration, LogLevel, MatrixClient } from "matrix-bot-sdk"; +import { + IAppserviceRegistration, + LogLevel, + MatrixClient, +} from "matrix-bot-sdk"; import * as assert from "assert"; import { configKey, hideKey } from "./Decorators"; import { BridgeConfigListener, ResourceTypeArray } from "../ListenerService"; @@ -13,727 +17,872 @@ import { ApiError, ErrCode } from "../api"; import { GithubInstance, GITHUB_CLOUD_URL } from "../github/GithubInstance"; import { Logger } from "matrix-appservice-bridge"; import { BridgeConfigCache } from "./sections/cache"; -import { BridgeConfigGenericWebhooks, BridgeConfigQueue, BridgeGenericWebhooksConfigYAML } from "./sections"; +import { + BridgeConfigGenericWebhooks, + BridgeConfigQueue, + BridgeGenericWebhooksConfigYAML, +} from "./sections"; import { GenericHookServiceConfig } from "../Connections"; import { BridgeConfigEncryption } from "./sections/encryption"; -import { BridgeOpenProjectConfig, BridgeOpenProjectConfigYAML } from "./sections/openproject"; +import { + BridgeOpenProjectConfig, + BridgeOpenProjectConfigYAML, +} from "./sections/openproject"; import { OpenProjectServiceConfig } from "../Connections/OpenProjectConnection"; const log = new Logger("Config"); - function makePrefixedUrl(urlString: string): URL { - return new URL(urlString.endsWith("/") ? urlString : urlString + "/"); + return new URL(urlString.endsWith("/") ? urlString : urlString + "/"); } export const ValidLogLevelStrings = [ - LogLevel.ERROR.toString(), - LogLevel.WARN.toString(), - LogLevel.INFO.toString(), - LogLevel.DEBUG.toString(), - LogLevel.TRACE.toString(), -].map(l => l.toLowerCase()); + LogLevel.ERROR.toString(), + LogLevel.WARN.toString(), + LogLevel.INFO.toString(), + LogLevel.DEBUG.toString(), + LogLevel.TRACE.toString(), +].map((l) => l.toLowerCase()); // Maps to permission_level_to_int in permissions.rs export enum BridgePermissionLevel { - "commands" = 1, - login = 2, - notifications = 3, - manageConnections = 4, - admin = 5, + "commands" = 1, + login = 2, + notifications = 3, + manageConnections = 4, + admin = 5, } interface BridgeConfigGitHubYAML { - enterpriseUrl?: string; - auth: { - id: number|string; - privateKeyFile: string; - }; - webhook: { - secret: string; - }; - oauth?: { - client_id: string; - client_secret: string; - redirect_uri: string; - }; - defaultOptions?: GitHubRepoConnectionOptions; - userIdPrefix?: string; + enterpriseUrl?: string; + auth: { + id: number | string; + privateKeyFile: string; + }; + webhook: { + secret: string; + }; + oauth?: { + client_id: string; + client_secret: string; + redirect_uri: string; + }; + defaultOptions?: GitHubRepoConnectionOptions; + userIdPrefix?: string; } export class BridgeConfigGitHub { - @configKey("Authentication for the GitHub App.", false) - readonly auth: { - id: number|string; - privateKeyFile: string; - }; - @configKey("Webhook settings for the GitHub app.", false) - readonly webhook: { - secret: string; - }; - @configKey("Settings for allowing users to sign in via OAuth.", true) - readonly oauth?: { - client_id: string; - client_secret: string; - redirect_uri: string; + @configKey("Authentication for the GitHub App.", false) + readonly auth: { + id: number | string; + privateKeyFile: string; + }; + @configKey("Webhook settings for the GitHub app.", false) + readonly webhook: { + secret: string; + }; + @configKey("Settings for allowing users to sign in via OAuth.", true) + readonly oauth?: { + client_id: string; + client_secret: string; + redirect_uri: string; + }; + @configKey("Default options for GitHub connections.", true) + readonly defaultOptions?: GitHubRepoConnectionOptions; + + @configKey("Prefix used when creating ghost users for GitHub accounts.", true) + readonly userIdPrefix: string; + + @configKey("URL for enterprise deployments. Does not include /api/v3", true) + private enterpriseUrl?: string; + + @hideKey() + public readonly baseUrl: URL; + + constructor(yaml: BridgeConfigGitHubYAML) { + this.auth = yaml.auth; + this.webhook = yaml.webhook; + this.oauth = yaml.oauth; + this.defaultOptions = yaml.defaultOptions; + this.userIdPrefix = yaml.userIdPrefix || "_github_"; + this.baseUrl = yaml.enterpriseUrl + ? new URL(yaml.enterpriseUrl) + : GITHUB_CLOUD_URL; + } + + public publicConfig(githubInstance?: GithubInstance) { + return { + userIdPrefix: this.userIdPrefix, + newInstallationUrl: githubInstance?.newInstallationUrl?.toString(), }; - @configKey("Default options for GitHub connections.", true) - readonly defaultOptions?: GitHubRepoConnectionOptions; - - @configKey("Prefix used when creating ghost users for GitHub accounts.", true) - readonly userIdPrefix: string; - - @configKey("URL for enterprise deployments. Does not include /api/v3", true) - private enterpriseUrl?: string; - - @hideKey() - public readonly baseUrl: URL; - - constructor(yaml: BridgeConfigGitHubYAML) { - this.auth = yaml.auth; - this.webhook = yaml.webhook; - this.oauth = yaml.oauth; - this.defaultOptions = yaml.defaultOptions; - this.userIdPrefix = yaml.userIdPrefix || "_github_"; - this.baseUrl = yaml.enterpriseUrl ? new URL(yaml.enterpriseUrl) : GITHUB_CLOUD_URL; - } - - public publicConfig(githubInstance?: GithubInstance) { - return { - userIdPrefix: this.userIdPrefix, - newInstallationUrl: githubInstance?.newInstallationUrl?.toString(), - } - } + } } export interface BridgeConfigJiraCloudOAuth { - client_id: string; - client_secret: string; - redirect_uri: string; + client_id: string; + client_secret: string; + redirect_uri: string; } export interface BridgeConfigJiraOnPremOAuth { - consumerKey: string; - privateKey: string; - redirect_uri: string; + consumerKey: string; + privateKey: string; + redirect_uri: string; } export interface BridgeConfigJiraYAML { - webhook: { - secret: string; - }; - url?: string, - oauth?: BridgeConfigJiraCloudOAuth|BridgeConfigJiraOnPremOAuth; - + webhook: { + secret: string; + }; + url?: string; + oauth?: BridgeConfigJiraCloudOAuth | BridgeConfigJiraOnPremOAuth; } export class BridgeConfigJira implements BridgeConfigJiraYAML { - static CLOUD_INSTANCE_NAME = "api.atlassian.com"; - - @configKey("Webhook settings for JIRA") - readonly webhook: { - secret: string; - }; - - // To hide the undefined for now - @hideKey() - @configKey("URL for the instance if using on prem. Ignore if targetting cloud (atlassian.net)", true) - readonly url?: string; - @configKey("OAuth settings for connecting users to JIRA. See documentation for more information", true) - readonly oauth?: BridgeConfigJiraCloudOAuth|BridgeConfigJiraOnPremOAuth; - - @hideKey() - readonly instanceUrl?: URL; - - @hideKey() - readonly instanceName: string; - - constructor(yaml: BridgeConfigJiraYAML) { - assert.ok(yaml.webhook); - assert.ok(yaml.webhook.secret); - this.webhook = yaml.webhook; - this.url = yaml.url; - this.instanceUrl = yaml.url !== undefined ? new URL(yaml.url) : undefined; - this.instanceName = this.instanceUrl?.host || BridgeConfigJira.CLOUD_INSTANCE_NAME; - if (!yaml.oauth) { - return; - } - let oauth: BridgeConfigJiraCloudOAuth|BridgeConfigJiraOnPremOAuth; - - assert.ok(yaml.oauth.redirect_uri); - // Validate oauth settings - if (this.url) { - // On-prem - oauth = yaml.oauth as BridgeConfigJiraOnPremOAuth; - assert.ok(oauth.consumerKey); - assert.ok(oauth.privateKey); - } else { - // Cloud - oauth = yaml.oauth as BridgeConfigJiraCloudOAuth; - assert.ok(oauth.client_id); - assert.ok(oauth.client_secret); - } - this.oauth = oauth; + static CLOUD_INSTANCE_NAME = "api.atlassian.com"; + + @configKey("Webhook settings for JIRA") + readonly webhook: { + secret: string; + }; + + // To hide the undefined for now + @hideKey() + @configKey( + "URL for the instance if using on prem. Ignore if targetting cloud (atlassian.net)", + true, + ) + readonly url?: string; + @configKey( + "OAuth settings for connecting users to JIRA. See documentation for more information", + true, + ) + readonly oauth?: BridgeConfigJiraCloudOAuth | BridgeConfigJiraOnPremOAuth; + + @hideKey() + readonly instanceUrl?: URL; + + @hideKey() + readonly instanceName: string; + + constructor(yaml: BridgeConfigJiraYAML) { + assert.ok(yaml.webhook); + assert.ok(yaml.webhook.secret); + this.webhook = yaml.webhook; + this.url = yaml.url; + this.instanceUrl = yaml.url !== undefined ? new URL(yaml.url) : undefined; + this.instanceName = + this.instanceUrl?.host || BridgeConfigJira.CLOUD_INSTANCE_NAME; + if (!yaml.oauth) { + return; + } + let oauth: BridgeConfigJiraCloudOAuth | BridgeConfigJiraOnPremOAuth; + + assert.ok(yaml.oauth.redirect_uri); + // Validate oauth settings + if (this.url) { + // On-prem + oauth = yaml.oauth as BridgeConfigJiraOnPremOAuth; + assert.ok(oauth.consumerKey); + assert.ok(oauth.privateKey); + } else { + // Cloud + oauth = yaml.oauth as BridgeConfigJiraCloudOAuth; + assert.ok(oauth.client_id); + assert.ok(oauth.client_secret); } + this.oauth = oauth; + } } export interface GitLabInstance { - url: string; + url: string; } export interface BridgeConfigGitLabYAML { - webhook: { - publicUrl?: string; - secret: string; - }, - instances: {[name: string]: GitLabInstance}; - userIdPrefix?: string; - commentDebounceMs?: number; + webhook: { + publicUrl?: string; + secret: string; + }; + instances: { [name: string]: GitLabInstance }; + userIdPrefix?: string; + commentDebounceMs?: number; } export class BridgeConfigGitLab { - readonly instances: {[name: string]: GitLabInstance}; - readonly webhook: { - publicUrl?: string; - secret: string; - }; - - @configKey("Prefix used when creating ghost users for GitLab accounts.", true) - readonly userIdPrefix: string; - - @configKey("Aggregate comments by waiting this many miliseconds before posting them to Matrix. Defaults to 5000 (5 seconds)", true) - readonly commentDebounceMs: number; - - constructor(yaml: BridgeConfigGitLabYAML) { - this.instances = yaml.instances; - this.webhook = yaml.webhook; - this.userIdPrefix = yaml.userIdPrefix || "_gitlab_"; - - for (const name in this.instances) { - const url = this.instances[name].url; - if (url.endsWith("/")) { - this.instances[name].url = url.slice(0, -1); - } - } - - if (yaml.commentDebounceMs === undefined) { - this.commentDebounceMs = 5000; - } else { - this.commentDebounceMs = yaml.commentDebounceMs; - } + readonly instances: { [name: string]: GitLabInstance }; + readonly webhook: { + publicUrl?: string; + secret: string; + }; + + @configKey("Prefix used when creating ghost users for GitLab accounts.", true) + readonly userIdPrefix: string; + + @configKey( + "Aggregate comments by waiting this many miliseconds before posting them to Matrix. Defaults to 5000 (5 seconds)", + true, + ) + readonly commentDebounceMs: number; + + constructor(yaml: BridgeConfigGitLabYAML) { + this.instances = yaml.instances; + this.webhook = yaml.webhook; + this.userIdPrefix = yaml.userIdPrefix || "_gitlab_"; + + for (const name in this.instances) { + const url = this.instances[name].url; + if (url.endsWith("/")) { + this.instances[name].url = url.slice(0, -1); + } } - @hideKey() - public get publicConfig() { - return { - userIdPrefix: this.userIdPrefix, - } + if (yaml.commentDebounceMs === undefined) { + this.commentDebounceMs = 5000; + } else { + this.commentDebounceMs = yaml.commentDebounceMs; } + } - - public getInstanceByProjectUrl(url: string): {name: string, instance: GitLabInstance}|null { - for (const [name, instance] of Object.entries(this.instances)) { - if (url.startsWith(instance.url)) { - return {name, instance}; - } - } - return null; + @hideKey() + public get publicConfig() { + return { + userIdPrefix: this.userIdPrefix, + }; + } + + public getInstanceByProjectUrl( + url: string, + ): { name: string; instance: GitLabInstance } | null { + for (const [name, instance] of Object.entries(this.instances)) { + if (url.startsWith(instance.url)) { + return { name, instance }; + } } + return null; + } } export interface BridgeConfigFeedsYAML { - enabled: boolean; - pollIntervalSeconds?: number; - pollConcurrency?: number; - pollTimeoutSeconds?: number; + enabled: boolean; + pollIntervalSeconds?: number; + pollConcurrency?: number; + pollTimeoutSeconds?: number; } export class BridgeConfigFeeds { - public enabled: boolean; - public pollIntervalSeconds: number; - public pollTimeoutSeconds: number; - public pollConcurrency: number; - - constructor(yaml: BridgeConfigFeedsYAML) { - this.enabled = yaml.enabled; - this.pollConcurrency = yaml.pollConcurrency ?? 4; - this.pollIntervalSeconds = yaml.pollIntervalSeconds ?? 600; - assert.strictEqual(typeof this.pollIntervalSeconds, "number"); - this.pollTimeoutSeconds = yaml.pollTimeoutSeconds ?? 30; - assert.strictEqual(typeof this.pollTimeoutSeconds, "number"); - } - - @hideKey() - public get publicConfig() { - return { - pollIntervalSeconds: this.pollIntervalSeconds, - } - } + public enabled: boolean; + public pollIntervalSeconds: number; + public pollTimeoutSeconds: number; + public pollConcurrency: number; + + constructor(yaml: BridgeConfigFeedsYAML) { + this.enabled = yaml.enabled; + this.pollConcurrency = yaml.pollConcurrency ?? 4; + this.pollIntervalSeconds = yaml.pollIntervalSeconds ?? 600; + assert.strictEqual(typeof this.pollIntervalSeconds, "number"); + this.pollTimeoutSeconds = yaml.pollTimeoutSeconds ?? 30; + assert.strictEqual(typeof this.pollTimeoutSeconds, "number"); + } + + @hideKey() + public get publicConfig() { + return { + pollIntervalSeconds: this.pollIntervalSeconds, + }; + } } export interface BridgeConfigFigma { - publicUrl: string; - overrideUserId?: string; - instances: {[name: string]: { - teamId: string; - accessToken: string; - passcode: string; - }}; + publicUrl: string; + overrideUserId?: string; + instances: { + [name: string]: { + teamId: string; + accessToken: string; + passcode: string; + }; + }; } interface BridgeWidgetConfigYAML { - publicUrl: string; - /** - * @deprecated Prefer using listener config. - */ - port?: number; - addToAdminRooms?: boolean; - roomSetupWidget?: { - addOnInvite?: boolean; - }; - disallowedIpRanges?: string[]; - branding?: { - widgetTitle: string, - } - openIdOverrides?: Record; + publicUrl: string; + /** + * @deprecated Prefer using listener config. + */ + port?: number; + addToAdminRooms?: boolean; + roomSetupWidget?: { + addOnInvite?: boolean; + }; + disallowedIpRanges?: string[]; + branding?: { + widgetTitle: string; + }; + openIdOverrides?: Record; } export class BridgeWidgetConfig { - public readonly addToAdminRooms: boolean; - - @hideKey() - public readonly parsedPublicUrl: URL; - public readonly publicUrl: () => string; - - public readonly roomSetupWidget?: { - addOnInvite?: boolean; + public readonly addToAdminRooms: boolean; + + @hideKey() + public readonly parsedPublicUrl: URL; + public readonly publicUrl: () => string; + + public readonly roomSetupWidget?: { + addOnInvite?: boolean; + }; + public readonly disallowedIpRanges?: string[]; + public readonly branding: { + widgetTitle: string; + }; + + @hideKey() + public readonly openIdOverrides?: Record; + constructor(yaml: BridgeWidgetConfigYAML) { + this.addToAdminRooms = yaml.addToAdminRooms || false; + this.disallowedIpRanges = yaml.disallowedIpRanges; + this.roomSetupWidget = yaml.roomSetupWidget; + if ( + yaml.disallowedIpRanges !== undefined && + (!Array.isArray(yaml.disallowedIpRanges) || + !yaml.disallowedIpRanges.every((s) => typeof s === "string")) + ) { + throw new ConfigError( + "widgets.disallowedIpRanges", + "must be a string array", + ); + } + try { + this.parsedPublicUrl = makePrefixedUrl(yaml.publicUrl); + this.publicUrl = () => { + return this.parsedPublicUrl.href; + }; + } catch { + throw new ConfigError( + "widgets.publicUrl", + "is not defined or not a valid URL", + ); + } + this.branding = yaml.branding || { + widgetTitle: "Hookshot Configuration", }; - public readonly disallowedIpRanges?: string[]; - public readonly branding: { - widgetTitle: string, - } - - @hideKey() - public readonly openIdOverrides?: Record; - constructor(yaml: BridgeWidgetConfigYAML) { - this.addToAdminRooms = yaml.addToAdminRooms || false; - this.disallowedIpRanges = yaml.disallowedIpRanges; - this.roomSetupWidget = yaml.roomSetupWidget; - if (yaml.disallowedIpRanges !== undefined && (!Array.isArray(yaml.disallowedIpRanges) || !yaml.disallowedIpRanges.every(s => typeof s === "string"))) { - throw new ConfigError("widgets.disallowedIpRanges", "must be a string array"); - } - try { - this.parsedPublicUrl = makePrefixedUrl(yaml.publicUrl) - this.publicUrl = () => { return this.parsedPublicUrl.href; } - } catch { - throw new ConfigError("widgets.publicUrl", "is not defined or not a valid URL"); - } - this.branding = yaml.branding || { - widgetTitle: "Hookshot Configuration" - }; - if (yaml.openIdOverrides) { - this.openIdOverrides = {}; - for (const [serverName, urlStr] of Object.entries(yaml.openIdOverrides)) { - this.openIdOverrides[serverName] = new URL(urlStr); - } - } + if (yaml.openIdOverrides) { + this.openIdOverrides = {}; + for (const [serverName, urlStr] of Object.entries(yaml.openIdOverrides)) { + this.openIdOverrides[serverName] = new URL(urlStr); + } } + } } - interface BridgeConfigBridge { - domain: string; - url: string; - mediaUrl?: string; - port: number; - bindAddress: string; + domain: string; + url: string; + mediaUrl?: string; + port: number; + bindAddress: string; } interface BridgeConfigWebhook { - port?: number; - bindAddress?: string; + port?: number; + bindAddress?: string; } export interface BridgeConfigLogging { - level: "debug"|"info"|"warn"|"error"|"trace"; - json?: boolean; - colorize?: boolean; - timestampFormat?: string; + level: "debug" | "info" | "warn" | "error" | "trace"; + json?: boolean; + colorize?: boolean; + timestampFormat?: string; } interface BridgeConfigBot { - displayname?: string; - avatar?: string; + displayname?: string; + avatar?: string; } - export interface BridgeConfigServiceBot { - localpart: string; - displayname?: string; - avatar?: string; - prefix: string; - service: string; + localpart: string; + displayname?: string; + avatar?: string; + prefix: string; + service: string; } export interface BridgeConfigMetrics { - enabled: boolean; - bindAddress?: string; - port?: number; + enabled: boolean; + bindAddress?: string; + port?: number; } export interface BridgeConfigSentry { - dsn: string; - environment?: string; + dsn: string; + environment?: string; } export interface BridgeConfigChallengeHound { - token?: string; + token?: string; } - export interface BridgeConfigRoot { - bot?: BridgeConfigBot; - bridge: BridgeConfigBridge; - cache?: BridgeConfigCache; - /** - * @deprecated Old, unsupported encryption propety. - */ - experimentalEncryption?: never; - encryption?: BridgeConfigEncryption; - feeds?: BridgeConfigFeedsYAML; - figma?: BridgeConfigFigma; - generic?: BridgeGenericWebhooksConfigYAML; - github?: BridgeConfigGitHubYAML; - gitlab?: BridgeConfigGitLabYAML; - jira?: BridgeConfigJiraYAML; - listeners?: BridgeConfigListener[]; - logging: BridgeConfigLogging; - metrics?: BridgeConfigMetrics; - passFile: string; - permissions?: BridgeConfigActorPermission[]; - openProject?: BridgeOpenProjectConfigYAML; - queue?: BridgeConfigQueue; - sentry?: BridgeConfigSentry; - serviceBots?: BridgeConfigServiceBot[]; - webhook?: BridgeConfigWebhook; - widgets?: BridgeWidgetConfigYAML; - challengeHound?: BridgeConfigChallengeHound; + bot?: BridgeConfigBot; + bridge: BridgeConfigBridge; + cache?: BridgeConfigCache; + /** + * @deprecated Old, unsupported encryption propety. + */ + experimentalEncryption?: never; + encryption?: BridgeConfigEncryption; + feeds?: BridgeConfigFeedsYAML; + figma?: BridgeConfigFigma; + generic?: BridgeGenericWebhooksConfigYAML; + github?: BridgeConfigGitHubYAML; + gitlab?: BridgeConfigGitLabYAML; + jira?: BridgeConfigJiraYAML; + listeners?: BridgeConfigListener[]; + logging: BridgeConfigLogging; + metrics?: BridgeConfigMetrics; + passFile: string; + permissions?: BridgeConfigActorPermission[]; + openProject?: BridgeOpenProjectConfigYAML; + queue?: BridgeConfigQueue; + sentry?: BridgeConfigSentry; + serviceBots?: BridgeConfigServiceBot[]; + webhook?: BridgeConfigWebhook; + widgets?: BridgeWidgetConfigYAML; + challengeHound?: BridgeConfigChallengeHound; } export class BridgeConfig { - @configKey("Basic homeserver configuration") - public readonly bridge: BridgeConfigBridge; - @configKey(`Cache options for large scale deployments. - For encryption to work, this must be configured.`, true) - public readonly cache?: BridgeConfigCache; - @configKey(`Configuration for encryption support in the bridge. - If omitted, encryption support will be disabled.`, true) - public readonly encryption?: BridgeConfigEncryption; - @configKey(`Message queue configuration options for large scale deployments. - For encryption to work, this must not be configured.`, true) - public readonly queue?: Omit; - @configKey("Logging settings. You can have a severity debug,info,warn,error") - public readonly logging: BridgeConfigLogging; - @configKey(`Permissions for using the bridge. See docs/setup.md#permissions for help`, true) - public readonly permissions: BridgeConfigActorPermission[]; - @configKey(`A passkey used to encrypt tokens stored inside the bridge. + @configKey("Basic homeserver configuration") + public readonly bridge: BridgeConfigBridge; + @configKey( + `Cache options for large scale deployments. + For encryption to work, this must be configured.`, + true, + ) + public readonly cache?: BridgeConfigCache; + @configKey( + `Configuration for encryption support in the bridge. + If omitted, encryption support will be disabled.`, + true, + ) + public readonly encryption?: BridgeConfigEncryption; + @configKey( + `Message queue configuration options for large scale deployments. + For encryption to work, this must not be configured.`, + true, + ) + public readonly queue?: Omit; + @configKey("Logging settings. You can have a severity debug,info,warn,error") + public readonly logging: BridgeConfigLogging; + @configKey( + `Permissions for using the bridge. See docs/setup.md#permissions for help`, + true, + ) + public readonly permissions: BridgeConfigActorPermission[]; + @configKey(`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`) - public readonly passFile: string; - @configKey("Configure this to enable GitHub support", true) - public readonly github?: BridgeConfigGitHub; - @configKey("Configure this to enable GitLab support", true) - public readonly gitlab?: BridgeConfigGitLab; - @configKey("Configure this to enable Jira support. Only specify `url` if you are using a On Premise install (i.e. not atlassian.com)", true) - public readonly jira?: BridgeConfigJira; - @configKey(`Support for generic webhook events. + public readonly passFile: string; + @configKey("Configure this to enable GitHub support", true) + public readonly github?: BridgeConfigGitHub; + @configKey("Configure this to enable GitLab support", true) + public readonly gitlab?: BridgeConfigGitLab; + @configKey( + "Configure this to enable Jira support. Only specify `url` if you are using a On Premise install (i.e. not atlassian.com)", + true, + ) + public readonly jira?: BridgeConfigJira; + @configKey( + `Support for generic webhook events. 'allowJsTransformationFunctions' will allow users to write short transformation snippets in code, and thus is unsafe in untrusted environments -`, true) - public readonly generic?: BridgeConfigGenericWebhooks; - @configKey("Configure this to enable Figma support", true) - public readonly figma?: BridgeConfigFigma; - @configKey("Configure this to enable RSS/Atom feed support", true) - public readonly feeds?: BridgeConfigFeeds; - @configKey("Configure Challenge Hound support", true) - public readonly challengeHound?: BridgeConfigChallengeHound; - @configKey("Configure OpenProject support", true) - public readonly openProject?: BridgeOpenProjectConfig; - @configKey("Define profile information for the bot user", true) - public readonly bot?: BridgeConfigBot; - @configKey("Define additional bot users for specific services", true) - public readonly serviceBots?: BridgeConfigServiceBot[]; - @configKey("EXPERIMENTAL support for complimentary widgets", true) - public readonly widgets?: BridgeWidgetConfig; - @configKey("Prometheus metrics support", true) - public readonly metrics?: BridgeConfigMetrics; - - @configKey(`HTTP Listener configuration. +`, + true, + ) + public readonly generic?: BridgeConfigGenericWebhooks; + @configKey("Configure this to enable Figma support", true) + public readonly figma?: BridgeConfigFigma; + @configKey("Configure this to enable RSS/Atom feed support", true) + public readonly feeds?: BridgeConfigFeeds; + @configKey("Configure Challenge Hound support", true) + public readonly challengeHound?: BridgeConfigChallengeHound; + @configKey("Configure OpenProject support", true) + public readonly openProject?: BridgeOpenProjectConfig; + @configKey("Define profile information for the bot user", true) + public readonly bot?: BridgeConfigBot; + @configKey("Define additional bot users for specific services", true) + public readonly serviceBots?: BridgeConfigServiceBot[]; + @configKey("EXPERIMENTAL support for complimentary widgets", true) + public readonly widgets?: BridgeWidgetConfig; + @configKey("Prometheus metrics support", true) + public readonly metrics?: BridgeConfigMetrics; + + @configKey(`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 ${ResourceTypeArray.join(', ')}`) - public readonly listeners: BridgeConfigListener[]; - - @configKey("Configure Sentry error reporting", true) - public readonly sentry?: BridgeConfigSentry; - - @hideKey() - private readonly bridgePermissions: BridgePermissions; - - - - constructor(configData: BridgeConfigRoot, env?: {[key: string]: string|undefined}) { - this.logging = configData.logging || { - level: "info", - } - this.bridge = configData.bridge; - assert.ok(this.bridge); - this.github = configData.github && new BridgeConfigGitHub(configData.github); - if (this.github?.auth && env?.["GITHUB_PRIVATE_KEY_FILE"]) { - this.github.auth.privateKeyFile = env?.["GITHUB_PRIVATE_KEY_FILE"]; - } - if (this.github?.oauth && env?.["GITHUB_OAUTH_REDIRECT_URI"]) { - this.github.oauth.redirect_uri = env?.["GITHUB_OAUTH_REDIRECT_URI"]; - } - this.gitlab = configData.gitlab && new BridgeConfigGitLab(configData.gitlab); - this.figma = configData.figma; - this.jira = configData.jira && new BridgeConfigJira(configData.jira); - this.generic = configData.generic && new BridgeConfigGenericWebhooks(configData.generic); - this.feeds = configData.feeds && new BridgeConfigFeeds(configData.feeds); - this.passFile = configData.passFile ?? "./passkey.pem"; - this.bot = configData.bot; - this.serviceBots = configData.serviceBots; - this.metrics = configData.metrics; - this.challengeHound = configData.challengeHound; - - this.openProject = configData.openProject && new BridgeOpenProjectConfig(configData.openProject); - - // TODO: Formalize env support - if (env?.CFG_QUEUE_MONOLITHIC && ["false", "off", "no"].includes(env.CFG_QUEUE_MONOLITHIC)) { - if (!env?.CFG_QUEUE_HOST) { - throw new ConfigError("env:CFG_QUEUE_HOST", "CFG_QUEUE_MONOLITHIC was defined but host was not"); - } - configData.queue = { - monolithic: false, - host: env?.CFG_QUEUE_HOST, - port: env?.CFG_QUEUE_POST ? parseInt(env?.CFG_QUEUE_POST, 10) : undefined, - } - } - - this.cache = configData.cache; - this.queue = configData.queue; - - if (configData.queue?.monolithic !== undefined) { - log.warn("The `queue.monolithic` config option is deprecated. Instead, configure the `cache` section."); - this.cache = { - redisUri: 'redisUri' in configData.queue ? configData.queue.redisUri - : `redis://${configData.queue.host ?? 'localhost'}:${configData.queue.port ?? 6379}` - }; - // If monolithic, disable the redis queue. - if (configData.queue.monolithic === true) { - this.queue = undefined; - } - } - - if (configData.experimentalEncryption) { - throw new ConfigError("experimentalEncryption", `This key is now called 'encryption'. Please adjust your config file.`) - } - - this.encryption = configData.encryption && new BridgeConfigEncryption(configData.encryption, this.cache, this.queue); - this.widgets = configData.widgets && new BridgeWidgetConfig(configData.widgets); - this.sentry = configData.sentry; - - // To allow DEBUG as well as debug - this.logging.level = this.logging.level.toLowerCase() as "debug"|"info"|"warn"|"error"|"trace"; - if (!ValidLogLevelStrings.includes(this.logging.level)) { - throw new ConfigError("logging.level", `Logging level is not valid. Must be one of ${ValidLogLevelStrings.join(', ')}`) - } - - this.permissions = configData.permissions || [{ - actor: this.bridge.domain, - services: [{ - service: '*', - level: BridgePermissionLevel[BridgePermissionLevel.admin], - }] - }]; - this.bridgePermissions = new BridgePermissions(this.permissions); - - if (!configData.permissions) { - log.warn(`You have not configured any permissions for the bridge, which by default means all users on ${this.bridge.domain} have admin levels of control. Please adjust your config.`); - } - - if (!this.github && !this.gitlab && !this.jira && !this.generic && !this.figma && !this.feeds) { - throw Error("Config is not valid: At least one of GitHub, GitLab, JIRA, Figma, feeds or generic hooks must be configured"); - } - - if ('goNebMigrator' in configData) { - log.warn(`The GoNEB migrator has been removed from Hookshot. You should remove the 'goNebMigrator' from your config.`); - } - - if ('provisioning' in configData) { - log.warn(`The provisioning API has been removed from Hookshot. You should remove the 'provisioning' from your config.`); - } - - // Listeners is a bit special - this.listeners = configData.listeners || []; - - // For legacy reasons, copy across the per-service listener config into the listeners array. - if (configData.webhook?.port) { - this.listeners.push({ - resources: ['webhooks'], - port: configData.webhook.port, - bindAddress: configData.webhook.bindAddress, - }); - log.warn("The `webhook` configuration still specifies a port/bindAddress. This should be moved to the `listeners` config."); - } - - if (configData.widgets?.port) { - this.listeners.push({ - resources: ['widgets'], - port: configData.widgets.port, - }) - } - - if (this.metrics?.port) { - this.listeners.push({ - resources: ['metrics'], - port: this.metrics.port, - bindAddress: this.metrics.bindAddress, - }) - log.warn("The `metrics` configuration still specifies a port/bindAddress. This should be moved to the `listeners` config."); - } - - if (configData.widgets?.port) { - this.listeners.push({ - resources: ['widgets'], - port: configData.widgets.port, - }); - log.warn("The `widgets` configuration still specifies a port/bindAddress. This should be moved to the `listeners` config."); - } - - const hasWidgetListener = !!this.listeners.find(l => l.resources.includes('widgets')); - if (this.widgets && !hasWidgetListener) { - throw new ConfigError(`listeners`, "You have enabled the widgets feature, but not included a widgets listener."); - } - - if (this.widgets && this.widgets.openIdOverrides) { - log.warn("The `widgets.openIdOverrides` config value SHOULD NOT be used in a production environment.") - } - - if (this.figma?.overrideUserId) { - log.warn("The `figma.overrideUserId` config value is deprecated. A service bot should be configured instead."); - } - } - - public async prefillMembershipCache(client: MatrixClient) { - const permissionRooms = this.bridgePermissions.getInterestedRooms(); - log.info(`Prefilling room membership for permissions for ${permissionRooms.length} rooms`); - for(const roomEntry of permissionRooms) { - const roomId = await client.resolveRoom(roomEntry); - // Attempt to join the room - await client.joinRoom(roomEntry); - const membership = await client.getJoinedRoomMembers(roomId); - membership.forEach(userId => this.bridgePermissions.addMemberToCache(roomEntry, userId)); - log.debug(`Found ${membership.length} users for ${roomEntry}`); - } - } - - public addMemberToCache(roomId: string, userId: string) { - this.bridgePermissions.addMemberToCache(roomId, userId); - } - - public removeMemberFromCache(roomId: string, userId: string) { - this.bridgePermissions.removeMemberFromCache(roomId, userId); - } - - public checkPermissionAny(mxid: string, permission: BridgePermissionLevel) { - return this.bridgePermissions.checkActionAny(mxid, BridgePermissionLevel[permission]); - } - - public checkPermission(mxid: string, service: string, permission: BridgePermissionLevel) { - return this.bridgePermissions.checkAction(mxid, service, BridgePermissionLevel[permission]); - } - - public get enabledServices(): string[] { - const services = []; - if (this.feeds && this.feeds.enabled) { - services.push("feeds"); - } - if (this.figma) { - services.push("figma"); - } - if (this.generic && this.generic.enabled) { - services.push("generic"); - if (this.generic.outbound) { - services.push("genericOutbound"); - } - } - if (this.github) { - services.push("github"); - } - if (this.gitlab) { - services.push("gitlab"); - } - if (this.jira) { - services.push("jira"); - } - if (this.challengeHound) { - services.push("challengehound"); - } - if (this.openProject) { - services.push("openproject"); - } - return services; - } - - public async getPublicConfigForService(serviceName: string): Promise|GenericHookServiceConfig|OpenProjectServiceConfig> { - let config: undefined|Record|GenericHookServiceConfig|OpenProjectServiceConfig; - switch (serviceName) { - case "feeds": - config = this.feeds?.publicConfig; - break; - case "generic": - config = await this.generic?.publicConfig; - break; - case "github": - config = this.github?.publicConfig(); - break; - case "gitlab": - config = this.gitlab?.publicConfig; - break; - case "genericOutbound": - case "jira": - case "openproject": - config = this.openProject?.publicConfig; - break; - default: - throw new ApiError("Not a known service, or service doesn't expose a config", ErrCode.NotFound); - } - - if (!config) { - throw new ApiError("Service is not enabled", ErrCode.DisabledFeature); - } - return config; - } - - static async parseConfig(filename: string, env: {[key: string]: string|undefined}) { - const file = await fs.readFile(filename, "utf-8"); - return new BridgeConfig(YAML.parse(file), env); + 'resources' may be any of ${ResourceTypeArray.join(", ")}`) + public readonly listeners: BridgeConfigListener[]; + + @configKey("Configure Sentry error reporting", true) + public readonly sentry?: BridgeConfigSentry; + + @hideKey() + private readonly bridgePermissions: BridgePermissions; + + constructor( + configData: BridgeConfigRoot, + env?: { [key: string]: string | undefined }, + ) { + this.logging = configData.logging || { + level: "info", + }; + this.bridge = configData.bridge; + assert.ok(this.bridge); + this.github = + configData.github && new BridgeConfigGitHub(configData.github); + if (this.github?.auth && env?.["GITHUB_PRIVATE_KEY_FILE"]) { + this.github.auth.privateKeyFile = env?.["GITHUB_PRIVATE_KEY_FILE"]; + } + if (this.github?.oauth && env?.["GITHUB_OAUTH_REDIRECT_URI"]) { + this.github.oauth.redirect_uri = env?.["GITHUB_OAUTH_REDIRECT_URI"]; + } + this.gitlab = + configData.gitlab && new BridgeConfigGitLab(configData.gitlab); + this.figma = configData.figma; + this.jira = configData.jira && new BridgeConfigJira(configData.jira); + this.generic = + configData.generic && new BridgeConfigGenericWebhooks(configData.generic); + this.feeds = configData.feeds && new BridgeConfigFeeds(configData.feeds); + this.passFile = configData.passFile ?? "./passkey.pem"; + this.bot = configData.bot; + this.serviceBots = configData.serviceBots; + this.metrics = configData.metrics; + this.challengeHound = configData.challengeHound; + + this.openProject = + configData.openProject && + new BridgeOpenProjectConfig(configData.openProject); + + // TODO: Formalize env support + if ( + env?.CFG_QUEUE_MONOLITHIC && + ["false", "off", "no"].includes(env.CFG_QUEUE_MONOLITHIC) + ) { + if (!env?.CFG_QUEUE_HOST) { + throw new ConfigError( + "env:CFG_QUEUE_HOST", + "CFG_QUEUE_MONOLITHIC was defined but host was not", + ); + } + configData.queue = { + monolithic: false, + host: env?.CFG_QUEUE_HOST, + port: env?.CFG_QUEUE_POST + ? parseInt(env?.CFG_QUEUE_POST, 10) + : undefined, + }; } -} -export async function parseRegistrationFile(filename: string) { + this.cache = configData.cache; + this.queue = configData.queue; + + if (configData.queue?.monolithic !== undefined) { + log.warn( + "The `queue.monolithic` config option is deprecated. Instead, configure the `cache` section.", + ); + this.cache = { + redisUri: + "redisUri" in configData.queue + ? configData.queue.redisUri + : `redis://${configData.queue.host ?? "localhost"}:${configData.queue.port ?? 6379}`, + }; + // If monolithic, disable the redis queue. + if (configData.queue.monolithic === true) { + this.queue = undefined; + } + } + + if (configData.experimentalEncryption) { + throw new ConfigError( + "experimentalEncryption", + `This key is now called 'encryption'. Please adjust your config file.`, + ); + } + + this.encryption = + configData.encryption && + new BridgeConfigEncryption(configData.encryption, this.cache, this.queue); + this.widgets = + configData.widgets && new BridgeWidgetConfig(configData.widgets); + this.sentry = configData.sentry; + + // To allow DEBUG as well as debug + this.logging.level = this.logging.level.toLowerCase() as + | "debug" + | "info" + | "warn" + | "error" + | "trace"; + if (!ValidLogLevelStrings.includes(this.logging.level)) { + throw new ConfigError( + "logging.level", + `Logging level is not valid. Must be one of ${ValidLogLevelStrings.join(", ")}`, + ); + } + + this.permissions = configData.permissions || [ + { + actor: this.bridge.domain, + services: [ + { + service: "*", + level: BridgePermissionLevel[BridgePermissionLevel.admin], + }, + ], + }, + ]; + this.bridgePermissions = new BridgePermissions(this.permissions); + + if (!configData.permissions) { + log.warn( + `You have not configured any permissions for the bridge, which by default means all users on ${this.bridge.domain} have admin levels of control. Please adjust your config.`, + ); + } + + if ( + !this.github && + !this.gitlab && + !this.jira && + !this.generic && + !this.figma && + !this.feeds + ) { + throw Error( + "Config is not valid: At least one of GitHub, GitLab, JIRA, Figma, feeds or generic hooks must be configured", + ); + } + + if ("goNebMigrator" in configData) { + log.warn( + `The GoNEB migrator has been removed from Hookshot. You should remove the 'goNebMigrator' from your config.`, + ); + } + + if ("provisioning" in configData) { + log.warn( + `The provisioning API has been removed from Hookshot. You should remove the 'provisioning' from your config.`, + ); + } + + // Listeners is a bit special + this.listeners = configData.listeners || []; + + // For legacy reasons, copy across the per-service listener config into the listeners array. + if (configData.webhook?.port) { + this.listeners.push({ + resources: ["webhooks"], + port: configData.webhook.port, + bindAddress: configData.webhook.bindAddress, + }); + log.warn( + "The `webhook` configuration still specifies a port/bindAddress. This should be moved to the `listeners` config.", + ); + } + + if (configData.widgets?.port) { + this.listeners.push({ + resources: ["widgets"], + port: configData.widgets.port, + }); + } + + if (this.metrics?.port) { + this.listeners.push({ + resources: ["metrics"], + port: this.metrics.port, + bindAddress: this.metrics.bindAddress, + }); + log.warn( + "The `metrics` configuration still specifies a port/bindAddress. This should be moved to the `listeners` config.", + ); + } + + if (configData.widgets?.port) { + this.listeners.push({ + resources: ["widgets"], + port: configData.widgets.port, + }); + log.warn( + "The `widgets` configuration still specifies a port/bindAddress. This should be moved to the `listeners` config.", + ); + } + + const hasWidgetListener = !!this.listeners.find((l) => + l.resources.includes("widgets"), + ); + if (this.widgets && !hasWidgetListener) { + throw new ConfigError( + `listeners`, + "You have enabled the widgets feature, but not included a widgets listener.", + ); + } + + if (this.widgets && this.widgets.openIdOverrides) { + log.warn( + "The `widgets.openIdOverrides` config value SHOULD NOT be used in a production environment.", + ); + } + + if (this.figma?.overrideUserId) { + log.warn( + "The `figma.overrideUserId` config value is deprecated. A service bot should be configured instead.", + ); + } + } + + public async prefillMembershipCache(client: MatrixClient) { + const permissionRooms = this.bridgePermissions.getInterestedRooms(); + log.info( + `Prefilling room membership for permissions for ${permissionRooms.length} rooms`, + ); + for (const roomEntry of permissionRooms) { + const roomId = await client.resolveRoom(roomEntry); + // Attempt to join the room + await client.joinRoom(roomEntry); + const membership = await client.getJoinedRoomMembers(roomId); + membership.forEach((userId) => + this.bridgePermissions.addMemberToCache(roomEntry, userId), + ); + log.debug(`Found ${membership.length} users for ${roomEntry}`); + } + } + + public addMemberToCache(roomId: string, userId: string) { + this.bridgePermissions.addMemberToCache(roomId, userId); + } + + public removeMemberFromCache(roomId: string, userId: string) { + this.bridgePermissions.removeMemberFromCache(roomId, userId); + } + + public checkPermissionAny(mxid: string, permission: BridgePermissionLevel) { + return this.bridgePermissions.checkActionAny( + mxid, + BridgePermissionLevel[permission], + ); + } + + public checkPermission( + mxid: string, + service: string, + permission: BridgePermissionLevel, + ) { + return this.bridgePermissions.checkAction( + mxid, + service, + BridgePermissionLevel[permission], + ); + } + + public get enabledServices(): string[] { + const services = []; + if (this.feeds && this.feeds.enabled) { + services.push("feeds"); + } + if (this.figma) { + services.push("figma"); + } + if (this.generic && this.generic.enabled) { + services.push("generic"); + if (this.generic.outbound) { + services.push("genericOutbound"); + } + } + if (this.github) { + services.push("github"); + } + if (this.gitlab) { + services.push("gitlab"); + } + if (this.jira) { + services.push("jira"); + } + if (this.challengeHound) { + services.push("challengehound"); + } + if (this.openProject) { + services.push("openproject"); + } + return services; + } + + public async getPublicConfigForService( + serviceName: string, + ): Promise< + | Record + | GenericHookServiceConfig + | OpenProjectServiceConfig + > { + let config: + | undefined + | Record + | GenericHookServiceConfig + | OpenProjectServiceConfig; + switch (serviceName) { + case "feeds": + config = this.feeds?.publicConfig; + break; + case "generic": + config = await this.generic?.publicConfig; + break; + case "github": + config = this.github?.publicConfig(); + break; + case "gitlab": + config = this.gitlab?.publicConfig; + break; + case "genericOutbound": + case "jira": + case "openproject": + config = this.openProject?.publicConfig; + break; + default: + throw new ApiError( + "Not a known service, or service doesn't expose a config", + ErrCode.NotFound, + ); + } + + if (!config) { + throw new ApiError("Service is not enabled", ErrCode.DisabledFeature); + } + return config; + } + + static async parseConfig( + filename: string, + env: { [key: string]: string | undefined }, + ) { const file = await fs.readFile(filename, "utf-8"); - return YAML.parse(file) as IAppserviceRegistration; + return new BridgeConfig(YAML.parse(file), env); + } } +export async function parseRegistrationFile(filename: string) { + const file = await fs.readFile(filename, "utf-8"); + return YAML.parse(file) as IAppserviceRegistration; +} // Can be called directly if (require.main === module) { - Logger.configure({console: "info"}); - BridgeConfig.parseConfig(process.argv[2] || "config.yml", process.env).then(() => { - console.log('Config successfully validated.'); - process.exit(0); - }).catch(ex => { - console.error('Error in config:', ex); - process.exit(1); + Logger.configure({ console: "info" }); + BridgeConfig.parseConfig(process.argv[2] || "config.yml", process.env) + .then(() => { + console.log("Config successfully validated."); + process.exit(0); + }) + .catch((ex) => { + console.error("Error in config:", ex); + process.exit(1); }); } diff --git a/src/config/Decorators.ts b/src/config/Decorators.ts index d4e7f5349..0eb2f5f55 100644 --- a/src/config/Decorators.ts +++ b/src/config/Decorators.ts @@ -6,9 +6,12 @@ export function configKey(comment?: string, optional = false) { return Reflect.metadata(configKeyMetadataKey, [comment, optional]); } -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export function getConfigKeyMetadata(target: any, propertyKey: string): [string, boolean]|null { - return Reflect.getMetadata(configKeyMetadataKey, target, propertyKey); + +export function getConfigKeyMetadata( + target: any, + propertyKey: string, +): [string, boolean] | null { + return Reflect.getMetadata(configKeyMetadataKey, target, propertyKey); } const hideKeyMetadataKey = Symbol("hideKey"); @@ -16,8 +19,9 @@ export function hideKey() { return Reflect.metadata(hideKeyMetadataKey, true); } - // eslint-disable-next-line @typescript-eslint/no-explicit-any export function keyIsHidden(target: any, propertyKey: string): boolean { - return Reflect.getMetadata(hideKeyMetadataKey, target, propertyKey) !== undefined; -} \ No newline at end of file + return ( + Reflect.getMetadata(hideKeyMetadataKey, target, propertyKey) !== undefined + ); +} diff --git a/src/config/Defaults.ts b/src/config/Defaults.ts index 7d159c76c..4c029c613 100644 --- a/src/config/Defaults.ts +++ b/src/config/Defaults.ts @@ -10,266 +10,288 @@ const serverName = "example.com"; const hookshotWebhooksUrl = "https://example.com"; export const DefaultConfigRoot: BridgeConfigRoot = { - bridge: { - domain: serverName, - url: "http://localhost:8008", - mediaUrl: "https://example.com", - port: 9993, - bindAddress: "127.0.0.1", + bridge: { + domain: serverName, + url: "http://localhost:8008", + mediaUrl: "https://example.com", + port: 9993, + bindAddress: "127.0.0.1", + }, + cache: { + redisUri: "redis://localhost:6379", + }, + logging: { + level: "info", + colorize: true, + json: false, + timestampFormat: "HH:mm:ss:SSS", + }, + permissions: [ + { + actor: serverName, + services: [ + { + service: "*", + level: "admin", + }, + ], }, - cache: { - redisUri: "redis://localhost:6379", + ], + passFile: "./passkey.pem", + widgets: { + publicUrl: `${hookshotWebhooksUrl}/widgetapi/v1/static`, + addToAdminRooms: false, + roomSetupWidget: { + addOnInvite: false, }, - logging: { - level: "info", - colorize: true, - json: false, - timestampFormat: "HH:mm:ss:SSS", + disallowedIpRanges: DefaultDisallowedIpRanges, + branding: { + widgetTitle: "Hookshot Configuration", }, - permissions: [{ - actor: serverName, - services: [{ - service: "*", - level: "admin" - }], - }], - passFile: "./passkey.pem", - widgets: { - publicUrl: `${hookshotWebhooksUrl}/widgetapi/v1/static`, - addToAdminRooms: false, - roomSetupWidget: { - addOnInvite: false, - }, - disallowedIpRanges: DefaultDisallowedIpRanges, - branding: { - widgetTitle: "Hookshot Configuration" - }, + }, + bot: { + displayname: "Hookshot Bot", + avatar: "mxc://half-shot.uk/2876e89ccade4cb615e210c458e2a7a6883fe17d", + }, + serviceBots: [ + { + localpart: "feeds", + displayname: "Feeds", + avatar: "./assets/feeds_avatar.png", + prefix: "!feeds", + service: "feeds", }, - bot: { - displayname: "Hookshot Bot", - avatar: "mxc://half-shot.uk/2876e89ccade4cb615e210c458e2a7a6883fe17d" + ], + github: { + auth: { + id: 123, + privateKeyFile: "github-key.pem", }, - serviceBots: [ - { - localpart: "feeds", - displayname: "Feeds", - avatar: "./assets/feeds_avatar.png", - prefix: "!feeds", - service: "feeds", - }, - ], - github: { - auth: { - id: 123, - privateKeyFile: "github-key.pem", - }, - oauth: { - client_id: "foo", - client_secret: "bar", - redirect_uri: `${hookshotWebhooksUrl}/oauth/`, - }, - webhook: { - secret: "secrettoken", - }, - defaultOptions: { - showIssueRoomLink: false, - hotlinkIssues: { - prefix: "#" - } - }, - userIdPrefix: "_github_", + oauth: { + client_id: "foo", + client_secret: "bar", + redirect_uri: `${hookshotWebhooksUrl}/oauth/`, }, - gitlab: { - instances: { - "gitlab.com": { - url: "https://gitlab.com", - } - }, - webhook: { - secret: "secrettoken", - publicUrl: `${hookshotWebhooksUrl}/hookshot/`, - }, - userIdPrefix: "_gitlab_", + webhook: { + secret: "secrettoken", }, - jira: { - webhook: { - secret: 'secrettoken' - }, - oauth: { - client_id: "foo", - client_secret: "bar", - redirect_uri: `${hookshotWebhooksUrl}/oauth/`, - }, + defaultOptions: { + showIssueRoomLink: false, + hotlinkIssues: { + prefix: "#", + }, }, - openProject: { - baseUrl: 'https://your-open-project.com', - webhook: { - secret: 'secrettoken' - }, - oauth: { - clientId: "foo", - clientSecret: "bar", - redirectUri: `${hookshotWebhooksUrl}/oauth/`, - }, + userIdPrefix: "_github_", + }, + gitlab: { + instances: { + "gitlab.com": { + url: "https://gitlab.com", + }, }, - generic: { - allowJsTransformationFunctions: false, - enabled: false, - enableHttpGet: false, - urlPrefix: `${hookshotWebhooksUrl}/webhook/`, - userIdPrefix: "_webhooks_", - waitForComplete: false, - maxExpiryTime: "30d", - sendExpiryNotice: false, + webhook: { + secret: "secrettoken", + publicUrl: `${hookshotWebhooksUrl}/hookshot/`, }, - figma: { - publicUrl: `${hookshotWebhooksUrl}/hookshot/`, - instances: { - "your-instance": { - teamId: "your-team-id", - accessToken: "your-personal-access-token", - passcode: "your-webhook-passcode", - } - } + userIdPrefix: "_gitlab_", + }, + jira: { + webhook: { + secret: "secrettoken", }, - feeds: { - enabled: false, - pollIntervalSeconds: 600, - pollTimeoutSeconds: 30, - pollConcurrency: 4, + oauth: { + client_id: "foo", + client_secret: "bar", + redirect_uri: `${hookshotWebhooksUrl}/oauth/`, }, - metrics: { - enabled: true, + }, + openProject: { + baseUrl: "https://your-open-project.com", + webhook: { + secret: "secrettoken", }, - listeners: [ - { - port: 9000, - bindAddress: '0.0.0.0', - resources: ['webhooks'], - }, - { - port: 9001, - bindAddress: '127.0.0.1', - resources: ['metrics'], - }, - { - port: 9002, - bindAddress: '0.0.0.0', - resources: ['widgets'], - } - ], - sentry: { - dsn: "https://examplePublicKey@o0.ingest.sentry.io/0", - environment: "production" + oauth: { + clientId: "foo", + clientSecret: "bar", + redirectUri: `${hookshotWebhooksUrl}/oauth/`, }, - encryption: { - storagePath: "./cryptostore" - } + }, + generic: { + allowJsTransformationFunctions: false, + enabled: false, + enableHttpGet: false, + urlPrefix: `${hookshotWebhooksUrl}/webhook/`, + userIdPrefix: "_webhooks_", + waitForComplete: false, + maxExpiryTime: "30d", + sendExpiryNotice: false, + }, + figma: { + publicUrl: `${hookshotWebhooksUrl}/hookshot/`, + instances: { + "your-instance": { + teamId: "your-team-id", + accessToken: "your-personal-access-token", + passcode: "your-webhook-passcode", + }, + }, + }, + feeds: { + enabled: false, + pollIntervalSeconds: 600, + pollTimeoutSeconds: 30, + pollConcurrency: 4, + }, + metrics: { + enabled: true, + }, + listeners: [ + { + port: 9000, + bindAddress: "0.0.0.0", + resources: ["webhooks"], + }, + { + port: 9001, + bindAddress: "127.0.0.1", + resources: ["metrics"], + }, + { + port: 9002, + bindAddress: "0.0.0.0", + resources: ["widgets"], + }, + ], + sentry: { + dsn: "https://examplePublicKey@o0.ingest.sentry.io/0", + environment: "production", + }, + encryption: { + storagePath: "./cryptostore", + }, }; export const DefaultConfig = new BridgeConfig(DefaultConfigRoot); -function renderSection(doc: YAML.Document, obj: Record, parentNode: YAMLSeq|YAML.Document = doc, parentIsOptional = false) { - const entries = Object.entries(obj); - entries.forEach(([key, value]) => { - if (keyIsHidden(obj, key)) { - return; - } +function renderSection( + doc: YAML.Document, + obj: Record, + parentNode: YAMLSeq | YAML.Document = doc, + parentIsOptional = false, +) { + const entries = Object.entries(obj); + entries.forEach(([key, value]) => { + if (keyIsHidden(obj, key)) { + return; + } + + if (value === undefined || value === null) { + return; + } - if (value === undefined || value === null) { - return; - } + const [comment, optional] = getConfigKeyMetadata(obj, key) ?? []; + let newNode: Node; + if (typeof value === "object" && !Array.isArray(value)) { + newNode = doc.createNode({}); + renderSection( + doc, + value as Record, + newNode as YAMLSeq, + optional ?? parentIsOptional, + ); + } else if (typeof value === "function") { + if (value.length !== 0) { + throw Error( + "Only zero-argument functions are allowed as config values", + ); + } + newNode = doc.createNode(value()); + } else { + newNode = doc.createNode(value); + } + if (comment) { + newNode.commentBefore = `${optional ? " (Optional)" : ""} ${comment}`; + } - const [comment, optional] = getConfigKeyMetadata(obj, key) ?? []; - let newNode: Node; - if (typeof value === "object" && !Array.isArray(value)) { - newNode = doc.createNode({}); - renderSection(doc, value as Record, newNode as YAMLSeq, optional ?? parentIsOptional); - } else if (typeof value === "function") { - if (value.length !== 0) { - throw Error("Only zero-argument functions are allowed as config values"); - } - newNode = doc.createNode(value()); - } else { - newNode = doc.createNode(value); - } - if (comment) { - newNode.commentBefore = `${optional ? ' (Optional)' : ''} ${comment}`; - } - - if (optional && !parentIsOptional) { - const tempDoc = new YAML.Document(); - tempDoc.contents = tempDoc.createNode({}); - tempDoc.add({key, value: newNode}); - // Apply to the parent node after required options - parentNode.comment = (parentNode.comment || "") + tempDoc.toString() + `\n`; - return; - } + if (optional && !parentIsOptional) { + const tempDoc = new YAML.Document(); + tempDoc.contents = tempDoc.createNode({}); + tempDoc.add({ key, value: newNode }); + // Apply to the parent node after required options + parentNode.comment = + (parentNode.comment || "") + tempDoc.toString() + `\n`; + return; + } - if (optional) { - parentNode.add({key: key, value: newNode}); - } else if (parentNode) { - parentNode.add({key, value: newNode}); - } - }) + if (optional) { + parentNode.add({ key: key, value: newNode }); + } else if (parentNode) { + parentNode.add({ key, value: newNode }); + } + }); } function renderDefaultConfig() { - const doc = new YAML.Document(); - doc.contents = doc.createNode({}); - doc.commentBefore = ' This is an example configuration file'; - // Needed because the entries syntax below would not work otherwise - renderSection(doc, DefaultConfig as unknown as Record); - return doc.toString(); + const doc = new YAML.Document(); + doc.contents = doc.createNode({}); + doc.commentBefore = " This is an example configuration file"; + // Needed because the entries syntax below would not work otherwise + renderSection(doc, DefaultConfig as unknown as Record); + return doc.toString(); } - async function renderRegistrationFile(configPath?: string) { - let bridgeConfig: BridgeConfig; - if (configPath) { - bridgeConfig = await BridgeConfig.parseConfig(configPath, process.env); - } else { - bridgeConfig = DefaultConfig; - } - const obj = { - as_token: randomBytes(32).toString('hex'), - hs_token: randomBytes(32).toString('hex'), - id: 'github-bridge', - url: `http://${bridgeConfig.bridge.bindAddress}:${bridgeConfig.bridge.port}/`, - rate_limited: false, - sender_localpart: 'github', - namespaces: { - aliases: [{ - exclusive: true, - regex: `#github_.+:${bridgeConfig.bridge.domain}` - },{ - exclusive: true, - regex: `#gitlab_.+:${bridgeConfig.bridge.domain}` - }], - users: [{ - exclusive: true, - regex: `@_github_.+:${bridgeConfig.bridge.domain}` - },{ - exclusive: true, - regex: `@_gitlab_.+:${bridgeConfig.bridge.domain}` - }], - rooms: [], + let bridgeConfig: BridgeConfig; + if (configPath) { + bridgeConfig = await BridgeConfig.parseConfig(configPath, process.env); + } else { + bridgeConfig = DefaultConfig; + } + const obj = { + as_token: randomBytes(32).toString("hex"), + hs_token: randomBytes(32).toString("hex"), + id: "github-bridge", + url: `http://${bridgeConfig.bridge.bindAddress}:${bridgeConfig.bridge.port}/`, + rate_limited: false, + sender_localpart: "github", + namespaces: { + aliases: [ + { + exclusive: true, + regex: `#github_.+:${bridgeConfig.bridge.domain}`, + }, + { + exclusive: true, + regex: `#gitlab_.+:${bridgeConfig.bridge.domain}`, + }, + ], + users: [ + { + exclusive: true, + regex: `@_github_.+:${bridgeConfig.bridge.domain}`, }, - }; + { + exclusive: true, + regex: `@_gitlab_.+:${bridgeConfig.bridge.domain}`, + }, + ], + rooms: [], + }, + }; - console.log(YAML.stringify(obj)); + console.log(YAML.stringify(obj)); } // Can be called directly if (require.main === module) { - if (process.argv[2] === '--config') { - console.log(renderDefaultConfig()); - } else if (process.argv[2] === '--registration') { - renderRegistrationFile(process.argv[3]).catch(ex => { - console.error(ex); - process.exit(1); - }); - } else { - throw Error('Must give --config or --registration'); - } + if (process.argv[2] === "--config") { + console.log(renderDefaultConfig()); + } else if (process.argv[2] === "--registration") { + renderRegistrationFile(process.argv[3]).catch((ex) => { + console.error(ex); + process.exit(1); + }); + } else { + throw Error("Must give --config or --registration"); + } } diff --git a/src/config/sections/cache.ts b/src/config/sections/cache.ts index 2da69c735..736a32a9f 100644 --- a/src/config/sections/cache.ts +++ b/src/config/sections/cache.ts @@ -1,7 +1,7 @@ export interface BridgeConfigCache { - /** - * A redis URI string - * @example `redis://user:password@host:port/dbnum` - */ - redisUri: string; -} \ No newline at end of file + /** + * A redis URI string + * @example `redis://user:password@host:port/dbnum` + */ + redisUri: string; +} diff --git a/src/config/sections/encryption.ts b/src/config/sections/encryption.ts index a802d79a5..2f1e5fd18 100644 --- a/src/config/sections/encryption.ts +++ b/src/config/sections/encryption.ts @@ -2,24 +2,39 @@ import { ConfigError } from "../../errors"; import { configKey } from "../Decorators"; interface BridgeConfigEncryptionYAML { - storagePath: string; + storagePath: string; } export class BridgeConfigEncryption { - @configKey("Path to the directory used to store encryption files. These files must be persist between restarts of the service.") - public readonly storagePath: string; + @configKey( + "Path to the directory used to store encryption files. These files must be persist between restarts of the service.", + ) + public readonly storagePath: string; - constructor(config: BridgeConfigEncryptionYAML, cache: unknown|undefined, queue: unknown|undefined) { - if (typeof config.storagePath !== "string" || !config.storagePath) { - throw new ConfigError("encryption.storagePath", "The crypto storage path must not be empty."); - } - this.storagePath = config.storagePath; + constructor( + config: BridgeConfigEncryptionYAML, + cache: unknown | undefined, + queue: unknown | undefined, + ) { + if (typeof config.storagePath !== "string" || !config.storagePath) { + throw new ConfigError( + "encryption.storagePath", + "The crypto storage path must not be empty.", + ); + } + this.storagePath = config.storagePath; - if (!cache) { - throw new ConfigError("cache", "Encryption requires the Redis cache to be enabled."); - } - if (queue) { - throw new ConfigError("queue", "Encryption does not support message queues."); - } + if (!cache) { + throw new ConfigError( + "cache", + "Encryption requires the Redis cache to be enabled.", + ); + } + if (queue) { + throw new ConfigError( + "queue", + "Encryption does not support message queues.", + ); } + } } diff --git a/src/config/sections/generichooks.ts b/src/config/sections/generichooks.ts index e2ca9b3a7..421f2cdfc 100644 --- a/src/config/sections/generichooks.ts +++ b/src/config/sections/generichooks.ts @@ -4,71 +4,79 @@ import { hideKey } from "../Decorators"; const parseDurationImport = import("parse-duration"); function makePrefixedUrl(urlString: string): URL { - return new URL(urlString.endsWith("/") ? urlString : urlString + "/"); + return new URL(urlString.endsWith("/") ? urlString : urlString + "/"); } export interface BridgeGenericWebhooksConfigYAML { - enabled: boolean; - urlPrefix: string; - userIdPrefix?: string; - allowJsTransformationFunctions?: boolean; - waitForComplete?: boolean; - enableHttpGet?: boolean; - outbound?: boolean; - disallowedIpRanges?: string[]; - maxExpiryTime?: string; - sendExpiryNotice?: boolean; - requireExpiryTime?: boolean; + enabled: boolean; + urlPrefix: string; + userIdPrefix?: string; + allowJsTransformationFunctions?: boolean; + waitForComplete?: boolean; + enableHttpGet?: boolean; + outbound?: boolean; + disallowedIpRanges?: string[]; + maxExpiryTime?: string; + sendExpiryNotice?: boolean; + requireExpiryTime?: boolean; } export class BridgeConfigGenericWebhooks { - public readonly enabled: boolean; - public readonly outbound: boolean; + public readonly enabled: boolean; + public readonly outbound: boolean; - @hideKey() - public readonly parsedUrlPrefix: URL; - public readonly urlPrefix: () => string; + @hideKey() + public readonly parsedUrlPrefix: URL; + public readonly urlPrefix: () => string; - public readonly userIdPrefix?: string; - public readonly allowJsTransformationFunctions?: boolean; - public readonly waitForComplete?: boolean; - public readonly enableHttpGet: boolean; + public readonly userIdPrefix?: string; + public readonly allowJsTransformationFunctions?: boolean; + public readonly waitForComplete?: boolean; + public readonly enableHttpGet: boolean; - @hideKey() - public readonly maxExpiryTimeMs?: Promise; - public readonly sendExpiryNotice: boolean; - public readonly requireExpiryTime: boolean; - // Public facing value for config generator - public readonly maxExpiryTime?: string; + @hideKey() + public readonly maxExpiryTimeMs?: Promise; + public readonly sendExpiryNotice: boolean; + public readonly requireExpiryTime: boolean; + // Public facing value for config generator + public readonly maxExpiryTime?: string; - constructor(yaml: BridgeGenericWebhooksConfigYAML) { - this.enabled = yaml.enabled || false; - this.outbound = yaml.outbound || false; - this.enableHttpGet = yaml.enableHttpGet || false; - this.sendExpiryNotice = yaml.sendExpiryNotice || false; - this.requireExpiryTime = yaml.requireExpiryTime || false; - try { - this.parsedUrlPrefix = makePrefixedUrl(yaml.urlPrefix); - this.urlPrefix = () => { return this.parsedUrlPrefix.href; } - } catch { - throw new ConfigError("generic.urlPrefix", "is not defined or not a valid URL"); - } - this.userIdPrefix = yaml.userIdPrefix; - this.allowJsTransformationFunctions = yaml.allowJsTransformationFunctions; - this.waitForComplete = yaml.waitForComplete; - this.maxExpiryTime = yaml.maxExpiryTime; - this.maxExpiryTimeMs = yaml.maxExpiryTime ? parseDurationImport.then(v => v.default(yaml.maxExpiryTime!) ?? undefined) : undefined; - } - - @hideKey() - public get publicConfig(): Promise { - return (async () => ({ - userIdPrefix: this.userIdPrefix, - allowJsTransformationFunctions: this.allowJsTransformationFunctions, - waitForComplete: this.waitForComplete, - maxExpiryTime: await this.maxExpiryTimeMs, - requireExpiryTime: this.requireExpiryTime, - }))(); + constructor(yaml: BridgeGenericWebhooksConfigYAML) { + this.enabled = yaml.enabled || false; + this.outbound = yaml.outbound || false; + this.enableHttpGet = yaml.enableHttpGet || false; + this.sendExpiryNotice = yaml.sendExpiryNotice || false; + this.requireExpiryTime = yaml.requireExpiryTime || false; + try { + this.parsedUrlPrefix = makePrefixedUrl(yaml.urlPrefix); + this.urlPrefix = () => { + return this.parsedUrlPrefix.href; + }; + } catch { + throw new ConfigError( + "generic.urlPrefix", + "is not defined or not a valid URL", + ); } + this.userIdPrefix = yaml.userIdPrefix; + this.allowJsTransformationFunctions = yaml.allowJsTransformationFunctions; + this.waitForComplete = yaml.waitForComplete; + this.maxExpiryTime = yaml.maxExpiryTime; + this.maxExpiryTimeMs = yaml.maxExpiryTime + ? parseDurationImport.then( + (v) => v.default(yaml.maxExpiryTime!) ?? undefined, + ) + : undefined; + } + @hideKey() + public get publicConfig(): Promise { + return (async () => ({ + userIdPrefix: this.userIdPrefix, + allowJsTransformationFunctions: this.allowJsTransformationFunctions, + waitForComplete: this.waitForComplete, + maxExpiryTime: await this.maxExpiryTimeMs, + requireExpiryTime: this.requireExpiryTime, + }))(); + } } diff --git a/src/config/sections/index.ts b/src/config/sections/index.ts index 8a6b91e70..53fa74006 100644 --- a/src/config/sections/index.ts +++ b/src/config/sections/index.ts @@ -1,3 +1,3 @@ export * from "./cache"; export * from "./queue"; -export * from "./generichooks"; \ No newline at end of file +export * from "./generichooks"; diff --git a/src/config/sections/openproject.ts b/src/config/sections/openproject.ts index 46ba83674..e035bd9df 100644 --- a/src/config/sections/openproject.ts +++ b/src/config/sections/openproject.ts @@ -2,56 +2,55 @@ import assert from "assert"; import { hideKey } from "../Decorators"; import { OpenProjectServiceConfig } from "../../Connections/OpenProjectConnection"; -export interface BridgeOpenProjectOAuthConfig{ - clientId: string; - clientSecret: string; - redirectUri: string; - } +export interface BridgeOpenProjectOAuthConfig { + clientId: string; + clientSecret: string; + redirectUri: string; +} export interface BridgeOpenProjectConfigYAML { - webhook: { - secret: string; - } - baseUrl: string; - oauth?: BridgeOpenProjectOAuthConfig + webhook: { + secret: string; + }; + baseUrl: string; + oauth?: BridgeOpenProjectOAuthConfig; } function makePrefixedUrl(urlString?: string): URL { - return new URL(urlString?.endsWith("/") ? urlString : urlString + "/"); + return new URL(urlString?.endsWith("/") ? urlString : urlString + "/"); } export class BridgeOpenProjectConfig { - /** - * @ignore For config generator only. - */ - public readonly baseUrl; - - /** - * @ignore For config generator only. - */ - public readonly webhook: {secret: string}; - - @hideKey() - baseURL: URL; - oauth?: BridgeOpenProjectOAuthConfig; - - - constructor(config: BridgeOpenProjectConfigYAML) { - assert(config.webhook?.secret); - this.webhook = config.webhook; - this.baseURL = makePrefixedUrl(config.baseUrl); - this.baseUrl = config.baseUrl; - if (config.oauth) { - assert(config.oauth.clientId); - assert(config.oauth.clientSecret); - assert(config.oauth.redirectUri); - this.oauth = config.oauth; - } + /** + * @ignore For config generator only. + */ + public readonly baseUrl; + + /** + * @ignore For config generator only. + */ + public readonly webhook: { secret: string }; + + @hideKey() + baseURL: URL; + oauth?: BridgeOpenProjectOAuthConfig; + + constructor(config: BridgeOpenProjectConfigYAML) { + assert(config.webhook?.secret); + this.webhook = config.webhook; + this.baseURL = makePrefixedUrl(config.baseUrl); + this.baseUrl = config.baseUrl; + if (config.oauth) { + assert(config.oauth.clientId); + assert(config.oauth.clientSecret); + assert(config.oauth.redirectUri); + this.oauth = config.oauth; } - - @hideKey() - public get publicConfig(): OpenProjectServiceConfig { - return { - baseUrl: this.baseURL.origin + this.baseURL.pathname - } - } -} \ No newline at end of file + } + + @hideKey() + public get publicConfig(): OpenProjectServiceConfig { + return { + baseUrl: this.baseURL.origin + this.baseURL.pathname, + }; + } +} diff --git a/src/config/sections/queue.ts b/src/config/sections/queue.ts index 605450e7f..f09b8eba1 100644 --- a/src/config/sections/queue.ts +++ b/src/config/sections/queue.ts @@ -2,21 +2,23 @@ * Configuration for the message queue. */ interface BridgeConfigQueueBase { - /** - * Controls whether the queue config is used just for the cache (monolithic), - * or the message queue as well. - * @deprecated Use the `cache` config instead to control this seperately. - */ - monolithic?: boolean; + /** + * Controls whether the queue config is used just for the cache (monolithic), + * or the message queue as well. + * @deprecated Use the `cache` config instead to control this seperately. + */ + monolithic?: boolean; } interface BridgeConfigQueueUri extends BridgeConfigQueueBase { - redisUri: string; + redisUri: string; } interface BridgeConfigQueueLegacyOptions extends BridgeConfigQueueBase { - port?: number; - host?: string; + port?: number; + host?: string; } -export type BridgeConfigQueue = BridgeConfigQueueUri|BridgeConfigQueueLegacyOptions \ No newline at end of file +export type BridgeConfigQueue = + | BridgeConfigQueueUri + | BridgeConfigQueueLegacyOptions; diff --git a/src/errors.ts b/src/errors.ts index c38ea631f..5e17b4567 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -1,27 +1,35 @@ export class CommandError extends Error { - constructor(public readonly message: string, public readonly humanError?: string) { - super(message); - } + constructor( + public readonly message: string, + public readonly humanError?: string, + ) { + super(message); + } } export class NotLoggedInError extends CommandError { - constructor() { - super("User is not logged in", "You are not logged in"); - } + constructor() { + super("User is not logged in", "You are not logged in"); + } } export class ConfigError extends Error { - constructor(public readonly configPath: string, public readonly msg?: string) { - super(`There was an error in the config (${configPath}): ${msg}`); - } + constructor( + public readonly configPath: string, + public readonly msg?: string, + ) { + super(`There was an error in the config (${configPath}): ${msg}`); + } } - export enum TokenErrorCode { - EXPIRED = "The token has expired." + EXPIRED = "The token has expired.", } export class TokenError extends Error { - constructor(public readonly code: TokenErrorCode, public readonly innerError: string) { - super(code); - } -} \ No newline at end of file + constructor( + public readonly code: TokenErrorCode, + public readonly innerError: string, + ) { + super(code); + } +} diff --git a/src/feeds/FeedReader.ts b/src/feeds/FeedReader.ts index 7b7968c8a..2de8e89a6 100644 --- a/src/feeds/FeedReader.ts +++ b/src/feeds/FeedReader.ts @@ -18,327 +18,385 @@ const BACKOFF_POW = 1.05; const BACKOFF_TIME_MS = 5 * 1000; export class FeedError extends Error { - constructor( - public url: string, - public cause: Error, - public readonly fetchKey: string, - ) { - super(`Error fetching feed ${url}: ${cause.message}`); - } - - get shouldErrorBeSilent() { - if (axios.isAxiosError(this.cause) && this.cause.response?.status) { - if (this.cause.response.status % 500 < 100) { - // 5XX error, retry these as it might be a server screwup. - return true; - } else if (this.cause.response.status % 400 < 100) { - // 4XX error, actually report these because the server is explicity stating we can't read the resource. - return false; - } - } - if (axios.isAxiosError(this.cause) && this.cause.code && ['ECONNABORTED', 'ECONNRESET'].includes(this.cause.code)) { - // Fuzzy match this, because it's usually a tempoary error. - return true; - } - // Err on the side of safety and report the rest + constructor( + public url: string, + public cause: Error, + public readonly fetchKey: string, + ) { + super(`Error fetching feed ${url}: ${cause.message}`); + } + + get shouldErrorBeSilent() { + if (axios.isAxiosError(this.cause) && this.cause.response?.status) { + if (this.cause.response.status % 500 < 100) { + // 5XX error, retry these as it might be a server screwup. + return true; + } else if (this.cause.response.status % 400 < 100) { + // 4XX error, actually report these because the server is explicity stating we can't read the resource. return false; + } + } + if ( + axios.isAxiosError(this.cause) && + this.cause.code && + ["ECONNABORTED", "ECONNRESET"].includes(this.cause.code) + ) { + // Fuzzy match this, because it's usually a tempoary error. + return true; } + // Err on the side of safety and report the rest + return false; + } } export interface FeedEntry { - feed: { - title: string|null, - url: string, - }, - title: string|null, - link: string|null, - pubdate: string|null, - summary: string|null, - author: string|null, - /** - * Unique key to identify the specific fetch across entries. - */ - fetchKey: string, + feed: { + title: string | null; + url: string; + }; + title: string | null; + link: string | null; + pubdate: string | null; + summary: string | null; + author: string | null; + /** + * Unique key to identify the specific fetch across entries. + */ + fetchKey: string; } export interface FeedSuccess { - url: string, + url: string; } function isNonEmptyString(input: unknown): input is string { - return Boolean(input) && typeof input === 'string'; + return Boolean(input) && typeof input === "string"; } function stripHtml(input: string): string { - return input.replace(/<[^>]*?>/g, ''); + return input.replace(/<[^>]*?>/g, ""); } function normalizeUrl(input: string): string { - const url = new URL(input); - url.hash = ''; - return url.toString(); + const url = new URL(input); + url.hash = ""; + return url.toString(); } export class FeedReader { - - private connections: FeedConnection[]; - - private feedQueue = new QueueWithBackoff(BACKOFF_TIME_MS, BACKOFF_POW, BACKOFF_TIME_MAX_MS); - - // A set of last modified times for each url. - private cacheTimes: Map = new Map(); - - // Reason failures to url map. - private feedsFailingHttp = new Set(); - private feedsFailingParsing = new Set(); - - static readonly seenEntriesEventType = "uk.half-shot.matrix-hookshot.feed.reader.seenEntries"; - - private shouldRun = true; - private readonly timeouts: (NodeJS.Timeout|undefined)[]; - private readonly feedsToRetain = new Set(); - - get sleepingInterval() { - return ( - // Calculate the number of MS to wait in between feeds. - (this.config.pollIntervalSeconds * 1000) / (this.feedQueue.length() || 1) - // And multiply by the number of concurrent readers - ) * this.config.pollConcurrency; - } - - constructor( - private readonly config: BridgeConfigFeeds, - private readonly connectionManager: ConnectionManager, - private readonly queue: MessageQueue, - private readonly storage: IBridgeStorageProvider, - ) { - // Ensure a fixed length array, - this.timeouts = new Array(config.pollConcurrency); - this.timeouts.fill(undefined); - Object.seal(this.timeouts); - this.connections = this.connectionManager.getAllConnectionsOfType(FeedConnection); - const feeds = this.calculateInitialFeedUrls(); - connectionManager.on('new-connection', newConnection => { - if (!(newConnection instanceof FeedConnection)) { - return; - } - const normalisedUrl = normalizeUrl(newConnection.feedUrl); - if (!feeds.has(normalisedUrl)) { - log.info(`Connection added, adding "${normalisedUrl}" to queue`); - this.feedQueue.push(normalisedUrl); - feeds.add(normalisedUrl); - Metrics.feedsCount.inc(); - Metrics.feedsCountDeprecated.inc(); - } - }); - connectionManager.on('connection-removed', removed => { - if (!(removed instanceof FeedConnection)) { - return; - } - let shouldKeepUrl = false; - const normalisedUrl = normalizeUrl(removed.feedUrl); - this.connections = this.connections.filter(c => { - // Cheeky reuse of iteration to determine if we should remove this URL. - if (c.connectionId !== removed.connectionId) { - shouldKeepUrl = shouldKeepUrl || normalizeUrl(c.feedUrl) === normalisedUrl; - return true; - } - return false; - }); - if (shouldKeepUrl) { - log.info(`Connection removed, but not removing "${normalisedUrl}" as it is still in use`); - return; - } - log.info(`Connection removed, removing "${normalisedUrl}" from queue`); - this.feedsToRetain.delete(normalisedUrl); - this.feedQueue.remove(normalisedUrl); - feeds.delete(normalisedUrl); - this.feedsFailingHttp.delete(normalisedUrl); - this.feedsFailingParsing.delete(normalisedUrl); - Metrics.feedsCount.dec(); - Metrics.feedsCountDeprecated.dec(); - }); - - log.debug('Loaded feed URLs:', [...feeds].join(', ')); - - for (let i = 0; i < config.pollConcurrency; i++) { - void this.pollFeeds(i); + private connections: FeedConnection[]; + + private feedQueue = new QueueWithBackoff( + BACKOFF_TIME_MS, + BACKOFF_POW, + BACKOFF_TIME_MAX_MS, + ); + + // A set of last modified times for each url. + private cacheTimes: Map = + new Map(); + + // Reason failures to url map. + private feedsFailingHttp = new Set(); + private feedsFailingParsing = new Set(); + + static readonly seenEntriesEventType = + "uk.half-shot.matrix-hookshot.feed.reader.seenEntries"; + + private shouldRun = true; + private readonly timeouts: (NodeJS.Timeout | undefined)[]; + private readonly feedsToRetain = new Set(); + + get sleepingInterval() { + return ( + // Calculate the number of MS to wait in between feeds. + ((this.config.pollIntervalSeconds * 1000) / + (this.feedQueue.length() || 1)) * + // And multiply by the number of concurrent readers + this.config.pollConcurrency + ); + } + + constructor( + private readonly config: BridgeConfigFeeds, + private readonly connectionManager: ConnectionManager, + private readonly queue: MessageQueue, + private readonly storage: IBridgeStorageProvider, + ) { + // Ensure a fixed length array, + this.timeouts = new Array(config.pollConcurrency); + this.timeouts.fill(undefined); + Object.seal(this.timeouts); + this.connections = + this.connectionManager.getAllConnectionsOfType(FeedConnection); + const feeds = this.calculateInitialFeedUrls(); + connectionManager.on("new-connection", (newConnection) => { + if (!(newConnection instanceof FeedConnection)) { + return; + } + const normalisedUrl = normalizeUrl(newConnection.feedUrl); + if (!feeds.has(normalisedUrl)) { + log.info(`Connection added, adding "${normalisedUrl}" to queue`); + this.feedQueue.push(normalisedUrl); + feeds.add(normalisedUrl); + Metrics.feedsCount.inc(); + Metrics.feedsCountDeprecated.inc(); + } + }); + connectionManager.on("connection-removed", (removed) => { + if (!(removed instanceof FeedConnection)) { + return; + } + let shouldKeepUrl = false; + const normalisedUrl = normalizeUrl(removed.feedUrl); + this.connections = this.connections.filter((c) => { + // Cheeky reuse of iteration to determine if we should remove this URL. + if (c.connectionId !== removed.connectionId) { + shouldKeepUrl = + shouldKeepUrl || normalizeUrl(c.feedUrl) === normalisedUrl; + return true; } + return false; + }); + if (shouldKeepUrl) { + log.info( + `Connection removed, but not removing "${normalisedUrl}" as it is still in use`, + ); + return; + } + log.info(`Connection removed, removing "${normalisedUrl}" from queue`); + this.feedsToRetain.delete(normalisedUrl); + this.feedQueue.remove(normalisedUrl); + feeds.delete(normalisedUrl); + this.feedsFailingHttp.delete(normalisedUrl); + this.feedsFailingParsing.delete(normalisedUrl); + Metrics.feedsCount.dec(); + Metrics.feedsCountDeprecated.dec(); + }); + + log.debug("Loaded feed URLs:", [...feeds].join(", ")); + + for (let i = 0; i < config.pollConcurrency; i++) { + void this.pollFeeds(i); } - - public stop() { - this.shouldRun = false; - this.timeouts.forEach(t => clearTimeout(t)); + } + + public stop() { + this.shouldRun = false; + this.timeouts.forEach((t) => clearTimeout(t)); + } + + /** + * Calculate the initial feed set for the reader. Should never + * be called twice. + */ + private calculateInitialFeedUrls(): Set { + // just in case we got an invalid URL somehow + const observedFeedUrls = new Set(); + for (const conn of this.connections) { + try { + observedFeedUrls.add(normalizeUrl(conn.feedUrl)); + } catch (err: unknown) { + log.error( + `Invalid feedUrl for connection ${conn.connectionId}: ${conn.feedUrl}. It will not be tracked`, + err, + ); + } } - - /** - * Calculate the initial feed set for the reader. Should never - * be called twice. - */ - private calculateInitialFeedUrls(): Set { - // just in case we got an invalid URL somehow - const observedFeedUrls = new Set(); - for (const conn of this.connections) { - try { - observedFeedUrls.add(normalizeUrl(conn.feedUrl)); - } catch (err: unknown) { - log.error(`Invalid feedUrl for connection ${conn.connectionId}: ${conn.feedUrl}. It will not be tracked`, err); - } + this.feedQueue.populate([...observedFeedUrls]); + Metrics.feedsCount.set(observedFeedUrls.size); + Metrics.feedsCountDeprecated.set(observedFeedUrls.size); + return observedFeedUrls; + } + + /** + * Poll a given feed URL for data, pushing any entries found into the message queue. + * We also check the `cacheTimes` cache to see if the feed has recent entries that we can + * filter out. + * + * @param url The URL to be polled. + * @returns A boolean that returns if we saw any changes on the feed since the last poll time. + */ + public async pollFeed(url: string): Promise { + // If a feed is deleted while it is being polled, we need + // to remember NOT to add it back to the queue. This + // set keeps track of all the feeds that *should* be + // requeued. + this.feedsToRetain.add(url); + let seenEntriesChanged = false; + const fetchKey = randomUUID(); + const { etag, lastModified } = this.cacheTimes.get(url) || {}; + log.debug(`Checking for updates in ${url} (${etag ?? lastModified})`); + try { + const result = await readFeed(url, { + pollTimeoutSeconds: this.config.pollTimeoutSeconds, + etag, + lastModified, + userAgent: UserAgent, + }); + + // Store any entity tags/cache times. + if (result.etag) { + this.cacheTimes.set(url, { etag: result.etag }); + } else if (result.lastModified) { + this.cacheTimes.set(url, { lastModified: result.lastModified }); + } + + const { feed } = result; + let initialSync = false; + if (!(await this.storage.hasSeenFeed(url))) { + initialSync = true; + seenEntriesChanged = true; // to ensure we only treat it as an initialSync once + } + + const newGuids = []; + if (feed) { + // If undefined, we got a not-modified. + log.debug(`Found ${feed.items.length} entries in ${url}`); + const seenItems = await this.storage.hasSeenFeedGuids( + url, + ...feed.items + .filter((item) => !!item.hashId) + .map((item) => item.hashId!), + ); + for (const item of feed.items) { + // Some feeds have a nasty habit of leading a empty tag there, making us parse it as garbage. + if (!item.hashId) { + log.error(`Could not determine guid for entry in ${url}, skipping`); + continue; + } + if (seenItems.includes(item.hashId)) { + continue; + } + newGuids.push(item.hashId); + + if (initialSync) { + log.debug( + `Skipping entry ${item.id ?? item.hashId} since we're performing an initial sync`, + ); + continue; + } + const entry = { + feed: { + title: isNonEmptyString(feed.title) + ? stripHtml(feed.title) + : null, + url: url, + }, + title: isNonEmptyString(item.title) ? stripHtml(item.title) : null, + pubdate: item.pubdate ?? null, + summary: item.summary ?? null, + author: item.author ?? null, + link: item.link ?? null, + fetchKey, + }; + + log.debug("New entry:", entry); + seenEntriesChanged = true; + + this.queue.push({ + eventName: "feed.entry", + sender: "FeedReader", + data: entry, + }); } - this.feedQueue.populate([...observedFeedUrls]); - Metrics.feedsCount.set(observedFeedUrls.size); - Metrics.feedsCountDeprecated.set(observedFeedUrls.size); - return observedFeedUrls; - } - /** - * Poll a given feed URL for data, pushing any entries found into the message queue. - * We also check the `cacheTimes` cache to see if the feed has recent entries that we can - * filter out. - * - * @param url The URL to be polled. - * @returns A boolean that returns if we saw any changes on the feed since the last poll time. - */ - public async pollFeed(url: string): Promise { - // If a feed is deleted while it is being polled, we need - // to remember NOT to add it back to the queue. This - // set keeps track of all the feeds that *should* be - // requeued. - this.feedsToRetain.add(url); - let seenEntriesChanged = false; - const fetchKey = randomUUID(); - const { etag, lastModified } = this.cacheTimes.get(url) || {}; - log.debug(`Checking for updates in ${url} (${etag ?? lastModified})`); - try { - const result = await readFeed(url, { - pollTimeoutSeconds: this.config.pollTimeoutSeconds, - etag, - lastModified, - userAgent: UserAgent, - }); - - // Store any entity tags/cache times. - if (result.etag) { - this.cacheTimes.set(url, { etag: result.etag }); - } else if (result.lastModified) { - this.cacheTimes.set(url, { lastModified: result.lastModified }); - } - - const { feed } = result; - let initialSync = false; - if (!await this.storage.hasSeenFeed(url)) { - initialSync = true; - seenEntriesChanged = true; // to ensure we only treat it as an initialSync once - } - - const newGuids = []; - if (feed) { - // If undefined, we got a not-modified. - log.debug(`Found ${feed.items.length} entries in ${url}`); - const seenItems = await this.storage.hasSeenFeedGuids(url, ...feed.items.filter(item => !!item.hashId).map(item => item.hashId!)) - for (const item of feed.items) { - // Some feeds have a nasty habit of leading a empty tag there, making us parse it as garbage. - if (!item.hashId) { - log.error(`Could not determine guid for entry in ${url}, skipping`); - continue; - } - if (seenItems.includes(item.hashId)) { - continue; - } - newGuids.push(item.hashId); - - if (initialSync) { - log.debug(`Skipping entry ${item.id ?? item.hashId} since we're performing an initial sync`); - continue; - } - const entry = { - feed: { - title: isNonEmptyString(feed.title) ? stripHtml(feed.title) : null, - url: url, - }, - title: isNonEmptyString(item.title) ? stripHtml(item.title) : null, - pubdate: item.pubdate ?? null, - summary: item.summary ?? null, - author: item.author ?? null, - link: item.link ?? null, - fetchKey - }; - - log.debug('New entry:', entry); - seenEntriesChanged = true; - - this.queue.push({ eventName: 'feed.entry', sender: 'FeedReader', data: entry }); - } - - if (seenEntriesChanged && newGuids.length) { - await this.storage.storeFeedGuids(url, ...newGuids); - } - } - this.queue.push({ eventName: 'feed.success', sender: 'FeedReader', data: { url } }); - // Clear any feed failures - this.feedsFailingHttp.delete(url); - this.feedsFailingParsing.delete(url); - if (this.feedsToRetain.has(url)) { - // If we've removed this feed since processing it, do not requeue. - this.feedQueue.push(url); - } - } catch (err: unknown) { - // TODO: Proper Rust Type error. - if ((err as Error).message.includes('Failed to fetch feed due to HTTP')) { - this.feedsFailingHttp.add(url); - } else { - this.feedsFailingParsing.add(url); - } - const backoffDuration = this.feedQueue.backoff(url); - const error = err instanceof Error ? err : new Error(`Unknown error ${err}`); - const feedError = new FeedError(url.toString(), error, fetchKey); - log.error("Unable to read feed:", feedError.message, `backing off for ${backoffDuration}ms`); - this.queue.push({ eventName: 'feed.error', sender: 'FeedReader', data: feedError}); + if (seenEntriesChanged && newGuids.length) { + await this.storage.storeFeedGuids(url, ...newGuids); } - return seenEntriesChanged; + } + this.queue.push({ + eventName: "feed.success", + sender: "FeedReader", + data: { url }, + }); + // Clear any feed failures + this.feedsFailingHttp.delete(url); + this.feedsFailingParsing.delete(url); + if (this.feedsToRetain.has(url)) { + // If we've removed this feed since processing it, do not requeue. + this.feedQueue.push(url); + } + } catch (err: unknown) { + // TODO: Proper Rust Type error. + if ((err as Error).message.includes("Failed to fetch feed due to HTTP")) { + this.feedsFailingHttp.add(url); + } else { + this.feedsFailingParsing.add(url); + } + const backoffDuration = this.feedQueue.backoff(url); + const error = + err instanceof Error ? err : new Error(`Unknown error ${err}`); + const feedError = new FeedError(url.toString(), error, fetchKey); + log.error( + "Unable to read feed:", + feedError.message, + `backing off for ${backoffDuration}ms`, + ); + this.queue.push({ + eventName: "feed.error", + sender: "FeedReader", + data: feedError, + }); } - - /** - * Start polling all the feeds. - */ - public async pollFeeds(workerId: number): Promise { - - // Update on each iteration - Metrics.feedsFailing.set({ reason: "http" }, this.feedsFailingHttp.size ); - Metrics.feedsFailing.set({ reason: "parsing" }, this.feedsFailingParsing.size); - Metrics.feedsFailingDeprecated.set({ reason: "http" }, this.feedsFailingHttp.size ); - Metrics.feedsFailingDeprecated.set({ reason: "parsing" }, this.feedsFailingParsing.size); - - log.debug(`Checking for updates in ${this.feedQueue.length()} RSS/Atom feeds (worker: ${workerId})`); - - const fetchingStarted = Date.now(); - - const url = this.feedQueue.pop(); - let sleepFor = this.sleepingInterval; - - if (url) { - if (await this.pollFeed(url)) { - log.debug(`Feed changed and will be saved`); - } - const elapsed = Date.now() - fetchingStarted; - Metrics.feedFetchMs.set(elapsed); - Metrics.feedsFetchMsDeprecated.set(elapsed); - sleepFor = Math.max(this.sleepingInterval - elapsed, 0); - log.debug(`Feed fetching took ${elapsed / 1000}s, sleeping for ${sleepFor / 1000}s`); - - if (elapsed > this.sleepingInterval) { - log.warn(`It took us longer to update the feeds than the configured pool interval`); - } - } else { - // It is possible that we have more workers than feeds. This will cause the worker to just sleep. - log.debug(`No feeds available to poll for worker ${workerId}`); - } - - this.timeouts[workerId] = setTimeout(() => { - if (!this.shouldRun) { - return; - } - void this.pollFeeds(workerId); - }, sleepFor); + return seenEntriesChanged; + } + + /** + * Start polling all the feeds. + */ + public async pollFeeds(workerId: number): Promise { + // Update on each iteration + Metrics.feedsFailing.set({ reason: "http" }, this.feedsFailingHttp.size); + Metrics.feedsFailing.set( + { reason: "parsing" }, + this.feedsFailingParsing.size, + ); + Metrics.feedsFailingDeprecated.set( + { reason: "http" }, + this.feedsFailingHttp.size, + ); + Metrics.feedsFailingDeprecated.set( + { reason: "parsing" }, + this.feedsFailingParsing.size, + ); + + log.debug( + `Checking for updates in ${this.feedQueue.length()} RSS/Atom feeds (worker: ${workerId})`, + ); + + const fetchingStarted = Date.now(); + + const url = this.feedQueue.pop(); + let sleepFor = this.sleepingInterval; + + if (url) { + if (await this.pollFeed(url)) { + log.debug(`Feed changed and will be saved`); + } + const elapsed = Date.now() - fetchingStarted; + Metrics.feedFetchMs.set(elapsed); + Metrics.feedsFetchMsDeprecated.set(elapsed); + sleepFor = Math.max(this.sleepingInterval - elapsed, 0); + log.debug( + `Feed fetching took ${elapsed / 1000}s, sleeping for ${sleepFor / 1000}s`, + ); + + if (elapsed > this.sleepingInterval) { + log.warn( + `It took us longer to update the feeds than the configured pool interval`, + ); + } + } else { + // It is possible that we have more workers than feeds. This will cause the worker to just sleep. + log.debug(`No feeds available to poll for worker ${workerId}`); } + + this.timeouts[workerId] = setTimeout(() => { + if (!this.shouldRun) { + return; + } + void this.pollFeeds(workerId); + }, sleepFor); + } } diff --git a/src/figma/index.ts b/src/figma/index.ts index d3012ab87..4cf359c08 100644 --- a/src/figma/index.ts +++ b/src/figma/index.ts @@ -1,5 +1,5 @@ import { BridgeConfigFigma } from "../config/Config"; -import * as Figma from 'figma-js'; +import * as Figma from "figma-js"; import { MatrixClient } from "matrix-bot-sdk"; export * from "./router"; export * from "./types"; @@ -10,89 +10,133 @@ export * from "./router"; export * from "./types"; interface FigmaWebhookDefinition { - id: string; - endpoint: string; - passcode: string; - status: "ACTIVE"|"PAUSED"; - description: string; + id: string; + endpoint: string; + passcode: string; + status: "ACTIVE" | "PAUSED"; + description: string; } -const log = new Logger('FigmaWebhooks'); - -export async function ensureFigmaWebhooks(figmaConfig: BridgeConfigFigma, matrixClient: MatrixClient) { - const publicUrl = figmaConfig.publicUrl; - const axiosConfig = { baseURL: 'https://api.figma.com/v2'}; - - for (const [instanceName, {accessToken, teamId, passcode}] of Object.entries(figmaConfig.instances)) { - const accountDataKey = `figma.${teamId}.webhook_id`; - const client = Figma.Client({ - personalAccessToken: accessToken - }); +const log = new Logger("FigmaWebhooks"); + +export async function ensureFigmaWebhooks( + figmaConfig: BridgeConfigFigma, + matrixClient: MatrixClient, +) { + const publicUrl = figmaConfig.publicUrl; + const axiosConfig = { baseURL: "https://api.figma.com/v2" }; + + for (const [ + instanceName, + { accessToken, teamId, passcode }, + ] of Object.entries(figmaConfig.instances)) { + const accountDataKey = `figma.${teamId}.webhook_id`; + const client = Figma.Client({ + personalAccessToken: accessToken, + }); + try { + await client.me(); + } catch (ex) { + const axiosErr = ex as AxiosError<{ message: string }>; + if (axiosErr.isAxiosError) { + log.error( + `Failed to check figma access token: ${axiosErr.code} ${axiosErr.response?.data?.message ?? ""}`, + ); + } + throw Error( + `Could not validate access token for figma team ${instanceName} (${teamId})`, + ); + } + + const { webhookId } = await matrixClient.getSafeAccountData<{ + webhookId: string | null; + }>(accountDataKey, { webhookId: null }); + let webhookDefinition: FigmaWebhookDefinition | undefined; + if (webhookId) { + try { + webhookDefinition = ( + await client.client.get(`webhooks/${webhookId}`, axiosConfig) + ).data; + log.info( + `Found existing hook for Figma instance ${instanceName} ${webhookId}`, + ); + } catch (ex) { + const axiosErr = ex as AxiosError<{ message: string }>; + if (axiosErr.response?.status !== 404) { + // Missing webhook, probably not found. + if (axiosErr.isAxiosError) { + log.error( + `Failed to update webhook: ${axiosErr.response?.status} ${axiosErr.response?.data?.message ?? ""}`, + ); + } + throw Error( + `Failed to verify Figma webhooks for ${instanceName}: ${ex.message}`, + ); + } + log.warn( + `Previous webhook ID ${webhookId} stored but API returned not found, creating new one.`, + ); + } + } + if (webhookDefinition) { + if ( + webhookDefinition.endpoint !== publicUrl || + webhookDefinition.passcode !== passcode + ) { + log.info( + `Existing hook ${webhookId} for ${instanceName} has stale endpoint or passcode, updating`, + ); try { - await client.me(); + await client.client.put( + `webhooks/${webhookId}`, + { + passcode, + endpoint: publicUrl, + }, + axiosConfig, + ); } catch (ex) { - const axiosErr = ex as AxiosError<{message: string}>; - if (axiosErr.isAxiosError) { - log.error(`Failed to check figma access token: ${axiosErr.code} ${axiosErr.response?.data?.message ?? ""}`) - } - throw Error(`Could not validate access token for figma team ${instanceName} (${teamId})`); - } - - const { webhookId } = await matrixClient.getSafeAccountData<{webhookId: string|null}>(accountDataKey, {webhookId: null}); - let webhookDefinition: FigmaWebhookDefinition|undefined; - if (webhookId) { - try { - webhookDefinition = (await client.client.get(`webhooks/${webhookId}`, axiosConfig)).data; - log.info(`Found existing hook for Figma instance ${instanceName} ${webhookId}`); - } catch (ex) { - const axiosErr = ex as AxiosError<{message: string}>; - if (axiosErr.response?.status !== 404) { - // Missing webhook, probably not found. - if (axiosErr.isAxiosError) { - log.error(`Failed to update webhook: ${axiosErr.response?.status} ${axiosErr.response?.data?.message ?? ""}`) - } - throw Error(`Failed to verify Figma webhooks for ${instanceName}: ${ex.message}`); - } - log.warn(`Previous webhook ID ${webhookId} stored but API returned not found, creating new one.`); - } + const axiosErr = ex as AxiosError<{ message: string }>; + if (axiosErr.isAxiosError) { + log.error( + `Failed to update webhook: ${axiosErr.code} ${axiosErr.response?.data?.message ?? ""}`, + ); + } + throw Error( + `Could not update an Figma webhook for instance ${instanceName}: ${ex}`, + ); } - if (webhookDefinition) { - if (webhookDefinition.endpoint !== publicUrl || webhookDefinition.passcode !== passcode) { - log.info(`Existing hook ${webhookId} for ${instanceName} has stale endpoint or passcode, updating`); - try { - await client.client.put(`webhooks/${webhookId}`, { - passcode, - endpoint: publicUrl, - }, axiosConfig); - } catch (ex) { - const axiosErr = ex as AxiosError<{message: string}>; - if (axiosErr.isAxiosError) { - log.error(`Failed to update webhook: ${axiosErr.code} ${axiosErr.response?.data?.message ?? ""}`) - } - throw Error(`Could not update an Figma webhook for instance ${instanceName}: ${ex}`); - } - } - } else { - log.info(`No webhook defined for instance ${instanceName}, creating`); - try { - const res = await client.client.post(`webhooks`, { - passcode, - endpoint: publicUrl, - description: 'matrix-hookshot', - event_type: 'FILE_COMMENT', - team_id: teamId.toString(), - }, axiosConfig); - webhookDefinition = res.data as FigmaWebhookDefinition; - await matrixClient.setAccountData(accountDataKey, {webhookId: webhookDefinition.id}); - } catch (ex) { - const axiosErr = ex as AxiosError<{message: string}>; - if (axiosErr.isAxiosError) { - log.error(`Failed to create webhook: ${axiosErr.code} ${axiosErr.response?.data?.message ?? ""}`) - } - throw Error(`Could not create a Figma webhook for instance ${instanceName}: ${ex}`); - } + } + } else { + log.info(`No webhook defined for instance ${instanceName}, creating`); + try { + const res = await client.client.post( + `webhooks`, + { + passcode, + endpoint: publicUrl, + description: "matrix-hookshot", + event_type: "FILE_COMMENT", + team_id: teamId.toString(), + }, + axiosConfig, + ); + webhookDefinition = res.data as FigmaWebhookDefinition; + await matrixClient.setAccountData(accountDataKey, { + webhookId: webhookDefinition.id, + }); + } catch (ex) { + const axiosErr = ex as AxiosError<{ message: string }>; + if (axiosErr.isAxiosError) { + log.error( + `Failed to create webhook: ${axiosErr.code} ${axiosErr.response?.data?.message ?? ""}`, + ); } + throw Error( + `Could not create a Figma webhook for instance ${instanceName}: ${ex}`, + ); + } } - -} \ No newline at end of file + } +} diff --git a/src/figma/router.ts b/src/figma/router.ts index 00be44cce..3d0643439 100644 --- a/src/figma/router.ts +++ b/src/figma/router.ts @@ -4,39 +4,52 @@ import { Request, Response, Router, json } from "express"; import { FigmaPayload } from "./types"; import { Logger } from "matrix-appservice-bridge"; -const log = new Logger('FigmaWebhooksRouter'); +const log = new Logger("FigmaWebhooksRouter"); export class FigmaWebhooksRouter { - constructor(private readonly config: BridgeConfigFigma, private readonly queue: MessageQueue) { } + constructor( + private readonly config: BridgeConfigFigma, + private readonly queue: MessageQueue, + ) {} - private onWebhook(req: Request, res: Response) { - const payload = req.body; - const instance = Object.entries(this.config.instances).find(([,p]) => p.passcode === payload.passcode); - log.debug(`Got figma webhook for instance ${instance?.[0]}`); - if (!instance) { - // No instance found - res.sendStatus(401); - return; - } - if (typeof payload.file_name !== "string" || typeof payload.file_key !== "string") { - res.status(400).send({error: "Missing required object keys file_name, file_key"}); - return; - } - res.status(200).send(); - this.queue.push({ - eventName: 'figma.payload', - data: { - payload, - instanceName: instance[0], - }, - sender: 'GithubWebhooks', - }) + private onWebhook( + req: Request, + res: Response, + ) { + const payload = req.body; + const instance = Object.entries(this.config.instances).find( + ([, p]) => p.passcode === payload.passcode, + ); + log.debug(`Got figma webhook for instance ${instance?.[0]}`); + if (!instance) { + // No instance found + res.sendStatus(401); + return; } - - public getRouter() { - const router = Router(); - router.use(json()); - router.post("/webhook", this.onWebhook.bind(this)); - return router; + if ( + typeof payload.file_name !== "string" || + typeof payload.file_key !== "string" + ) { + res + .status(400) + .send({ error: "Missing required object keys file_name, file_key" }); + return; } + res.status(200).send(); + this.queue.push({ + eventName: "figma.payload", + data: { + payload, + instanceName: instance[0], + }, + sender: "GithubWebhooks", + }); + } + + public getRouter() { + const router = Router(); + router.use(json()); + router.post("/webhook", this.onWebhook.bind(this)); + return router; + } } diff --git a/src/figma/types.ts b/src/figma/types.ts index 24730d3a5..079620234 100644 --- a/src/figma/types.ts +++ b/src/figma/types.ts @@ -1,23 +1,23 @@ export interface FigmaPayload { - comment_id: string, - comment: [ { text: string, } ], - created_at: string, - event_type: string, - file_key: string, - file_name: string, - mentions: unknown[], - order_id: string, - parent_id?: string, - passcode: string, - protocol_version: string, - resolved_at: string, - retries: number, - timestamp: string, - triggered_by: { id: string, handle: string }, - webhook_id: string, + comment_id: string; + comment: [{ text: string }]; + created_at: string; + event_type: string; + file_key: string; + file_name: string; + mentions: unknown[]; + order_id: string; + parent_id?: string; + passcode: string; + protocol_version: string; + resolved_at: string; + retries: number; + timestamp: string; + triggered_by: { id: string; handle: string }; + webhook_id: string; } export interface FigmaEvent { - payload: FigmaPayload, - instanceName: string, -} \ No newline at end of file + payload: FigmaPayload; + instanceName: string; +} diff --git a/src/generic/Router.ts b/src/generic/Router.ts index 6645afbdb..ad55bed34 100644 --- a/src/generic/Router.ts +++ b/src/generic/Router.ts @@ -9,99 +9,133 @@ import { StatusCodes } from "http-status-codes"; const WEBHOOK_RESPONSE_TIMEOUT = 5000; -const log = new Logger('GenericWebhooksRouter'); +const log = new Logger("GenericWebhooksRouter"); export class GenericWebhooksRouter { - constructor(private readonly queue: MessageQueue, private readonly deprecatedPath = false, private readonly allowGet: boolean) { } + constructor( + private readonly queue: MessageQueue, + private readonly deprecatedPath = false, + private readonly allowGet: boolean, + ) {} - private onWebhook(req: Request<{hookId: string}, unknown, unknown, unknown>, res: Response, next: NextFunction) { - if (req.method === "GET" && !this.allowGet) { - throw new ApiError("Invalid Method. Expecting PUT or POST", ErrCode.MethodNotAllowed); - } + private onWebhook( + req: Request<{ hookId: string }, unknown, unknown, unknown>, + res: Response, + next: NextFunction, + ) { + if (req.method === "GET" && !this.allowGet) { + throw new ApiError( + "Invalid Method. Expecting PUT or POST", + ErrCode.MethodNotAllowed, + ); + } - if (!['PUT', 'GET', 'POST'].includes(req.method)) { - throw new ApiError("Invalid Method. Expecting PUT, GET or POST", ErrCode.MethodNotAllowed); - } - - let body; - if (req.method === 'GET') { - body = req.query; - } else { - body = req.body; - } - - this.queue.pushWait({ - eventName: 'generic-webhook.event', - sender: "GithubWebhooks", - data: { - hookData: body, - hookId: req.params.hookId, - }, - }, WEBHOOK_RESPONSE_TIMEOUT).then((response) => { - if (response.notFound) { - if (this.deprecatedPath) { - // If the webhook wasn't found and we're on a deprecated path, ignore it. - next(); - return; - } - res.status(StatusCodes.NOT_FOUND).send({ok: false, error: "Webhook not found"}); - } else if (response.successful) { - const body = response.response?.body ?? {ok: true}; - if (response.response?.contentType) { - res.contentType(response.response.contentType); - } - res.status(response.response?.statusCode ?? StatusCodes.OK).send(body); - } else if (response.successful === false) { - res.status(response.statusCode ?? StatusCodes.INTERNAL_SERVER_ERROR).send({ok: false, error: response.error || "Failed to process webhook"}); - } else { - res.status(StatusCodes.ACCEPTED).send({ok: true}); - } - }).catch((err) => { - log.error(`Failed to emit payload: ${err}`); - res.status(StatusCodes.INTERNAL_SERVER_ERROR).send({ok: false, error: "Failed to handle webhook"}); - }); + if (!["PUT", "GET", "POST"].includes(req.method)) { + throw new ApiError( + "Invalid Method. Expecting PUT, GET or POST", + ErrCode.MethodNotAllowed, + ); } - private static xmlHandler(req: Request, res: Response, next: NextFunction) { - express.text({ type: ["*/xml", "+xml"] })(req, res, (err) => { - if (err) { - next(err); - return; - } - if (typeof req.body !== 'string') { - next(); - return; - } - xml.parseStringPromise(req.body).then(xmlResult => { - req.body = xmlResult; - next(); - }).catch(e => { - res.statusCode = 400; - next(e); + let body; + if (req.method === "GET") { + body = req.query; + } else { + body = req.body; + } + + this.queue + .pushWait( + { + eventName: "generic-webhook.event", + sender: "GithubWebhooks", + data: { + hookData: body, + hookId: req.params.hookId, + }, + }, + WEBHOOK_RESPONSE_TIMEOUT, + ) + .then((response) => { + if (response.notFound) { + if (this.deprecatedPath) { + // If the webhook wasn't found and we're on a deprecated path, ignore it. + next(); + return; + } + res + .status(StatusCodes.NOT_FOUND) + .send({ ok: false, error: "Webhook not found" }); + } else if (response.successful) { + const body = response.response?.body ?? { ok: true }; + if (response.response?.contentType) { + res.contentType(response.response.contentType); + } + res + .status(response.response?.statusCode ?? StatusCodes.OK) + .send(body); + } else if (response.successful === false) { + res + .status(response.statusCode ?? StatusCodes.INTERNAL_SERVER_ERROR) + .send({ + ok: false, + error: response.error || "Failed to process webhook", }); + } else { + res.status(StatusCodes.ACCEPTED).send({ ok: true }); + } + }) + .catch((err) => { + log.error(`Failed to emit payload: ${err}`); + res + .status(StatusCodes.INTERNAL_SERVER_ERROR) + .send({ ok: false, error: "Failed to handle webhook" }); + }); + } + + private static xmlHandler(req: Request, res: Response, next: NextFunction) { + express.text({ type: ["*/xml", "+xml"] })(req, res, (err) => { + if (err) { + next(err); + return; + } + if (typeof req.body !== "string") { + next(); + return; + } + xml + .parseStringPromise(req.body) + .then((xmlResult) => { + req.body = xmlResult; + next(); + }) + .catch((e) => { + res.statusCode = 400; + next(e); }); - } + }); + } - public getRouter() { - const router = Router(); - router.all( - '/:hookId', - helmet({ - contentSecurityPolicy: { - useDefaults: true, - directives: { - defaultSrc: "'self'", - sandbox: '' - } - }, - xFrameOptions: { action: 'deny'}, - crossOriginResourcePolicy: { policy: 'same-site'} , - }), - GenericWebhooksRouter.xmlHandler, - express.urlencoded({ extended: false }), - express.json(), - express.text({ type: 'text/*'}), - this.onWebhook.bind(this), - ); - return router; - } + public getRouter() { + const router = Router(); + router.all( + "/:hookId", + helmet({ + contentSecurityPolicy: { + useDefaults: true, + directives: { + defaultSrc: "'self'", + sandbox: "", + }, + }, + xFrameOptions: { action: "deny" }, + crossOriginResourcePolicy: { policy: "same-site" }, + }), + GenericWebhooksRouter.xmlHandler, + express.urlencoded({ extended: false }), + express.json(), + express.text({ type: "text/*" }), + this.onWebhook.bind(this), + ); + return router; + } } diff --git a/src/generic/transformer.ts b/src/generic/transformer.ts index 5ed14194e..7191a8e22 100644 --- a/src/generic/transformer.ts +++ b/src/generic/transformer.ts @@ -1,159 +1,209 @@ - -import { QuickJSWASMModule, newQuickJSWASMModule, shouldInterruptAfterDeadline } from "quickjs-emscripten"; +import { + QuickJSWASMModule, + newQuickJSWASMModule, + shouldInterruptAfterDeadline, +} from "quickjs-emscripten"; const TRANSFORMATION_TIMEOUT_MS = 500; interface Mentions { - user_ids?: string[]; - room?: boolean; + user_ids?: string[]; + room?: boolean; } interface FunctionResultObject { - version: string; - plain?: string; - html?: string; - msgtype?: string; - empty?: boolean; - webhookResponse?: ExecuteResultWebhookResponse; - mentions?: Mentions; + version: string; + plain?: string; + html?: string; + msgtype?: string; + empty?: boolean; + webhookResponse?: ExecuteResultWebhookResponse; + mentions?: Mentions; } export interface ExecuteResultWebhookResponse { - body: string; - contentType?: string; - statusCode?: number; + body: string; + contentType?: string; + statusCode?: number; } - export interface ExecuteResultContent { - plain: string, - html?: string, - msgtype?: string, - mentions?: Mentions, + plain: string; + html?: string; + msgtype?: string; + mentions?: Mentions; } export interface ExecuteResult { - content?: ExecuteResultContent; - webhookResponse?: ExecuteResultWebhookResponse; + content?: ExecuteResultContent; + webhookResponse?: ExecuteResultWebhookResponse; } - export class WebhookTransformer { - private static quickModule?: QuickJSWASMModule; - - - public static get canTransform() { - return !!this.quickModule; + private static quickModule?: QuickJSWASMModule; + + public static get canTransform() { + return !!this.quickModule; + } + + public static async initialiseQuickJS() { + WebhookTransformer.quickModule = await newQuickJSWASMModule(); + } + + public static validateScript(scriptSrc: string): string | null { + const ctx = this.quickModule!.newContext(); + try { + const codeEvalResult = ctx.evalCode( + `function f(data) {${scriptSrc}}`, + undefined, + { compileOnly: true }, + ); + try { + if (codeEvalResult.error) { + const errorString = JSON.stringify( + ctx.dump(codeEvalResult.error), + null, + 2, + ); + return errorString; + } + } finally { + codeEvalResult.dispose(); + } + } finally { + ctx.dispose(); } - - public static async initialiseQuickJS() { - WebhookTransformer.quickModule = await newQuickJSWASMModule(); + return null; + } + + constructor(private readonly scriptSrc: string) {} + + public execute(data: unknown): ExecuteResult { + let result; + const ctx = WebhookTransformer.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.scriptSrc} })();`, + ); + + 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(); } - public static validateScript(scriptSrc: string): string|null { - const ctx = this.quickModule!.newContext(); - try { - const codeEvalResult = ctx.evalCode(`function f(data) {${scriptSrc}}`, undefined, { compileOnly: true }); - try { - if (codeEvalResult.error) { - const errorString = JSON.stringify(ctx.dump(codeEvalResult.error), null, 2); - return errorString; - } - } finally { - codeEvalResult.dispose(); - } - } finally { - ctx.dispose(); - } - return null; + // Legacy v1 api + if (typeof result === "string") { + return { content: { plain: `Received webhook: ${result}` } }; + } else if (typeof result !== "object") { + return { content: { plain: `No content` } }; + } + const transformationResult = result as FunctionResultObject; + if (transformationResult.version !== "v2") { + throw Error( + "Result returned from transformation didn't specify version = v2", + ); } - constructor(private readonly scriptSrc: string) { } - - public execute(data: unknown): ExecuteResult { - let result; - const ctx = WebhookTransformer.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.scriptSrc} })();`); - - 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 {content: {plain: `Received webhook: ${result}`}}; - } else if (typeof result !== "object") { - return {content: {plain: `No content`}}; - } - const transformationResult = result as FunctionResultObject; - if (transformationResult.version !== "v2") { - throw Error("Result returned from transformation didn't specify version = v2"); - } - - 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"); - } - } - - if (transformationResult.empty) { - return { - content: undefined, - webhookResponse: transformationResult.webhookResponse, - } - } - 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"); - } - if (transformationResult.mentions) { - if (transformationResult.mentions.room !== undefined && typeof transformationResult.mentions.room !== "boolean") { - throw Error("Result returned from transformation provided an invalid mentions.room"); - } - if (transformationResult.mentions.user_ids !== undefined && !Array.isArray(transformationResult.mentions.user_ids)) { - throw Error("Result returned from transformation provided an invalid mentions.user_ids"); - } - // Sanitise - transformationResult.mentions = { - room: transformationResult.mentions.room, - user_ids: transformationResult.mentions.user_ids, - } - } + 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 { - content: { - plain: transformationResult.plain, - html: transformationResult.html, - msgtype: transformationResult.msgtype, - mentions: transformationResult.mentions, - }, - webhookResponse: transformationResult.webhookResponse, - } + if (transformationResult.empty) { + return { + content: undefined, + webhookResponse: transformationResult.webhookResponse, + }; + } + 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", + ); } - -} \ No newline at end of file + if (transformationResult.mentions) { + if ( + transformationResult.mentions.room !== undefined && + typeof transformationResult.mentions.room !== "boolean" + ) { + throw Error( + "Result returned from transformation provided an invalid mentions.room", + ); + } + if ( + transformationResult.mentions.user_ids !== undefined && + !Array.isArray(transformationResult.mentions.user_ids) + ) { + throw Error( + "Result returned from transformation provided an invalid mentions.user_ids", + ); + } + // Sanitise + transformationResult.mentions = { + room: transformationResult.mentions.room, + user_ids: transformationResult.mentions.user_ids, + }; + } + + return { + content: { + plain: transformationResult.plain, + html: transformationResult.html, + msgtype: transformationResult.msgtype, + mentions: transformationResult.mentions, + }, + webhookResponse: transformationResult.webhookResponse, + }; + } +} diff --git a/src/generic/types.ts b/src/generic/types.ts index c8e3f0971..8259af5a1 100644 --- a/src/generic/types.ts +++ b/src/generic/types.ts @@ -1,20 +1,22 @@ import { ExecuteResultWebhookResponse } from "../generic/transformer"; export interface GenericWebhookEvent { - hookData: unknown; - hookId: string; + hookData: unknown; + hookId: string; } -export type GenericWebhookEventResult = GenericWebhookEventResultSuccess | GenericWebhookEventResultFailure; +export type GenericWebhookEventResult = + | GenericWebhookEventResultSuccess + | GenericWebhookEventResultFailure; export interface GenericWebhookEventResultSuccess { - successful: true|null; - response?: ExecuteResultWebhookResponse, - notFound?: boolean; + successful: true | null; + response?: ExecuteResultWebhookResponse; + notFound?: boolean; } export interface GenericWebhookEventResultFailure { - successful: false; - statusCode?: number; - error?: string; - notFound?: boolean; -} \ No newline at end of file + successful: false; + statusCode?: number; + error?: string; + notFound?: boolean; +} diff --git a/src/github/AdminCommands.ts b/src/github/AdminCommands.ts index 7c1190e9c..990ca603e 100644 --- a/src/github/AdminCommands.ts +++ b/src/github/AdminCommands.ts @@ -1,4 +1,4 @@ -import { AdminRoomCommandHandler, Category } from "../AdminRoomCommandHandler" +import { AdminRoomCommandHandler, Category } from "../AdminRoomCommandHandler"; import { botCommand } from "../BotCommands"; import { CommandError, TokenError, TokenErrorCode } from "../errors"; import { GithubInstance } from "./GithubInstance"; @@ -6,67 +6,108 @@ import { GitHubOAuthToken } from "./Types"; import { Logger } from "matrix-appservice-bridge"; import { BridgePermissionLevel } from "../config/Config"; -const log = new Logger('GitHubBotCommands'); +const log = new Logger("GitHubBotCommands"); export class GitHubBotCommands extends AdminRoomCommandHandler { - @botCommand("github login", {help: "Log in to GitHub", category: Category.Github, permissionLevel: BridgePermissionLevel.login}) - public async loginCommand() { - if (!this.config.github) { - throw new CommandError("no-github-support", "The bridge is not configured with GitHub support."); - } - if (!this.config.github.oauth) { - throw new CommandError("no-github-support", "The bridge is not configured with GitHub OAuth support."); - } - const state = this.tokenStore.createStateForOAuth(this.userId); - const url = GithubInstance.generateOAuthUrl( - this.config.github.baseUrl, - "authorize", - { - state, - client_id: this.config.github.oauth.client_id, - redirect_uri: this.config.github.oauth.redirect_uri, - } - ); - return this.sendNotice(`Open ${url} to link your account to the bridge.`); + @botCommand("github login", { + help: "Log in to GitHub", + category: Category.Github, + permissionLevel: BridgePermissionLevel.login, + }) + public async loginCommand() { + if (!this.config.github) { + throw new CommandError( + "no-github-support", + "The bridge is not configured with GitHub support.", + ); + } + if (!this.config.github.oauth) { + throw new CommandError( + "no-github-support", + "The bridge is not configured with GitHub OAuth support.", + ); } + const state = this.tokenStore.createStateForOAuth(this.userId); + const url = GithubInstance.generateOAuthUrl( + this.config.github.baseUrl, + "authorize", + { + state, + client_id: this.config.github.oauth.client_id, + redirect_uri: this.config.github.oauth.redirect_uri, + }, + ); + return this.sendNotice(`Open ${url} to link your account to the bridge.`); + } - @botCommand("github setpersonaltoken", {help: "Set your personal access token for GitHub", requiredArgs: ['accessToken'], category: Category.Github, permissionLevel: BridgePermissionLevel.login}) - public async setGHPersonalAccessToken(accessToken: string) { - if (!this.config.github) { - throw new CommandError("no-github-support", "The bridge is not configured with GitHub support."); - } - let me; - try { - const octokit = GithubInstance.createUserOctokit(accessToken, this.config.github.baseUrl); - me = await octokit.users.getAuthenticated(); - } catch (ex) { - log.error("Failed to auth with GitHub", ex); - await this.sendNotice("Could not authenticate with GitHub. Is your token correct?"); - return; - } - await this.tokenStore.storeUserToken("github", this.userId, JSON.stringify({access_token: accessToken, token_type: 'pat'} as GitHubOAuthToken)); - await this.sendNotice(`Connected as ${me.data.login}. Token stored.`); + @botCommand("github setpersonaltoken", { + help: "Set your personal access token for GitHub", + requiredArgs: ["accessToken"], + category: Category.Github, + permissionLevel: BridgePermissionLevel.login, + }) + public async setGHPersonalAccessToken(accessToken: string) { + if (!this.config.github) { + throw new CommandError( + "no-github-support", + "The bridge is not configured with GitHub support.", + ); + } + let me; + try { + const octokit = GithubInstance.createUserOctokit( + accessToken, + this.config.github.baseUrl, + ); + me = await octokit.users.getAuthenticated(); + } catch (ex) { + log.error("Failed to auth with GitHub", ex); + await this.sendNotice( + "Could not authenticate with GitHub. Is your token correct?", + ); + return; } + await this.tokenStore.storeUserToken( + "github", + this.userId, + JSON.stringify({ + access_token: accessToken, + token_type: "pat", + } as GitHubOAuthToken), + ); + await this.sendNotice(`Connected as ${me.data.login}. Token stored.`); + } - @botCommand("github status", {help: "Check the status of your GitHub authentication", category: Category.Github, permissionLevel: BridgePermissionLevel.login}) - public async getTokenStatus() { - if (!this.config.github) { - throw new CommandError("no-github-support", "The bridge is not configured with GitHub support."); - } - try { - const octokit = await this.tokenStore.getOctokitForUser(this.userId); - if (octokit === null) { - await this.sendNotice("You are not authenticated, please login."); - return; - } - const me = await octokit.users.getAuthenticated(); - this.sendNotice(`You are logged in as ${me.data.login}`); - } catch (ex) { - if (ex instanceof TokenError && ex.code === TokenErrorCode.EXPIRED) { - await this.sendNotice("Your authentication is no longer valid, please login again."); - } else { - // Generic catch-all. - await this.sendNotice("The bridge was unable to authenticate as you, please login again."); - } - } + @botCommand("github status", { + help: "Check the status of your GitHub authentication", + category: Category.Github, + permissionLevel: BridgePermissionLevel.login, + }) + public async getTokenStatus() { + if (!this.config.github) { + throw new CommandError( + "no-github-support", + "The bridge is not configured with GitHub support.", + ); + } + try { + const octokit = await this.tokenStore.getOctokitForUser(this.userId); + if (octokit === null) { + await this.sendNotice("You are not authenticated, please login."); + return; + } + const me = await octokit.users.getAuthenticated(); + this.sendNotice(`You are logged in as ${me.data.login}`); + } catch (ex) { + if (ex instanceof TokenError && ex.code === TokenErrorCode.EXPIRED) { + await this.sendNotice( + "Your authentication is no longer valid, please login again.", + ); + } else { + // Generic catch-all. + await this.sendNotice( + "The bridge was unable to authenticate as you, please login again.", + ); + } } + } } diff --git a/src/github/Discussion.ts b/src/github/Discussion.ts index 9fe47a248..2c96b027e 100644 --- a/src/github/Discussion.ts +++ b/src/github/Discussion.ts @@ -1,20 +1,20 @@ export interface DiscussionQLResponse { + id: string; + number: number; + author: { + login: string; + avatarUrl: string; + }; + bodyHTML: string; + bodyText: string; + category: { + name: string; id: string; - number: number; - author: { - login: string; - avatarUrl: string; - }; - bodyHTML: string; - bodyText: string; - category: { - name: string; - id: string; - }; - createdAt: string; - locked: boolean; - title: string; - url: string; + }; + createdAt: string; + locked: boolean; + title: string; + url: string; } export const DiscussionQL = ` @@ -37,4 +37,4 @@ createdAt, locked, title, url, -` \ No newline at end of file +`; diff --git a/src/github/GithubInstance.ts b/src/github/GithubInstance.ts index 1d9b266a9..9d57651ba 100644 --- a/src/github/GithubInstance.ts +++ b/src/github/GithubInstance.ts @@ -3,7 +3,12 @@ import { Octokit } from "@octokit/rest"; import { Logger } from "matrix-appservice-bridge"; import { DiscussionQLResponse, DiscussionQL } from "./Discussion"; import * as GitHubWebhookTypes from "@octokit/webhooks-types"; -import { GitHubOAuthErrorResponse, GitHubOAuthTokenResponse, InstallationDataType, NAMELESS_ORG_PLACEHOLDER } from "./Types"; +import { + GitHubOAuthErrorResponse, + GitHubOAuthTokenResponse, + InstallationDataType, + NAMELESS_ORG_PLACEHOLDER, +} from "./Types"; import axios from "axios"; import UserAgent from "../UserAgent"; @@ -13,248 +18,331 @@ export const GITHUB_CLOUD_URL = new URL("https://api.github.com"); export const GITHUB_CLOUD_PUBLIC_URL = new URL("https://github.com"); export class GitHubOAuthError extends Error { - constructor(errorResponse: GitHubOAuthErrorResponse) { - super(`OAuth interaction failed with ${errorResponse.error}: ${errorResponse.error_description}. See ${errorResponse.error_uri}`); - } + constructor(errorResponse: GitHubOAuthErrorResponse) { + super( + `OAuth interaction failed with ${errorResponse.error}: ${errorResponse.error_description}. See ${errorResponse.error_uri}`, + ); + } } -export function getNameForGitHubAccount(account: {login: string}|{name?: string}) { - return ('login' in account) ? account.login : account.name ?? NAMELESS_ORG_PLACEHOLDER; +export function getNameForGitHubAccount( + account: { login: string } | { name?: string }, +) { + return "login" in account + ? account.login + : (account.name ?? NAMELESS_ORG_PLACEHOLDER); } interface Installation { - account: { + account: + | { login?: string; - } | { + } + | { name: string; - } | null; - id: number; - repository_selection: "selected"|"all"; - matchesRepository: string[]; + } + | null; + id: number; + repository_selection: "selected" | "all"; + matchesRepository: string[]; } interface OAuthUrlParameters { - [key: string]: string|undefined; - state?: string; - client_id?: string; - redirect_uri?: string; - client_secret?: string, - refresh_token?: string, - grant_type?: 'refresh_token', + [key: string]: string | undefined; + state?: string; + client_id?: string; + redirect_uri?: string; + client_secret?: string; + refresh_token?: string; + grant_type?: "refresh_token"; } export class GithubInstance { - private internalOctokit!: Octokit; - private readonly installationsCache = new Map(); - private internalAppUrl?: string; - - constructor (private readonly appId: number|string, private readonly privateKey: string, private readonly baseUrl: URL) { - this.appId = parseInt(appId as string, 10); - } - - public get appUrl() { - return this.internalAppUrl; - } - - public get appOctokit() { - if (!this.internalOctokit) { - throw Error('Instance is not ready yet'); - } - return this.internalOctokit; - } - - public static baseOctokitConfig(baseUrl: URL) { - // Enterprise GitHub uses a /api/v3 basepath (https://github.com/octokit/octokit.js#constructor-options) - // Cloud uses api.github.com - const url = (baseUrl.hostname === GITHUB_CLOUD_URL.hostname ? baseUrl : new URL("/api/v3", baseUrl)).toString(); - return { - userAgent: UserAgent, - // Remove trailing slash, which is always included in URL objects. - baseUrl: url.endsWith('/') ? url.slice(0, -1) : url, - } - } - - - public static createUserOctokit(token: string, baseUrl: URL) { - return new Octokit({ - auth: token, - ...this.baseOctokitConfig(baseUrl) - }); + private internalOctokit!: Octokit; + private readonly installationsCache = new Map(); + private internalAppUrl?: string; + + constructor( + private readonly appId: number | string, + private readonly privateKey: string, + private readonly baseUrl: URL, + ) { + this.appId = parseInt(appId as string, 10); + } + + public get appUrl() { + return this.internalAppUrl; + } + + public get appOctokit() { + if (!this.internalOctokit) { + throw Error("Instance is not ready yet"); } - - public static async refreshAccessToken(refreshToken: string, clientId: string, clientSecret: string, baseUrl: URL): Promise { - const url = GithubInstance.generateOAuthUrl(baseUrl, "access_token", { - client_id: clientId, - client_secret: clientSecret, - refresh_token: refreshToken, - grant_type: 'refresh_token', - }); - const accessTokenRes = await axios.post(url); - const response: Record = Object.fromEntries(new URLSearchParams(accessTokenRes.data)); - if ('error' in response) { - throw new GitHubOAuthError(response as unknown as GitHubOAuthErrorResponse); - } - return response as unknown as GitHubOAuthTokenResponse; + return this.internalOctokit; + } + + public static baseOctokitConfig(baseUrl: URL) { + // Enterprise GitHub uses a /api/v3 basepath (https://github.com/octokit/octokit.js#constructor-options) + // Cloud uses api.github.com + const url = ( + baseUrl.hostname === GITHUB_CLOUD_URL.hostname + ? baseUrl + : new URL("/api/v3", baseUrl) + ).toString(); + return { + userAgent: UserAgent, + // Remove trailing slash, which is always included in URL objects. + baseUrl: url.endsWith("/") ? url.slice(0, -1) : url, + }; + } + + public static createUserOctokit(token: string, baseUrl: URL) { + return new Octokit({ + auth: token, + ...this.baseOctokitConfig(baseUrl), + }); + } + + public static async refreshAccessToken( + refreshToken: string, + clientId: string, + clientSecret: string, + baseUrl: URL, + ): Promise { + const url = GithubInstance.generateOAuthUrl(baseUrl, "access_token", { + client_id: clientId, + client_secret: clientSecret, + refresh_token: refreshToken, + grant_type: "refresh_token", + }); + const accessTokenRes = await axios.post(url); + const response: Record = Object.fromEntries( + new URLSearchParams(accessTokenRes.data), + ); + if ("error" in response) { + throw new GitHubOAuthError( + response as unknown as GitHubOAuthErrorResponse, + ); } - - public getSafeOctokitForRepo(orgName: string, repoName?: string) { - const targetName = (repoName ? `${orgName}/${repoName}` : orgName).toLowerCase(); - for (const install of this.installationsCache.values()) { - if (install.matchesRepository.includes(targetName) || install.matchesRepository.includes(`${targetName.split('/')[0]}/*`)) { - return this.createOctokitForInstallation(install.id); - } - } - return null; + return response as unknown as GitHubOAuthTokenResponse; + } + + public getSafeOctokitForRepo(orgName: string, repoName?: string) { + const targetName = ( + repoName ? `${orgName}/${repoName}` : orgName + ).toLowerCase(); + for (const install of this.installationsCache.values()) { + if ( + install.matchesRepository.includes(targetName) || + install.matchesRepository.includes(`${targetName.split("/")[0]}/*`) + ) { + return this.createOctokitForInstallation(install.id); + } } + return null; + } - public getOctokitForRepo(orgName: string, repoName?: string) { - const res = this.getSafeOctokitForRepo(orgName, repoName); - if (res) { - return res; - } - // TODO: Refresh cache? - throw Error(`No installation found to handle ${orgName}/${repoName}`); + public getOctokitForRepo(orgName: string, repoName?: string) { + const res = this.getSafeOctokitForRepo(orgName, repoName); + if (res) { + return res; } + // TODO: Refresh cache? + throw Error(`No installation found to handle ${orgName}/${repoName}`); + } + + private createOctokitForInstallation(installationId: number) { + return new Octokit({ + authStrategy: createAppAuth, + auth: { + appId: this.appId, + privateKey: this.privateKey, + installationId, + }, + ...GithubInstance.baseOctokitConfig(this.baseUrl), + }); + } + + public async start() { + // TODO: Make this generic. + const auth = { + appId: this.appId, + privateKey: this.privateKey, + }; - private createOctokitForInstallation(installationId: number) { - return new Octokit({ - authStrategy: createAppAuth, - auth: { - appId: this.appId, - privateKey: this.privateKey, - installationId, - }, - ...GithubInstance.baseOctokitConfig(this.baseUrl), - }); + this.internalOctokit = new Octokit({ + authStrategy: createAppAuth, + auth, + ...GithubInstance.baseOctokitConfig(this.baseUrl), + }); + + const appDetails = await this.internalOctokit.apps.getAuthenticated(); + if (!appDetails.data) { + throw Error( + "No information returned about GitHub App. Is your GitHub App configured correctly?", + ); } - - public async start() { - // TODO: Make this generic. - const auth = { - appId: this.appId, - privateKey: this.privateKey, - }; - - - this.internalOctokit = new Octokit({ - authStrategy: createAppAuth, - auth, - ...GithubInstance.baseOctokitConfig(this.baseUrl), - }); - - const appDetails = await this.internalOctokit.apps.getAuthenticated(); - if (!appDetails.data) { - throw Error("No information returned about GitHub App. Is your GitHub App configured correctly?"); + this.internalAppUrl = appDetails.data.html_url; + + let installPageSize = 100; + let page = 1; + do { + const installations = await this.internalOctokit.apps.listInstallations({ + per_page: 100, + page: page++, + }); + for (const install of installations.data) { + if (install.suspended_at) { + log.warn( + `GitHub app install ${install.id} was suspended. GitHub connections using this install may not work correctly`, + ); + continue; } - this.internalAppUrl = appDetails.data.html_url; - - let installPageSize = 100; - let page = 1; - do { - const installations = await this.internalOctokit.apps.listInstallations({ per_page: 100, page: page++ }); - for (const install of installations.data) { - if (install.suspended_at) { - log.warn(`GitHub app install ${install.id} was suspended. GitHub connections using this install may not work correctly`); - continue; - } - try { - await this.addInstallation(install); - } catch (ex) { - log.info(`Failed to handle GitHub installation ${install.id}`, ex); - } - } - installPageSize = installations.data.length; - } while(installPageSize === 100) - - log.info(`Found ${this.installationsCache.size} installations`); - } - - private async addInstallation(install: InstallationDataType, repos?: {full_name: string}[]) { - let matchesRepository: string[] = []; - if (install.repository_selection === "all" && install.account && 'login' in install.account) { - matchesRepository = [`${install.account.login}/*`.toLowerCase()]; - } else if (repos) { - matchesRepository = repos.map(r => r.full_name.toLowerCase()); - } else { - const installOctokit = this.createOctokitForInstallation(install.id); - const repos = await installOctokit.apps.listReposAccessibleToInstallation({ per_page: 100 }); - matchesRepository.push(...repos.data.repositories.map(r => r.full_name.toLowerCase())); + try { + await this.addInstallation(install); + } catch (ex) { + log.info(`Failed to handle GitHub installation ${install.id}`, ex); } - this.installationsCache.set(install.id, { - account: install.account, - id: install.id, - repository_selection: install.repository_selection, - matchesRepository, - }); - } - - public onInstallationCreated(data: GitHubWebhookTypes.InstallationCreatedEvent|GitHubWebhookTypes.InstallationUnsuspendEvent) { - this.addInstallation(data.installation as InstallationDataType, data.repositories); - } - - public onInstallationRemoved(data: GitHubWebhookTypes.InstallationDeletedEvent|GitHubWebhookTypes.InstallationSuspendEvent) { - this.installationsCache.delete(data.installation.id); + } + installPageSize = installations.data.length; + } while (installPageSize === 100); + + log.info(`Found ${this.installationsCache.size} installations`); + } + + private async addInstallation( + install: InstallationDataType, + repos?: { full_name: string }[], + ) { + let matchesRepository: string[] = []; + if ( + install.repository_selection === "all" && + install.account && + "login" in install.account + ) { + matchesRepository = [`${install.account.login}/*`.toLowerCase()]; + } else if (repos) { + matchesRepository = repos.map((r) => r.full_name.toLowerCase()); + } else { + const installOctokit = this.createOctokitForInstallation(install.id); + const repos = await installOctokit.apps.listReposAccessibleToInstallation( + { per_page: 100 }, + ); + matchesRepository.push( + ...repos.data.repositories.map((r) => r.full_name.toLowerCase()), + ); } - - public get newInstallationUrl() { - if (!this.appUrl) { - throw Error('No configured app url, cannot get installation url'); - } - return new URL(this.appUrl); + this.installationsCache.set(install.id, { + account: install.account, + id: install.id, + repository_selection: install.repository_selection, + matchesRepository, + }); + } + + public onInstallationCreated( + data: + | GitHubWebhookTypes.InstallationCreatedEvent + | GitHubWebhookTypes.InstallationUnsuspendEvent, + ) { + this.addInstallation( + data.installation as InstallationDataType, + data.repositories, + ); + } + + public onInstallationRemoved( + data: + | GitHubWebhookTypes.InstallationDeletedEvent + | GitHubWebhookTypes.InstallationSuspendEvent, + ) { + this.installationsCache.delete(data.installation.id); + } + + public get newInstallationUrl() { + if (!this.appUrl) { + throw Error("No configured app url, cannot get installation url"); } - - public static generateOAuthUrl(baseUrl: URL, action: "authorize"|"access_token", params: OAuthUrlParameters) { - const q = new URLSearchParams(params as Record); - if (baseUrl.hostname === GITHUB_CLOUD_URL.hostname) { - // Cloud doesn't use `api.` for oauth. - baseUrl = GITHUB_CLOUD_PUBLIC_URL; - } - const rawUrl = baseUrl.toString(); - return rawUrl + `${rawUrl.endsWith('/') ? '' : '/'}` + `login/oauth/${action}?${q}`; + return new URL(this.appUrl); + } + + public static generateOAuthUrl( + baseUrl: URL, + action: "authorize" | "access_token", + params: OAuthUrlParameters, + ) { + const q = new URLSearchParams(params as Record); + if (baseUrl.hostname === GITHUB_CLOUD_URL.hostname) { + // Cloud doesn't use `api.` for oauth. + baseUrl = GITHUB_CLOUD_PUBLIC_URL; } + const rawUrl = baseUrl.toString(); + return ( + rawUrl + + `${rawUrl.endsWith("/") ? "" : "/"}` + + `login/oauth/${action}?${q}` + ); + } } export class GithubGraphQLClient { - private static headers: Record = { - 'GraphQL-Features': 'discussions_api', - }; - constructor(private readonly octokit: Octokit) { } - - private async query(request: string, variables: Record) { - log.debug(`GraphQL Query: ${request}`); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return this.octokit.graphql>(`${request}`, { - headers: GithubGraphQLClient.headers, - ...variables, - }); - } - - public async getDiscussionByNumber(owner: string, name: string, number: number) { - const result = await this.query(` + private static headers: Record = { + "GraphQL-Features": "discussions_api", + }; + constructor(private readonly octokit: Octokit) {} + + private async query( + request: string, + variables: Record, + ) { + log.debug(`GraphQL Query: ${request}`); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return this.octokit.graphql>(`${request}`, { + headers: GithubGraphQLClient.headers, + ...variables, + }); + } + + public async getDiscussionByNumber( + owner: string, + name: string, + number: number, + ) { + const result = await this.query( + ` query($name: String!, $owner: String!, $number: Int!) { repository(name: $name, owner: $owner) { discussion(number: $number) { ${DiscussionQL} } } -}`, {name, owner, number}); - return result.repository.discussion as DiscussionQLResponse; - } - - public async addDiscussionComment(discussionId: string, body: string): Promise { - const result = await this.query(` +}`, + { name, owner, number }, + ); + return result.repository.discussion as DiscussionQLResponse; + } + + public async addDiscussionComment( + discussionId: string, + body: string, + ): Promise { + const result = await this.query( + ` mutation addDiscussionComment($discussionId: ID!, $body: String!) { addDiscussionComment(input: {discussionId: $discussionId, body: $body}) { comment { id } } - }`, {discussionId, body}); - return result.addDiscussionComment.comment.id as string; - } - - public async listDiscussions(owner: string, name: string) { - return this.query(` + }`, + { discussionId, body }, + ); + return result.addDiscussionComment.comment.id as string; + } + + public async listDiscussions(owner: string, name: string) { + return this.query( + ` query($name: String!, $owner: String!) { repository(name: $name, owner: $owner) { discussions(first: 10) { @@ -282,6 +370,8 @@ query($name: String!, $owner: String!) { } } } -}`, {name, owner}); - } +}`, + { name, owner }, + ); + } } diff --git a/src/github/GrantChecker.ts b/src/github/GrantChecker.ts index e3eb27eef..bdc69bfcc 100644 --- a/src/github/GrantChecker.ts +++ b/src/github/GrantChecker.ts @@ -2,37 +2,51 @@ import { Appservice } from "matrix-bot-sdk"; import { GitHubRepoConnection } from "../Connections"; import { GrantChecker } from "../grants/GrantCheck"; import { UserTokenStore } from "../tokens/UserTokenStore"; -import { Logger } from 'matrix-appservice-bridge'; +import { Logger } from "matrix-appservice-bridge"; -const log = new Logger('GitHubGrantChecker'); +const log = new Logger("GitHubGrantChecker"); interface GitHubGrantConnectionId { - org: string; - repo: string; + org: string; + repo: string; } - export class GitHubGrantChecker extends GrantChecker { - constructor(private readonly as: Appservice, private readonly tokenStore: UserTokenStore) { - super(as.botIntent, "github") - } + constructor( + private readonly as: Appservice, + private readonly tokenStore: UserTokenStore, + ) { + super(as.botIntent, "github"); + } - protected async checkFallback(roomId: string, connectionId: GitHubGrantConnectionId, sender?: string) { - if (!sender) { - log.debug(`Tried to check fallback for ${roomId} with a missing sender`); - // Cannot validate without a sender. - return false; - } - if (this.as.isNamespacedUser(sender)) { - // Bridge is always valid. - return true; - } - try { - await GitHubRepoConnection.assertUserHasAccessToRepo(sender, connectionId.org, connectionId.repo, this.tokenStore); - return true; - } catch (ex) { - log.info(`Tried to check fallback for ${roomId}: ${sender} does not have access to ${connectionId.org}/${connectionId.repo}`, ex); - return false; - } + protected async checkFallback( + roomId: string, + connectionId: GitHubGrantConnectionId, + sender?: string, + ) { + if (!sender) { + log.debug(`Tried to check fallback for ${roomId} with a missing sender`); + // Cannot validate without a sender. + return false; + } + if (this.as.isNamespacedUser(sender)) { + // Bridge is always valid. + return true; } -} \ No newline at end of file + try { + await GitHubRepoConnection.assertUserHasAccessToRepo( + sender, + connectionId.org, + connectionId.repo, + this.tokenStore, + ); + return true; + } catch (ex) { + log.info( + `Tried to check fallback for ${roomId}: ${sender} does not have access to ${connectionId.org}/${connectionId.repo}`, + ex, + ); + return false; + } + } +} diff --git a/src/github/Router.ts b/src/github/Router.ts index fa05c57be..847b527ba 100644 --- a/src/github/Router.ts +++ b/src/github/Router.ts @@ -8,191 +8,275 @@ import { NAMELESS_ORG_PLACEHOLDER } from "./Types"; const log = new Logger("GitHubProvisionerRouter"); interface GitHubAccountStatus { - loggedIn: boolean; - username?: string; - organisations?: { - name: string; - avatarUrl?: string; - }[] + loggedIn: boolean; + username?: string; + organisations?: { + name: string; + avatarUrl?: string; + }[]; } interface GitHubRepoItem { - name: string; - owner: string; - fullName: string; - description: string|null; - avatarUrl: string; + name: string; + owner: string; + fullName: string; + description: string | null; + avatarUrl: string; } interface GitHubRepoResponse { - page: number; - repositories: GitHubRepoItem[]; - changeSelectionUrl?: string; + page: number; + repositories: GitHubRepoItem[]; + changeSelectionUrl?: string; } export class GitHubProvisionerRouter { - constructor(private readonly config: BridgeConfigGitHub, private readonly tokenStore: UserTokenStore, private readonly githubInstance: GithubInstance) { } + constructor( + private readonly config: BridgeConfigGitHub, + private readonly tokenStore: UserTokenStore, + private readonly githubInstance: GithubInstance, + ) {} - public getRouter() { - const router = Router(); - router.get("/oauth", this.onOAuth.bind(this)); - router.get("/account", this.onGetAccount.bind(this)); - router.get("/orgs/:orgName/repositories", this.onGetOrgRepositories.bind(this)); - router.get("/repositories", this.onGetRepositories.bind(this)); - return router; - } + public getRouter() { + const router = Router(); + router.get("/oauth", this.onOAuth.bind(this)); + router.get("/account", this.onGetAccount.bind(this)); + router.get( + "/orgs/:orgName/repositories", + this.onGetOrgRepositories.bind(this), + ); + router.get("/repositories", this.onGetRepositories.bind(this)); + return router; + } - private onOAuth(req: Request, res: Response<{user_url: string, org_url: string}>) { - if (!this.config.oauth) { - throw new ApiError("GitHub is not configured to support OAuth", ErrCode.UnsupportedOperation); - } - const userUrl = GithubInstance.generateOAuthUrl( - this.config.baseUrl, - "authorize", - { - redirect_uri: this.config.oauth.redirect_uri, - client_id: this.config.oauth.client_id, - state: this.tokenStore.createStateForOAuth(req.query.userId) - } - ); - res.send({ - user_url: userUrl, - org_url: this.githubInstance.newInstallationUrl.toString(), - }); + private onOAuth( + req: Request, + res: Response<{ user_url: string; org_url: string }>, + ) { + if (!this.config.oauth) { + throw new ApiError( + "GitHub is not configured to support OAuth", + ErrCode.UnsupportedOperation, + ); } + const userUrl = GithubInstance.generateOAuthUrl( + this.config.baseUrl, + "authorize", + { + redirect_uri: this.config.oauth.redirect_uri, + client_id: this.config.oauth.client_id, + state: this.tokenStore.createStateForOAuth(req.query.userId), + }, + ); + res.send({ + user_url: userUrl, + org_url: this.githubInstance.newInstallationUrl.toString(), + }); + } - private async onGetAccount(req: Request, res: Response, next: NextFunction) { - const octokit = await this.tokenStore.getOctokitForUser(req.query.userId); - if (!octokit) { - return res.send({ - loggedIn: false, - }); - } - const organisations = []; - const page = req.query.page ? parseInt(req.query.page) : 1; - const perPage = req.query.perPage ? parseInt(req.query.perPage) : 10; - try { - const installs = await octokit.apps.listInstallationsForAuthenticatedUser({page: page, per_page: perPage}); - for (const install of installs.data.installations) { - if (install.account) { - const name = ('login' in install.account) ? install.account.login : install.account.name ?? NAMELESS_ORG_PLACEHOLDER; - organisations.push({ - name, // org or user name - avatarUrl: install.account.avatar_url, - }); - } else { - log.debug(`Skipping install ${install.id}, has no attached account`); - } - } - } catch (ex) { - log.warn(`Failed to fetch orgs for GitHub user ${req.query.userId}`, ex); - return next( new ApiError("Could not fetch orgs for GitHub user", ErrCode.AdditionalActionRequired)); - } - return res.send({ - loggedIn: true, - username: await (await octokit.users.getAuthenticated()).data.login, - organisations, - }) + private async onGetAccount( + req: Request< + undefined, + undefined, + undefined, + { userId: string; page: string; perPage: string } + >, + res: Response, + next: NextFunction, + ) { + const octokit = await this.tokenStore.getOctokitForUser(req.query.userId); + if (!octokit) { + return res.send({ + loggedIn: false, + }); } - - private async onGetOrgRepositories(req: Request<{orgName: string}, undefined, undefined, {userId: string, page: string, perPage: string}>, res: Response, next: NextFunction) { - const octokit = await this.tokenStore.getOctokitForUser(req.query.userId); - if (!octokit) { - // TODO: Better error? - return next(new ApiError("Not logged in", ErrCode.ForbiddenUser)); + const organisations = []; + const page = req.query.page ? parseInt(req.query.page) : 1; + const perPage = req.query.perPage ? parseInt(req.query.perPage) : 10; + try { + const installs = await octokit.apps.listInstallationsForAuthenticatedUser( + { page: page, per_page: perPage }, + ); + for (const install of installs.data.installations) { + if (install.account) { + const name = + "login" in install.account + ? install.account.login + : (install.account.name ?? NAMELESS_ORG_PLACEHOLDER); + organisations.push({ + name, // org or user name + avatarUrl: install.account.avatar_url, + }); + } else { + log.debug(`Skipping install ${install.id}, has no attached account`); } + } + } catch (ex) { + log.warn(`Failed to fetch orgs for GitHub user ${req.query.userId}`, ex); + return next( + new ApiError( + "Could not fetch orgs for GitHub user", + ErrCode.AdditionalActionRequired, + ), + ); + } + return res.send({ + loggedIn: true, + username: await (await octokit.users.getAuthenticated()).data.login, + organisations, + }); + } - const ownSelf = await octokit.users.getAuthenticated(); + private async onGetOrgRepositories( + req: Request< + { orgName: string }, + undefined, + undefined, + { userId: string; page: string; perPage: string } + >, + res: Response, + next: NextFunction, + ) { + const octokit = await this.tokenStore.getOctokitForUser(req.query.userId); + if (!octokit) { + // TODO: Better error? + return next(new ApiError("Not logged in", ErrCode.ForbiddenUser)); + } - const repositories = []; - const page = req.query.page ? parseInt(req.query.page) : 1; - const perPage = req.query.perPage ? parseInt(req.query.perPage) : 10; - try { - let changeInstallUrl: URL | undefined = undefined; - let reposPromise; + const ownSelf = await octokit.users.getAuthenticated(); - if (ownSelf.data.login === req.params.orgName) { - const userInstallation = await this.githubInstance.appOctokit.apps.getUserInstallation({username: ownSelf.data.login}); - reposPromise = octokit.apps.listInstallationReposForAuthenticatedUser({ - page, - installation_id: userInstallation.data.id, - per_page: perPage, - }); - if (userInstallation.data.repository_selection === 'selected') { - changeInstallUrl = new URL(`/settings/installations/${userInstallation.data.id}`, this.config.baseUrl); - } - } else { - const orgInstallation = await this.githubInstance.appOctokit.apps.getOrgInstallation({org: req.params.orgName}); + const repositories = []; + const page = req.query.page ? parseInt(req.query.page) : 1; + const perPage = req.query.perPage ? parseInt(req.query.perPage) : 10; + try { + let changeInstallUrl: URL | undefined = undefined; + let reposPromise; - // Github will error if the authed user tries to list repos of a disallowed installation, even - // if we got the installation ID from the app's instance. - reposPromise = octokit.apps.listInstallationReposForAuthenticatedUser({ - page, - installation_id: orgInstallation.data.id, - per_page: perPage, - }); - if (orgInstallation.data.repository_selection === 'selected') { - changeInstallUrl = new URL(`/organizations/${req.params.orgName}/settings/installations/${orgInstallation.data.id}`, this.config.baseUrl); - } - } - const reposRes = await reposPromise; - for (const repo of reposRes.data.repositories) { - repositories.push({ - name: repo.name, - owner: repo.owner.login, - fullName: repo.full_name, - description: repo.description, - avatarUrl: repo.owner.avatar_url, - }); - } + if (ownSelf.data.login === req.params.orgName) { + const userInstallation = + await this.githubInstance.appOctokit.apps.getUserInstallation({ + username: ownSelf.data.login, + }); + reposPromise = octokit.apps.listInstallationReposForAuthenticatedUser({ + page, + installation_id: userInstallation.data.id, + per_page: perPage, + }); + if (userInstallation.data.repository_selection === "selected") { + changeInstallUrl = new URL( + `/settings/installations/${userInstallation.data.id}`, + this.config.baseUrl, + ); + } + } else { + const orgInstallation = + await this.githubInstance.appOctokit.apps.getOrgInstallation({ + org: req.params.orgName, + }); - return res.send({ - page, - repositories, - changeSelectionUrl: changeInstallUrl?.toString(), - }); - } catch (ex) { - log.warn(`Failed to fetch accessible repos for ${req.params.orgName} / ${req.query.userId}`, ex); - return next(new ApiError("Could not fetch accessible repos for GitHub org", ErrCode.AdditionalActionRequired)); + // Github will error if the authed user tries to list repos of a disallowed installation, even + // if we got the installation ID from the app's instance. + reposPromise = octokit.apps.listInstallationReposForAuthenticatedUser({ + page, + installation_id: orgInstallation.data.id, + per_page: perPage, + }); + if (orgInstallation.data.repository_selection === "selected") { + changeInstallUrl = new URL( + `/organizations/${req.params.orgName}/settings/installations/${orgInstallation.data.id}`, + this.config.baseUrl, + ); } + } + const reposRes = await reposPromise; + for (const repo of reposRes.data.repositories) { + repositories.push({ + name: repo.name, + owner: repo.owner.login, + fullName: repo.full_name, + description: repo.description, + avatarUrl: repo.owner.avatar_url, + }); + } + + return res.send({ + page, + repositories, + changeSelectionUrl: changeInstallUrl?.toString(), + }); + } catch (ex) { + log.warn( + `Failed to fetch accessible repos for ${req.params.orgName} / ${req.query.userId}`, + ex, + ); + return next( + new ApiError( + "Could not fetch accessible repos for GitHub org", + ErrCode.AdditionalActionRequired, + ), + ); } + } - private async onGetRepositories(req: Request, res: Response, next: NextFunction) { - const octokit = await this.tokenStore.getOctokitForUser(req.query.userId); - if (!octokit) { - // TODO: Better error? - return next(new ApiError("Not logged in", ErrCode.ForbiddenUser)); - } + private async onGetRepositories( + req: Request< + undefined, + undefined, + undefined, + { userId: string; page: string; perPage: string } + >, + res: Response, + next: NextFunction, + ) { + const octokit = await this.tokenStore.getOctokitForUser(req.query.userId); + if (!octokit) { + // TODO: Better error? + return next(new ApiError("Not logged in", ErrCode.ForbiddenUser)); + } - const repositories = []; - const page = req.query.page ? parseInt(req.query.page) : 1; - const perPage = req.query.perPage ? parseInt(req.query.perPage) : 10; - try { - const userRes = await octokit.users.getAuthenticated(); - const userInstallation = await this.githubInstance.appOctokit.apps.getUserInstallation({username: userRes.data.login}); - const orgRes = await octokit.apps.listInstallationReposForAuthenticatedUser({ - page, - installation_id: userInstallation.data.id, - per_page: perPage, - }); - for (const repo of orgRes.data.repositories) { - repositories.push({ - name: repo.name, - owner: repo.owner.login, - fullName: repo.full_name, - description: repo.description, - avatarUrl: repo.owner.avatar_url, - }); - } - const changeSelectionUrl = new URL(`/settings/installations/${userInstallation.data.id}`, this.config.baseUrl).toString(); + const repositories = []; + const page = req.query.page ? parseInt(req.query.page) : 1; + const perPage = req.query.perPage ? parseInt(req.query.perPage) : 10; + try { + const userRes = await octokit.users.getAuthenticated(); + const userInstallation = + await this.githubInstance.appOctokit.apps.getUserInstallation({ + username: userRes.data.login, + }); + const orgRes = + await octokit.apps.listInstallationReposForAuthenticatedUser({ + page, + installation_id: userInstallation.data.id, + per_page: perPage, + }); + for (const repo of orgRes.data.repositories) { + repositories.push({ + name: repo.name, + owner: repo.owner.login, + fullName: repo.full_name, + description: repo.description, + avatarUrl: repo.owner.avatar_url, + }); + } + const changeSelectionUrl = new URL( + `/settings/installations/${userInstallation.data.id}`, + this.config.baseUrl, + ).toString(); - return res.send({ - page, - repositories, - ...(orgRes.data.repository_selection === 'selected' && {changeSelectionUrl}) - }); - } catch (ex) { - log.warn(`Failed to fetch accessible repos for ${req.query.userId}`, ex); - return next(new ApiError("Could not fetch accessible repos for GitHub user", ErrCode.AdditionalActionRequired)); - } + return res.send({ + page, + repositories, + ...(orgRes.data.repository_selection === "selected" && { + changeSelectionUrl, + }), + }); + } catch (ex) { + log.warn(`Failed to fetch accessible repos for ${req.query.userId}`, ex); + return next( + new ApiError( + "Could not fetch accessible repos for GitHub user", + ErrCode.AdditionalActionRequired, + ), + ); } + } } diff --git a/src/github/Types.ts b/src/github/Types.ts index f51dc2843..146c8436a 100644 --- a/src/github/Types.ts +++ b/src/github/Types.ts @@ -1,68 +1,96 @@ import { Endpoints } from "@octokit/types"; -export type IssuesGetResponseData = Endpoints["GET /repos/{owner}/{repo}/issues/{issue_number}"]["response"]["data"]; -export type IssuesGetCommentResponseData = Endpoints["GET /repos/{owner}/{repo}/issues/comments/{comment_id}"]["response"]["data"]; -export type PullsListReviewsResponseData = Endpoints["GET /repos/{owner}/{repo}/pulls/{pull_number}/reviews"]["response"]["data"]; -export type PullsListRequestedReviewersResponseData = Endpoints["GET /repos/{owner}/{repo}/pulls/{pull_number}/requested_reviewers"]["response"]["data"]; -export type ReposGetResponseData = Endpoints["GET /repos/{owner}/{repo}"]["response"]["data"]; -export type ProjectsGetResponseData = Endpoints["GET /projects/{project_id}"]["response"]["data"]; -export type ProjectsListForTeamsResponseData = Endpoints["GET /teams/{team_id}/projects"]["response"]["data"]; -export type ProjectsListForRepoResponseData = Endpoints["GET /repos/{owner}/{repo}/projects"]["response"]["data"]; -export type ProjectsListForUserResponseData = Endpoints["GET /users/{username}/projects"]["response"]["data"]; -export type ProjectsListResponseData = ProjectsListForTeamsResponseData|ProjectsListForRepoResponseData|ProjectsListForUserResponseData; -export type IssuesListAssigneesResponseData = Endpoints["GET /repos/{owner}/{repo}/issues"]["response"]["data"]; -export type PullsGetResponseData = Endpoints["GET /repos/{owner}/{repo}/pulls"]["response"]["data"]; -export type PullGetResponseData = Endpoints["GET /repos/{owner}/{repo}/pulls/{pull_number}"]["response"]["data"]; -export type DiscussionDataType = Endpoints["GET /repos/{owner}/{repo}/pulls/{pull_number}"]["response"]["data"]; -export type InstallationDataType = Endpoints["GET /app/installations/{installation_id}"]["response"]["data"]; -export type CreateInstallationAccessTokenDataType = Endpoints["POST /app/installations/{installation_id}/access_tokens"]["response"]["data"]; +export type IssuesGetResponseData = + Endpoints["GET /repos/{owner}/{repo}/issues/{issue_number}"]["response"]["data"]; +export type IssuesGetCommentResponseData = + Endpoints["GET /repos/{owner}/{repo}/issues/comments/{comment_id}"]["response"]["data"]; +export type PullsListReviewsResponseData = + Endpoints["GET /repos/{owner}/{repo}/pulls/{pull_number}/reviews"]["response"]["data"]; +export type PullsListRequestedReviewersResponseData = + Endpoints["GET /repos/{owner}/{repo}/pulls/{pull_number}/requested_reviewers"]["response"]["data"]; +export type ReposGetResponseData = + Endpoints["GET /repos/{owner}/{repo}"]["response"]["data"]; +export type ProjectsGetResponseData = + Endpoints["GET /projects/{project_id}"]["response"]["data"]; +export type ProjectsListForTeamsResponseData = + Endpoints["GET /teams/{team_id}/projects"]["response"]["data"]; +export type ProjectsListForRepoResponseData = + Endpoints["GET /repos/{owner}/{repo}/projects"]["response"]["data"]; +export type ProjectsListForUserResponseData = + Endpoints["GET /users/{username}/projects"]["response"]["data"]; +export type ProjectsListResponseData = + | ProjectsListForTeamsResponseData + | ProjectsListForRepoResponseData + | ProjectsListForUserResponseData; +export type IssuesListAssigneesResponseData = + Endpoints["GET /repos/{owner}/{repo}/issues"]["response"]["data"]; +export type PullsGetResponseData = + Endpoints["GET /repos/{owner}/{repo}/pulls"]["response"]["data"]; +export type PullGetResponseData = + Endpoints["GET /repos/{owner}/{repo}/pulls/{pull_number}"]["response"]["data"]; +export type DiscussionDataType = + Endpoints["GET /repos/{owner}/{repo}/pulls/{pull_number}"]["response"]["data"]; +export type InstallationDataType = + Endpoints["GET /app/installations/{installation_id}"]["response"]["data"]; +export type CreateInstallationAccessTokenDataType = + Endpoints["POST /app/installations/{installation_id}/access_tokens"]["response"]["data"]; export const NAMELESS_ORG_PLACEHOLDER = "No name"; export interface GitHubUserNotification { - id: string; - reason: "assign"|"author"|"comment"|"invitation"|"manual"|"mention"|"review_requested"| - "security_alert"|"state_change"|"subscribed"|"team_mention"; - unread: boolean; - updated_at: number; - last_read_at: number; + id: string; + reason: + | "assign" + | "author" + | "comment" + | "invitation" + | "manual" + | "mention" + | "review_requested" + | "security_alert" + | "state_change" + | "subscribed" + | "team_mention"; + unread: boolean; + updated_at: number; + last_read_at: number; + url: string; + subject: { + title: string; url: string; - subject: { - title: string; - url: string; - latest_comment_url: string|null; - type: "PullRequest"|"Issue"|"RepositoryVulnerabilityAlert"; - // Probably. - url_data?: IssuesGetResponseData; - latest_comment_url_data?: IssuesGetCommentResponseData; - requested_reviewers?: PullsListRequestedReviewersResponseData; - reviews?: PullsListReviewsResponseData; - }; - // Not quite the right type but good nuff. - repository: ReposGetResponseData; + latest_comment_url: string | null; + type: "PullRequest" | "Issue" | "RepositoryVulnerabilityAlert"; + // Probably. + url_data?: IssuesGetResponseData; + latest_comment_url_data?: IssuesGetCommentResponseData; + requested_reviewers?: PullsListRequestedReviewersResponseData; + reviews?: PullsListReviewsResponseData; + }; + // Not quite the right type but good nuff. + repository: ReposGetResponseData; } export interface GitHubOAuthTokenResponse { - state: string; - access_token: string; - expires_in?: string; - refresh_token?: string; - refresh_token_expires_in?: string; - scope: string; - token_type: 'bearer'|'pat'; + state: string; + access_token: string; + expires_in?: string; + refresh_token?: string; + refresh_token_expires_in?: string; + scope: string; + token_type: "bearer" | "pat"; } export interface GitHubOAuthErrorResponse { - error: string; - error_description: string; - error_uri: string; + error: string; + error_description: string; + error_uri: string; } export interface GitHubOAuthToken { - access_token: string; - expires_in?: number; - refresh_token?: string; - refresh_token_expires_in?: number; - scope?: string; - token_type: 'bearer'|'pat'; + access_token: string; + expires_in?: number; + refresh_token?: string; + refresh_token_expires_in?: number; + scope?: string; + token_type: "bearer" | "pat"; } diff --git a/src/grants/GrantCheck.ts b/src/grants/GrantCheck.ts index b81de7eea..4a5958faa 100644 --- a/src/grants/GrantCheck.ts +++ b/src/grants/GrantCheck.ts @@ -6,109 +6,144 @@ import { FormatUtil } from "../FormatUtil"; const GRANT_ACCOUNT_DATA_KEY = "uk.half-shot.matrix-hookshot.grant"; interface GrantContent { - granted: boolean; + granted: boolean; } const log = new Logger("GrantChecker"); export class GrantRejectedError extends Error { - constructor(public readonly roomId: string, public readonly connectionId: string) { - super(`No grant exists for ${roomId}/${connectionId}. Rejecting`); - } + constructor( + public readonly roomId: string, + public readonly connectionId: string, + ) { + super(`No grant exists for ${roomId}/${connectionId}. Rejecting`); + } } - -type ConnectionId = string|object; +type ConnectionId = string | object; export class GrantChecker { - private static stringifyConnectionId(connId: cId) { - if (typeof connId === "string") { - return FormatUtil.hashId(connId.toString()); - } - return FormatUtil.hashId(Object.entries(connId as Record).map((data) => `${data[0]}:${data[1]}`).join('')); - } - - constructor(private readonly intent: Intent, protected readonly grantType: string) { } - - /** - * If the connection hasn't been previously granted, we can use this function to check - * their permissions in the moment. - * - * By default, this always returns false. - */ - // eslint-disable-next-line @typescript-eslint/no-unused-vars - protected checkFallback(_roomId: string, _connectionId: cId, _sender?: string): Promise|boolean { - return false; - } - - private getKey(connectionIdStr: string): string { - return `${GRANT_ACCOUNT_DATA_KEY}/${this.grantType}/${connectionIdStr}`.toLowerCase(); - } - - public async assertConnectionGranted(roomId: string, connectionId: cId, sender?: string) { - const connId = GrantChecker.stringifyConnectionId(connectionId); - try { - const content = await this.intent.underlyingClient.getRoomAccountData(this.getKey(connId), roomId); - if (!content.granted) { - // Previously granted but now stale. - throw new GrantRejectedError(roomId, connId); - } - } catch (ex) { - if (ex instanceof MatrixError && ex.errcode === "M_NOT_FOUND") { - if (!await this.checkFallback?.(roomId, connectionId, sender)) { - throw new GrantRejectedError(roomId, connId); - } else { - log.info(`Grant fallback succeeded for ${roomId}/${connectionId}`); - await this.grantConnection(roomId, connectionId); - } - } else { - log.warn(`Failed to check grant in ${roomId}/${connectionId}`, ex); - throw new GrantRejectedError(roomId, connId); - } - } + private static stringifyConnectionId(connId: cId) { + if (typeof connId === "string") { + return FormatUtil.hashId(connId.toString()); } - - public async grantConnection(roomId: string, connectionId: cId) { - const cidStr = GrantChecker.stringifyConnectionId(connectionId); - log.info(`Granting ${roomId}/${cidStr}`); - await this.intent.underlyingClient.setRoomAccountData( - this.getKey(cidStr), - roomId, - { granted: true } as GrantContent - ); - } - - public async ungrantConnection(roomId: string, connectionId: cId) { - const cidStr = GrantChecker.stringifyConnectionId(connectionId); - log.info(`Ungranting ${roomId}/${cidStr}`); - await this.intent.underlyingClient.setRoomAccountData( - this.getKey(cidStr), - roomId, - { granted: false } as GrantContent + return FormatUtil.hashId( + Object.entries(connId as Record) + .map((data) => `${data[0]}:${data[1]}`) + .join(""), + ); + } + + constructor( + private readonly intent: Intent, + protected readonly grantType: string, + ) {} + + /** + * If the connection hasn't been previously granted, we can use this function to check + * their permissions in the moment. + * + * By default, this always returns false. + */ + + protected checkFallback( + _roomId: string, + _connectionId: cId, + _sender?: string, + ): Promise | boolean { + return false; + } + + private getKey(connectionIdStr: string): string { + return `${GRANT_ACCOUNT_DATA_KEY}/${this.grantType}/${connectionIdStr}`.toLowerCase(); + } + + public async assertConnectionGranted( + roomId: string, + connectionId: cId, + sender?: string, + ) { + const connId = GrantChecker.stringifyConnectionId(connectionId); + try { + const content = + await this.intent.underlyingClient.getRoomAccountData( + this.getKey(connId), + roomId, ); + if (!content.granted) { + // Previously granted but now stale. + throw new GrantRejectedError(roomId, connId); + } + } catch (ex) { + if (ex instanceof MatrixError && ex.errcode === "M_NOT_FOUND") { + if (!(await this.checkFallback?.(roomId, connectionId, sender))) { + throw new GrantRejectedError(roomId, connId); + } else { + log.info(`Grant fallback succeeded for ${roomId}/${connectionId}`); + await this.grantConnection(roomId, connectionId); + } + } else { + log.warn(`Failed to check grant in ${roomId}/${connectionId}`, ex); + throw new GrantRejectedError(roomId, connId); + } } + } + + public async grantConnection(roomId: string, connectionId: cId) { + const cidStr = GrantChecker.stringifyConnectionId(connectionId); + log.info(`Granting ${roomId}/${cidStr}`); + await this.intent.underlyingClient.setRoomAccountData( + this.getKey(cidStr), + roomId, + { granted: true } as GrantContent, + ); + } + + public async ungrantConnection(roomId: string, connectionId: cId) { + const cidStr = GrantChecker.stringifyConnectionId(connectionId); + log.info(`Ungranting ${roomId}/${cidStr}`); + await this.intent.underlyingClient.setRoomAccountData( + this.getKey(cidStr), + roomId, + { granted: false } as GrantContent, + ); + } } /** * Check the grant of a given connection, falling back to checking the permissions of the user * across the bridge. */ -export class ConfigGrantChecker extends GrantChecker { - static ConfigMinAccessLevel = BridgePermissionLevel.admin; - - constructor(grantType: string, private readonly as: Appservice, private readonly config: BridgeConfig) { - super(as.botIntent, grantType) +export class ConfigGrantChecker< + cId extends ConnectionId = ConnectionId, +> extends GrantChecker { + static ConfigMinAccessLevel = BridgePermissionLevel.admin; + + constructor( + grantType: string, + private readonly as: Appservice, + private readonly config: BridgeConfig, + ) { + super(as.botIntent, grantType); + } + + protected checkFallback( + _roomId: string, + _connectionId: cId, + sender?: string, + ) { + if (!sender) { + // Cannot validate without a sender. + return false; } - - protected checkFallback(_roomId: string, _connectionId: cId, sender?: string) { - if (!sender) { - // Cannot validate without a sender. - return false; - } - if (this.as.isNamespacedUser(sender)) { - // Bridge is always valid. - return true; - } - return this.config.checkPermission(sender, this.grantType, ConfigGrantChecker.ConfigMinAccessLevel); + if (this.as.isNamespacedUser(sender)) { + // Bridge is always valid. + return true; } -} \ No newline at end of file + return this.config.checkPermission( + sender, + this.grantType, + ConfigGrantChecker.ConfigMinAccessLevel, + ); + } +} diff --git a/src/hound/reader.ts b/src/hound/reader.ts index 378ce0763..7105806ec 100644 --- a/src/hound/reader.ts +++ b/src/hound/reader.ts @@ -1,6 +1,10 @@ import axios from "axios"; import { ConnectionManager } from "../ConnectionManager"; -import { HoundConnection, HoundPayload, HoundActivity } from "../Connections/HoundConnection"; +import { + HoundConnection, + HoundPayload, + HoundActivity, +} from "../Connections/HoundConnection"; import { MessageQueue } from "../MessageQueue"; import { IBridgeStorageProvider } from "../Stores/StorageProvider"; import { BridgeConfigChallengeHound } from "../config/Config"; @@ -10,133 +14,165 @@ import { hashId } from "../libRs"; const log = new Logger("HoundReader"); export class HoundReader { - private connections: HoundConnection[]; - private challengeIds: string[]; - private timeout?: NodeJS.Timeout; - private shouldRun = true; - private readonly houndClient: axios.AxiosInstance; - - get sleepingInterval() { - return 60000 / (this.challengeIds.length || 1); - } - - constructor( - config: BridgeConfigChallengeHound, - private readonly connectionManager: ConnectionManager, - private readonly queue: MessageQueue, - private readonly storage: IBridgeStorageProvider, - ) { - this.connections = this.connectionManager.getAllConnectionsOfType(HoundConnection); - this.challengeIds = this.connections.map(c => c.challengeId); - this.houndClient = axios.create({ - headers: { - 'Authorization': config.token, - } - }); - - connectionManager.on('new-connection', newConnection => { - if (!(newConnection instanceof HoundConnection)) { - return; - } - if (!this.challengeIds.includes(newConnection.challengeId)) { - log.info(`Connection added, adding "${newConnection.challengeId}" to queue`); - this.challengeIds.push(newConnection.challengeId); - } - }); - connectionManager.on('connection-removed', removed => { - if (!(removed instanceof HoundConnection)) { - return; - } - let shouldKeepUrl = false; - this.connections = this.connections.filter(c => { - // Cheeky reuse of iteration to determine if we should remove this URL. - if (c.connectionId !== removed.connectionId) { - shouldKeepUrl = shouldKeepUrl || c.challengeId === removed.challengeId; - return true; - } - return false; - }); - if (shouldKeepUrl) { - log.info(`Connection removed, but not removing "${removed.challengeId}" as it is still in use`); - return; - } - log.info(`Connection removed, removing "${removed.challengeId}" from queue`); - this.challengeIds = this.challengeIds.filter(u => u !== removed.challengeId) - }); - - log.debug('Loaded challenge IDs:', [...this.challengeIds].join(', ')); - void this.pollChallenges(); - } - - public stop() { - this.shouldRun = false; - if (this.timeout) { - clearTimeout(this.timeout); + private connections: HoundConnection[]; + private challengeIds: string[]; + private timeout?: NodeJS.Timeout; + private shouldRun = true; + private readonly houndClient: axios.AxiosInstance; + + get sleepingInterval() { + return 60000 / (this.challengeIds.length || 1); + } + + constructor( + config: BridgeConfigChallengeHound, + private readonly connectionManager: ConnectionManager, + private readonly queue: MessageQueue, + private readonly storage: IBridgeStorageProvider, + ) { + this.connections = + this.connectionManager.getAllConnectionsOfType(HoundConnection); + this.challengeIds = this.connections.map((c) => c.challengeId); + this.houndClient = axios.create({ + headers: { + Authorization: config.token, + }, + }); + + connectionManager.on("new-connection", (newConnection) => { + if (!(newConnection instanceof HoundConnection)) { + return; + } + if (!this.challengeIds.includes(newConnection.challengeId)) { + log.info( + `Connection added, adding "${newConnection.challengeId}" to queue`, + ); + this.challengeIds.push(newConnection.challengeId); + } + }); + connectionManager.on("connection-removed", (removed) => { + if (!(removed instanceof HoundConnection)) { + return; + } + let shouldKeepUrl = false; + this.connections = this.connections.filter((c) => { + // Cheeky reuse of iteration to determine if we should remove this URL. + if (c.connectionId !== removed.connectionId) { + shouldKeepUrl = + shouldKeepUrl || c.challengeId === removed.challengeId; + return true; } + return false; + }); + if (shouldKeepUrl) { + log.info( + `Connection removed, but not removing "${removed.challengeId}" as it is still in use`, + ); + return; + } + log.info( + `Connection removed, removing "${removed.challengeId}" from queue`, + ); + this.challengeIds = this.challengeIds.filter( + (u) => u !== removed.challengeId, + ); + }); + + log.debug("Loaded challenge IDs:", [...this.challengeIds].join(", ")); + void this.pollChallenges(); + } + + public stop() { + this.shouldRun = false; + if (this.timeout) { + clearTimeout(this.timeout); } - - private static hashActivity(activity: HoundActivity) { - return hashId(activity.activityId + activity.name + activity.distanceKilometers + activity.durationSeconds + activity.elevationMeters); - } - - public async poll(challengeId: string) { - const resAct = await this.houndClient.get(`https://api.challengehound.com/v1/activities?challengeId=${challengeId}&size=10`); - const activites = (resAct.data["results"] as HoundActivity[]).map(a => ({...a, hash: HoundReader.hashActivity(a)})); - const seen = await this.storage.hasSeenHoundActivity(challengeId, ...activites.map(a => a.hash)); - - // Don't emit anything if our cache is empty, as we'll probably create duplicates. - const hasSeenChallenge = await this.storage.hasSeenHoundChallenge(challengeId); - if (hasSeenChallenge) { - for (const activity of activites) { - if (seen.includes(activity.hash)) { - continue; - } - this.queue.push({ - eventName: "hound.activity", - sender: "HoundReader", - data: { - challengeId, - activity: activity, - } - }); - } + } + + private static hashActivity(activity: HoundActivity) { + return hashId( + activity.activityId + + activity.name + + activity.distanceKilometers + + activity.durationSeconds + + activity.elevationMeters, + ); + } + + public async poll(challengeId: string) { + const resAct = await this.houndClient.get( + `https://api.challengehound.com/v1/activities?challengeId=${challengeId}&size=10`, + ); + const activites = (resAct.data["results"] as HoundActivity[]).map((a) => ({ + ...a, + hash: HoundReader.hashActivity(a), + })); + const seen = await this.storage.hasSeenHoundActivity( + challengeId, + ...activites.map((a) => a.hash), + ); + + // Don't emit anything if our cache is empty, as we'll probably create duplicates. + const hasSeenChallenge = + await this.storage.hasSeenHoundChallenge(challengeId); + if (hasSeenChallenge) { + for (const activity of activites) { + if (seen.includes(activity.hash)) { + continue; } - // Ensure we don't add duplicates to the storage. - await this.storage.storeHoundActivity(challengeId, ...activites.filter(s => !seen.includes(s.hash)).map(a => a.hash)) + this.queue.push({ + eventName: "hound.activity", + sender: "HoundReader", + data: { + challengeId, + activity: activity, + }, + }); + } } - - public async pollChallenges(): Promise { - log.debug(`Checking for updates`); - - const fetchingStarted = Date.now(); - - const challengeId = this.challengeIds.pop(); - let sleepFor = this.sleepingInterval; - - if (challengeId) { - try { - await this.poll(challengeId); - const elapsed = Date.now() - fetchingStarted; - sleepFor = Math.max(this.sleepingInterval - elapsed, 0); - log.debug(`Activity fetching took ${elapsed / 1000}s, sleeping for ${sleepFor / 1000}s`); - - if (elapsed > this.sleepingInterval) { - log.warn(`It took us longer to update the activities than the expected interval`); - } - } catch (ex) { - log.warn("Failed to poll for challenge", ex); - } finally { - this.challengeIds.splice(0, 0, challengeId); - } - } else { - log.debug(`No activites available to poll`); + // Ensure we don't add duplicates to the storage. + await this.storage.storeHoundActivity( + challengeId, + ...activites.filter((s) => !seen.includes(s.hash)).map((a) => a.hash), + ); + } + + public async pollChallenges(): Promise { + log.debug(`Checking for updates`); + + const fetchingStarted = Date.now(); + + const challengeId = this.challengeIds.pop(); + let sleepFor = this.sleepingInterval; + + if (challengeId) { + try { + await this.poll(challengeId); + const elapsed = Date.now() - fetchingStarted; + sleepFor = Math.max(this.sleepingInterval - elapsed, 0); + log.debug( + `Activity fetching took ${elapsed / 1000}s, sleeping for ${sleepFor / 1000}s`, + ); + + if (elapsed > this.sleepingInterval) { + log.warn( + `It took us longer to update the activities than the expected interval`, + ); } - - this.timeout = setTimeout(() => { - if (!this.shouldRun) { - return; - } - void this.pollChallenges(); - }, sleepFor); + } catch (ex) { + log.warn("Failed to poll for challenge", ex); + } finally { + this.challengeIds.splice(0, 0, challengeId); + } + } else { + log.debug(`No activites available to poll`); } -} \ No newline at end of file + + this.timeout = setTimeout(() => { + if (!this.shouldRun) { + return; + } + void this.pollChallenges(); + }, sleepFor); + } +} diff --git a/src/jira/AdminCommands.ts b/src/jira/AdminCommands.ts index d02c46855..30ed1707d 100644 --- a/src/jira/AdminCommands.ts +++ b/src/jira/AdminCommands.ts @@ -4,66 +4,93 @@ import { JiraAPIAccessibleResource } from "./Types"; import { Logger } from "matrix-appservice-bridge"; import { BridgePermissionLevel } from "../config/Config"; -const log = new Logger('JiraBotCommands'); +const log = new Logger("JiraBotCommands"); export class JiraBotCommands extends AdminRoomCommandHandler { - @botCommand("jira login", {help: "Log in to JIRA", category: Category.Jira, permissionLevel: BridgePermissionLevel.login}) - public async loginCommand() { - if (!this.config.jira?.oauth || !this.tokenStore.jiraOAuth) { - this.sendNotice(`Bot is not configured with JIRA OAuth support.`); - return; - } - const state = this.tokenStore.createStateForOAuth(this.userId); - const url = await this.tokenStore.jiraOAuth?.getAuthUrl(state); - await this.sendNotice(`Open ${url} to link your account to the bridge.`); + @botCommand("jira login", { + help: "Log in to JIRA", + category: Category.Jira, + permissionLevel: BridgePermissionLevel.login, + }) + public async loginCommand() { + if (!this.config.jira?.oauth || !this.tokenStore.jiraOAuth) { + this.sendNotice(`Bot is not configured with JIRA OAuth support.`); + return; } + const state = this.tokenStore.createStateForOAuth(this.userId); + const url = await this.tokenStore.jiraOAuth?.getAuthUrl(state); + await this.sendNotice(`Open ${url} to link your account to the bridge.`); + } + @botCommand("jira logout", { + help: "Clear any login information", + category: Category.Jira, + permissionLevel: BridgePermissionLevel.login, + }) + public async logout() { + if (!this.config.jira?.oauth || !this.tokenStore.jiraOAuth) { + this.sendNotice(`Bot is not configured with JIRA OAuth support.`); + return; + } + if ( + await this.tokenStore.clearUserToken( + "jira", + this.userId, + this.config.jira.instanceName, + ) + ) { + return this.sendNotice( + `Your JIRA account has been unlinked from your Matrix user.`, + ); + } + return this.sendNotice(`No JIRA account was linked to your Matrix user.`); + } - @botCommand("jira logout", {help: "Clear any login information", category: Category.Jira, permissionLevel: BridgePermissionLevel.login}) - public async logout() { - if (!this.config.jira?.oauth || !this.tokenStore.jiraOAuth) { - this.sendNotice(`Bot is not configured with JIRA OAuth support.`); - return; - } - if (await this.tokenStore.clearUserToken("jira", this.userId, this.config.jira.instanceName)) { - return this.sendNotice(`Your JIRA account has been unlinked from your Matrix user.`); - } - return this.sendNotice(`No JIRA account was linked to your Matrix user.`); + @botCommand("jira whoami", { + help: "Determine JIRA identity", + category: Category.Jira, + permissionLevel: BridgePermissionLevel.login, + }) + public async whoami() { + if (!this.config.jira) { + await this.sendNotice(`Bot is not configured with JIRA OAuth support.`); + return; } + const client = await this.tokenStore.getJiraForUser( + this.userId, + this.config.jira.url, + ); - @botCommand("jira whoami", {help: "Determine JIRA identity", category: Category.Jira, permissionLevel: BridgePermissionLevel.login}) - public async whoami() { - if (!this.config.jira) { - await this.sendNotice(`Bot is not configured with JIRA OAuth support.`); - return; - } - const client = await this.tokenStore.getJiraForUser(this.userId, this.config.jira.url); - - if (!client) { - await this.sendNotice(`You are not logged into JIRA.`); - return; - } - // Get all resources for user - let resources: JiraAPIAccessibleResource[]; - try { - resources = await client.getAccessibleResources(); - } catch (ex) { - log.warn(`Could not request resources from JIRA API: `, ex); - await this.sendNotice(`Could not request JIRA resources due to an error.`); - return; - } - let response = resources.length === 0 ? `You do not have any instances authorised with this bot.` : 'You have access to the following instances:'; - for (const resource of resources) { - const clientForResource = await client.getClientForResource(resource); - if (!clientForResource) { - continue; - } - const user = await clientForResource.getCurrentUser(); - response += - `\n - ${resource.name}` + - (user.name ? ` ${user.name}` : "") + - (user.displayName ? ` (${user.displayName})` : ""); - } - await this.sendNotice(response); + if (!client) { + await this.sendNotice(`You are not logged into JIRA.`); + return; + } + // Get all resources for user + let resources: JiraAPIAccessibleResource[]; + try { + resources = await client.getAccessibleResources(); + } catch (ex) { + log.warn(`Could not request resources from JIRA API: `, ex); + await this.sendNotice( + `Could not request JIRA resources due to an error.`, + ); + return; + } + let response = + resources.length === 0 + ? `You do not have any instances authorised with this bot.` + : "You have access to the following instances:"; + for (const resource of resources) { + const clientForResource = await client.getClientForResource(resource); + if (!clientForResource) { + continue; + } + const user = await clientForResource.getCurrentUser(); + response += + `\n - ${resource.name}` + + (user.name ? ` ${user.name}` : "") + + (user.displayName ? ` (${user.displayName})` : ""); } + await this.sendNotice(response); + } } diff --git a/src/jira/Client.ts b/src/jira/Client.ts index 52cd375fe..9b679426d 100644 --- a/src/jira/Client.ts +++ b/src/jira/Client.ts @@ -1,69 +1,91 @@ -import axios, { Method } from 'axios'; -import JiraApi from 'jira-client'; -import { JiraAccount, JiraAPIAccessibleResource, JiraProject } from './Types'; +import axios, { Method } from "axios"; +import JiraApi from "jira-client"; +import { JiraAccount, JiraAPIAccessibleResource, JiraProject } from "./Types"; export function isJiraCloudInstance(host: string) { - return host.endsWith('atlassian.net'); + return host.endsWith("atlassian.net"); } export interface JiraClient { - getAccessibleResources(): Promise; - getClientForUrl(url: URL): Promise; - getClientForName(name: string): Promise; - getClientForResource(res: JiraAPIAccessibleResource): Promise; + getAccessibleResources(): Promise; + getClientForUrl(url: URL): Promise; + getClientForName(name: string): Promise; + getClientForResource( + res: JiraAPIAccessibleResource, + ): Promise; } export class JiraApiError extends Error { - constructor(readonly errorMessages: string[], readonly errors: { description: string}) { - super(); - } + constructor( + readonly errorMessages: string[], + readonly errors: { description: string }, + ) { + super(); + } - public get message() { - return `JIRA API Error: ${this.errors.description}`; - } + public get message() { + return `JIRA API Error: ${this.errors.description}`; + } } - export abstract class HookshotJiraApi extends JiraApi { - constructor(private options: JiraApi.JiraApiOptions, private readonly res: JiraAPIAccessibleResource) { - super(options); - } + constructor( + private options: JiraApi.JiraApiOptions, + private readonly res: JiraAPIAccessibleResource, + ) { + super(options); + } - public get resource() { - return this.res; - } + public get resource() { + return this.res; + } - public abstract getAllProjects(query?: string, maxResults?: number): AsyncIterable; + public abstract getAllProjects( + query?: string, + maxResults?: number, + ): AsyncIterable; - protected async apiRequest(path: string, method?: Method, data?: undefined): Promise - protected async apiRequest(path: string, method: Method, data?: R): Promise { - const url = `https://${this.options.host}/${this.options.base}${path}`; - const res = await axios.request({ url, - method: method || "GET", - data, - headers: { - Authorization: `Bearer ${this.options.bearer}` - }, - responseType: 'json', - }); - return res.data; - } - + protected async apiRequest( + path: string, + method?: Method, + data?: undefined, + ): Promise; + protected async apiRequest( + path: string, + method: Method, + data?: R, + ): Promise { + const url = `https://${this.options.host}/${this.options.base}${path}`; + const res = await axios.request({ + url, + method: method || "GET", + data, + headers: { + Authorization: `Bearer ${this.options.bearer}`, + }, + responseType: "json", + }); + return res.data; + } - async getProject(projectIdOrKey: string): Promise { - return await super.getProject(projectIdOrKey) as JiraProject; - } + async getProject(projectIdOrKey: string): Promise { + return (await super.getProject(projectIdOrKey)) as JiraProject; + } - async searchUsers(opts: {query: string, maxResults?: number}|{username: string, maxResults?: number}): Promise { - // Types are wrong here. - return super.searchUsers(opts as never) as unknown as JiraAccount[]; - } - - async addNewIssue(issue: JiraApi.IssueObject): Promise { - const res = await super.addNewIssue(issue); - if (res.errors) { - throw new JiraApiError(res.errorMessages, res.errors); - } - return res; + async searchUsers( + opts: + | { query: string; maxResults?: number } + | { username: string; maxResults?: number }, + ): Promise { + // Types are wrong here. + return super.searchUsers(opts as never) as unknown as JiraAccount[]; + } + + async addNewIssue(issue: JiraApi.IssueObject): Promise { + const res = await super.addNewIssue(issue); + if (res.errors) { + throw new JiraApiError(res.errorMessages, res.errors); } + return res; + } } diff --git a/src/jira/GrantChecker.ts b/src/jira/GrantChecker.ts index d2a4e2457..755f50c97 100644 --- a/src/jira/GrantChecker.ts +++ b/src/jira/GrantChecker.ts @@ -3,31 +3,40 @@ import { JiraProjectConnection } from "../Connections"; import { GrantChecker } from "../grants/GrantCheck"; import { UserTokenStore } from "../tokens/UserTokenStore"; -interface JiraGrantConnectionId{ - url: string; +interface JiraGrantConnectionId { + url: string; } - - export class JiraGrantChecker extends GrantChecker { - constructor(private readonly as: Appservice, private readonly tokenStore: UserTokenStore) { - super(as.botIntent, "jira") - } + constructor( + private readonly as: Appservice, + private readonly tokenStore: UserTokenStore, + ) { + super(as.botIntent, "jira"); + } - protected async checkFallback(roomId: string, connectionId: JiraGrantConnectionId, sender?: string) { - if (!sender) { - // Cannot validate without a sender. - return false; - } - if (this.as.isNamespacedUser(sender)) { - // Bridge is always valid. - return true; - } - try { - await JiraProjectConnection.assertUserHasAccessToProject(this.tokenStore, sender, connectionId.url); - return true; - } catch { - return false; - } + protected async checkFallback( + roomId: string, + connectionId: JiraGrantConnectionId, + sender?: string, + ) { + if (!sender) { + // Cannot validate without a sender. + return false; } -} \ No newline at end of file + if (this.as.isNamespacedUser(sender)) { + // Bridge is always valid. + return true; + } + try { + await JiraProjectConnection.assertUserHasAccessToProject( + this.tokenStore, + sender, + connectionId.url, + ); + return true; + } catch { + return false; + } + } +} diff --git a/src/jira/OAuth.ts b/src/jira/OAuth.ts index 91eaa1546..209688bbd 100644 --- a/src/jira/OAuth.ts +++ b/src/jira/OAuth.ts @@ -1,21 +1,32 @@ import { JiraOAuthResult } from "./Types"; export interface JiraOAuth { - getAuthUrl(state: string): Promise; - exchangeRequestForToken(codeOrToken: string,verifier?: string): Promise; + getAuthUrl(state: string): Promise; + exchangeRequestForToken( + codeOrToken: string, + verifier?: string, + ): Promise; } export interface JiraOAuthRequestOnPrem { - state: string; - oauthToken: string; - oauthVerifier: string; + state: string; + oauthToken: string; + oauthVerifier: string; } -export function encodeJiraToken(oauthToken: string, oauthTokenSecret: string): string { - return `jira-oauth1.0:${oauthToken}/${oauthTokenSecret}`; +export function encodeJiraToken( + oauthToken: string, + oauthTokenSecret: string, +): string { + return `jira-oauth1.0:${oauthToken}/${oauthTokenSecret}`; } -export function decodeJiraToken(token: string): {oauthToken: string, oauthTokenSecret: string} { - const [ oauthToken, oauthTokenSecret] = token.substring("jira-oauth1.0:".length).split('/'); - return { oauthToken, oauthTokenSecret }; -} \ No newline at end of file +export function decodeJiraToken(token: string): { + oauthToken: string; + oauthTokenSecret: string; +} { + const [oauthToken, oauthTokenSecret] = token + .substring("jira-oauth1.0:".length) + .split("/"); + return { oauthToken, oauthTokenSecret }; +} diff --git a/src/jira/Router.ts b/src/jira/Router.ts index b5992db4f..8cf31dbc0 100644 --- a/src/jira/Router.ts +++ b/src/jira/Router.ts @@ -14,234 +14,318 @@ type JiraOAuthRequestCloud = OAuthRequest; const log = new Logger("JiraRouter"); interface OAuthQueryCloud { - state: string; - code: string; + state: string; + code: string; } interface OAuthQueryOnPrem { - state: string; - oauth_token: string; - oauth_verifier: string; + state: string; + oauth_token: string; + oauth_verifier: string; } export class JiraWebhooksRouter { - - public static IsJIRARequest(req: Request): boolean { - if (req.headers['x-atlassian-webhook-identifier']) { - return true; // Cloud - } else if (req.headers['user-agent']?.match(/JIRA/)) { - return true; // JIRA On-prem - } - return false; + public static IsJIRARequest(req: Request): boolean { + if (req.headers["x-atlassian-webhook-identifier"]) { + return true; // Cloud + } else if (req.headers["user-agent"]?.match(/JIRA/)) { + return true; // JIRA On-prem } + return false; + } - constructor(private readonly queue: MessageQueue, private readonly secret: string) { - - } + constructor( + private readonly queue: MessageQueue, + private readonly secret: string, + ) {} - /** - * Verifies a JIRA webhook request for a valid secret or signature. - * @throws If the request is invalid - * @param req The express request. - */ - public verifyWebhookRequest(req: Request, buffer: Buffer): void { - const querySecret = req.query.secret; - const hubSecret = req.headers['x-hub-signature']?.slice('sha256='.length); - if (querySecret) { - if (querySecret && !this.secret) { - log.warn(`Received JIRA request with a query secret but no secret is configured`); - throw new ApiError("Invalid secret", ErrCode.BadToken); - } - if (querySecret !== this.secret) { - log.warn(`JIRA secret did not match`); - throw new ApiError("Invalid secret", ErrCode.BadToken); - } - return; - } - else if (hubSecret) { - if (!this.secret) { - log.warn(`Received JIRA request with a signature but no secret is configured`); - throw new ApiError("Invalid secret", ErrCode.BadToken); - } - const calculatedSecret = createHmac('sha256', this.secret).update( - buffer - ).digest('hex'); - if (hubSecret !== calculatedSecret) { - log.warn(`JIRA secret did not match`); - throw new ApiError("Signature did not match", ErrCode.BadToken); - } - return; - } - log.warn(`Received JIRA request without a signature or query parameter but a secret was expected`); + /** + * Verifies a JIRA webhook request for a valid secret or signature. + * @throws If the request is invalid + * @param req The express request. + */ + public verifyWebhookRequest(req: Request, buffer: Buffer): void { + const querySecret = req.query.secret; + const hubSecret = req.headers["x-hub-signature"]?.slice("sha256=".length); + if (querySecret) { + if (querySecret && !this.secret) { + log.warn( + `Received JIRA request with a query secret but no secret is configured`, + ); + throw new ApiError("Invalid secret", ErrCode.BadToken); + } + if (querySecret !== this.secret) { + log.warn(`JIRA secret did not match`); + throw new ApiError("Invalid secret", ErrCode.BadToken); + } + return; + } else if (hubSecret) { + if (!this.secret) { + log.warn( + `Received JIRA request with a signature but no secret is configured`, + ); throw new ApiError("Invalid secret", ErrCode.BadToken); + } + const calculatedSecret = createHmac("sha256", this.secret) + .update(buffer) + .digest("hex"); + if (hubSecret !== calculatedSecret) { + log.warn(`JIRA secret did not match`); + throw new ApiError("Signature did not match", ErrCode.BadToken); + } + return; } + log.warn( + `Received JIRA request without a signature or query parameter but a secret was expected`, + ); + throw new ApiError("Invalid secret", ErrCode.BadToken); + } - private async onOAuth(req: Request, res: Response) { - let result: OAuthRequestResult; - if ("oauth_token" in req.query) { - // On-prem - if (typeof req.query.state !== "string") { - throw new ApiError("Missing 'state' parameter", ErrCode.BadValue); - } - if (typeof req.query.oauth_token !== "string") { - throw new ApiError("Missing 'code' parameter", ErrCode.BadValue); - } - const { state, oauth_token, oauth_verifier } = req.query; - try { - result = await this.queue.pushWait({ - eventName: "jira.oauth.response", - sender: "GithubWebhooks", - data: { - state, - // eslint-disable-next-line camelcase - oauthToken: oauth_token, - // eslint-disable-next-line camelcase - oauthVerifier: oauth_verifier, - }, - }); - } - catch (ex) { - log.error("Failed to handle oauth request:", ex); - throw new ApiError("Encountered an error handing oauth request", ErrCode.Unknown); - } - } else if ("code" in req.query) { - // Cloud - if (typeof req.query.state !== "string") { - throw new ApiError("Missing 'state' parameter", ErrCode.BadValue); - } - if (typeof req.query.code !== "string") { - throw new ApiError("Missing 'code' parameter", ErrCode.BadValue); - } - const { state, code } = req.query; - log.info(`Got new JIRA oauth request (${state.substring(0, 8)})`); - try { - result = await this.queue.pushWait({ - eventName: "jira.oauth.response", - sender: "GithubWebhooks", - data: { - state, - code, - }, - }); - } catch (ex) { - log.error("Failed to handle oauth request:", ex); - throw new ApiError("Encountered an error handing oauth request", ErrCode.Unknown); - } - } else { - throw new ApiError("Missing 'oauth_token'/'code' parameter", ErrCode.BadValue); - } - - switch (result) { - case OAuthRequestResult.Success: - return res.send(`

Your account has been bridged

`); - case OAuthRequestResult.UserNotFound: - return res.status(404).send(`

Could not find user which authorised this request. Has it timed out?

`); - default: - return res.status(404).send(`

Unknown failure

`); - } + private async onOAuth( + req: Request, + res: Response, + ) { + let result: OAuthRequestResult; + if ("oauth_token" in req.query) { + // On-prem + if (typeof req.query.state !== "string") { + throw new ApiError("Missing 'state' parameter", ErrCode.BadValue); + } + if (typeof req.query.oauth_token !== "string") { + throw new ApiError("Missing 'code' parameter", ErrCode.BadValue); + } + const { state, oauth_token, oauth_verifier } = req.query; + try { + result = await this.queue.pushWait< + JiraOAuthRequestOnPrem, + OAuthRequestResult + >({ + eventName: "jira.oauth.response", + sender: "GithubWebhooks", + data: { + state, + // eslint-disable-next-line camelcase + oauthToken: oauth_token, + // eslint-disable-next-line camelcase + oauthVerifier: oauth_verifier, + }, + }); + } catch (ex) { + log.error("Failed to handle oauth request:", ex); + throw new ApiError( + "Encountered an error handing oauth request", + ErrCode.Unknown, + ); + } + } else if ("code" in req.query) { + // Cloud + if (typeof req.query.state !== "string") { + throw new ApiError("Missing 'state' parameter", ErrCode.BadValue); + } + if (typeof req.query.code !== "string") { + throw new ApiError("Missing 'code' parameter", ErrCode.BadValue); + } + const { state, code } = req.query; + log.info(`Got new JIRA oauth request (${state.substring(0, 8)})`); + try { + result = await this.queue.pushWait< + JiraOAuthRequestCloud, + OAuthRequestResult + >({ + eventName: "jira.oauth.response", + sender: "GithubWebhooks", + data: { + state, + code, + }, + }); + } catch (ex) { + log.error("Failed to handle oauth request:", ex); + throw new ApiError( + "Encountered an error handing oauth request", + ErrCode.Unknown, + ); + } + } else { + throw new ApiError( + "Missing 'oauth_token'/'code' parameter", + ErrCode.BadValue, + ); } - public getRouter() { - const router = Router(); - router.use(json()); - router.get("/oauth", this.onOAuth.bind(this)); - return router; + switch (result) { + case OAuthRequestResult.Success: + return res.send(`

Your account has been bridged

`); + case OAuthRequestResult.UserNotFound: + return res + .status(404) + .send( + `

Could not find user which authorised this request. Has it timed out?

`, + ); + default: + return res.status(404).send(`

Unknown failure

`); } -} + } + public getRouter() { + const router = Router(); + router.use(json()); + router.get("/oauth", this.onOAuth.bind(this)); + return router; + } +} interface JiraAccountStatus { - loggedIn: boolean; - instances?: { - name: string; - url: string; - }[] -} -interface JiraProjectsListing { + loggedIn: boolean; + instances?: { name: string; - key: string; - id: string; url: string; + }[]; +} +interface JiraProjectsListing { + name: string; + key: string; + id: string; + url: string; } export class JiraProvisionerRouter { - constructor(private readonly config: BridgeConfigJira, private readonly tokenStore: UserTokenStore) { } - - public getRouter() { - const router = Router(); - router.get("/oauth", this.onOAuth.bind(this)); - router.get("/account", this.onGetAccount.bind(this)); - router.get("/instances/:instanceName/projects", this.onGetInstanceProjects.bind(this)); - return router; + constructor( + private readonly config: BridgeConfigJira, + private readonly tokenStore: UserTokenStore, + ) {} + + public getRouter() { + const router = Router(); + router.get("/oauth", this.onOAuth.bind(this)); + router.get("/account", this.onGetAccount.bind(this)); + router.get( + "/instances/:instanceName/projects", + this.onGetInstanceProjects.bind(this), + ); + return router; + } + + private async onOAuth( + req: Request, + res: Response<{ url: string }>, + ) { + if (!this.tokenStore.jiraOAuth) { + throw new ApiError("JIRA OAuth is disabled.", ErrCode.DisabledFeature); } + const url = await this.tokenStore.jiraOAuth.getAuthUrl( + this.tokenStore.createStateForOAuth(req.query.userId), + ); + res.send({ url }); + } - private async onOAuth(req: Request, res: Response<{url: string}>) { - if (!this.tokenStore.jiraOAuth) { - throw new ApiError('JIRA OAuth is disabled.', ErrCode.DisabledFeature); - } - const url = await this.tokenStore.jiraOAuth.getAuthUrl(this.tokenStore.createStateForOAuth(req.query.userId)); - res.send({ url }); + private async onGetAccount( + req: Request, + res: Response, + next: NextFunction, + ) { + const jiraUser = await this.tokenStore.getJiraForUser( + req.query.userId, + this.config.url, + ); + if (!jiraUser) { + return res.send({ + loggedIn: false, + }); + } + const instances = []; + try { + for (const resource of await jiraUser.getAccessibleResources()) { + instances.push({ + url: resource.url, + name: resource.name, + }); + } + } catch (ex) { + log.warn( + `Failed to fetch accessible resources for ${req.query.userId}`, + ex, + ); + return next( + new ApiError( + "Could not fetch accessible resources for JIRA user.", + ErrCode.Unknown, + ), + ); } + return res.send({ + loggedIn: true, + instances: instances, + }); + } - private async onGetAccount(req: Request, res: Response, next: NextFunction) { - const jiraUser = await this.tokenStore.getJiraForUser(req.query.userId, this.config.url); - if (!jiraUser) { - return res.send({ - loggedIn: false, - }); - } - const instances = []; - try { - for (const resource of await jiraUser.getAccessibleResources()) { - instances.push({ - url: resource.url, - name: resource.name, - }); - } - } catch (ex) { - log.warn(`Failed to fetch accessible resources for ${req.query.userId}`, ex); - return next( new ApiError("Could not fetch accessible resources for JIRA user.", ErrCode.Unknown)); - } - return res.send({ - loggedIn: true, - instances: instances - }) + private async onGetInstanceProjects( + req: Request< + { instanceName: string }, + undefined, + undefined, + { userId: string } + >, + res: Response, + next: NextFunction, + ) { + const jiraUser = await this.tokenStore.getJiraForUser( + req.query.userId, + this.config.url, + ); + if (!jiraUser) { + // TODO: Better error? + return next(new ApiError("Not logged in", ErrCode.ForbiddenUser)); } - private async onGetInstanceProjects(req: Request<{instanceName: string}, undefined, undefined, {userId: string}>, res: Response, next: NextFunction) { - const jiraUser = await this.tokenStore.getJiraForUser(req.query.userId, this.config.url); - if (!jiraUser) { - // TODO: Better error? - return next( new ApiError("Not logged in", ErrCode.ForbiddenUser)); - } - - let resClient: HookshotJiraApi|null; - try { - resClient = await jiraUser.getClientForName(req.params.instanceName); - } catch (ex) { - log.warn(`Failed to fetch client for ${req.params.instanceName} for ${req.query.userId}`, ex); - return next( new ApiError("Could not fetch accessible resources for JIRA user.", ErrCode.Unknown)); - } - if (!resClient) { - return next( new ApiError("Instance not known or not accessible to this user.", ErrCode.ForbiddenUser)); - } - - const projects: JiraProjectsListing[] = []; - try { - for await (const project of resClient.getAllProjects()) { - projects.push({ - key: project.key, - name: project.name, - id: project.id, - // Technically not the real URL, but good enough for hookshot! - url: `${resClient.resource.url}/projects/${project.key}`, - }); - } - } catch (ex) { - log.warn(`Failed to fetch accessible projects for ${req.params.instanceName} / ${req.query.userId}`, ex); - return next( new ApiError("Could not fetch accessible projects for JIRA user.", ErrCode.Unknown)); - } - - return res.send(projects); + let resClient: HookshotJiraApi | null; + try { + resClient = await jiraUser.getClientForName(req.params.instanceName); + } catch (ex) { + log.warn( + `Failed to fetch client for ${req.params.instanceName} for ${req.query.userId}`, + ex, + ); + return next( + new ApiError( + "Could not fetch accessible resources for JIRA user.", + ErrCode.Unknown, + ), + ); } + if (!resClient) { + return next( + new ApiError( + "Instance not known or not accessible to this user.", + ErrCode.ForbiddenUser, + ), + ); + } + + const projects: JiraProjectsListing[] = []; + try { + for await (const project of resClient.getAllProjects()) { + projects.push({ + key: project.key, + name: project.name, + id: project.id, + // Technically not the real URL, but good enough for hookshot! + url: `${resClient.resource.url}/projects/${project.key}`, + }); + } + } catch (ex) { + log.warn( + `Failed to fetch accessible projects for ${req.params.instanceName} / ${req.query.userId}`, + ex, + ); + return next( + new ApiError( + "Could not fetch accessible projects for JIRA user.", + ErrCode.Unknown, + ), + ); + } + + return res.send(projects); + } } diff --git a/src/jira/Types.ts b/src/jira/Types.ts index eb759f4af..75c52df76 100644 --- a/src/jira/Types.ts +++ b/src/jira/Types.ts @@ -1,124 +1,123 @@ - export interface JiraIssueType { - self: string; - id: string; - description: string; - iconUrl: string; - name: string; - subtask: boolean; - avatarId: number; - hierachyLevel: number; + self: string; + id: string; + description: string; + iconUrl: string; + name: string; + subtask: boolean; + avatarId: number; + hierachyLevel: number; } export interface JiraProject { - /** - * URL - */ - self: string; - id: string; - key: string; - name: string; - projectTypeKey: string; - avatarUrls?: Record; - simplified?: boolean; - issueTypes?: JiraIssueType[]; + /** + * URL + */ + self: string; + id: string; + key: string; + name: string; + projectTypeKey: string; + avatarUrls?: Record; + simplified?: boolean; + issueTypes?: JiraIssueType[]; } export interface JiraAccount { - /** - * URL - */ - self: string; - accountId: string; - avatarUrls: Record; - displayName: string; - active: boolean; - timeZone: string; - /** - * e.g. "atlassian" - */ - accountType: string; + /** + * URL + */ + self: string; + accountId: string; + avatarUrls: Record; + displayName: string; + active: boolean; + timeZone: string; + /** + * e.g. "atlassian" + */ + accountType: string; } export interface JiraComment { - /** - * URL - */ - self: string; - id: string; - author: JiraAccount; - body: string; - updateAuthor: JiraAccount; - created: string; - updated: string; - jsdPublic: boolean; + /** + * URL + */ + self: string; + id: string; + author: JiraAccount; + body: string; + updateAuthor: JiraAccount; + created: string; + updated: string; + jsdPublic: boolean; } export interface JiraIssue { - /** - * URL - */ - self: string; - id: string; - key: string; - fields: { - summary: string; - issuetype: unknown; - project: JiraProject; - assignee: null|unknown; - priority: unknown; - status: unknown; - creator?: JiraAccount; - } + /** + * URL + */ + self: string; + id: string; + key: string; + fields: { + summary: string; + issuetype: unknown; + project: JiraProject; + assignee: null | unknown; + priority: unknown; + status: unknown; + creator?: JiraAccount; + }; } export interface JiraVersion { - /** - * URL - */ - self: string; - id: string; - description: string; - name: string; - archived: boolean; - released: boolean; - startDate?: string; - releaseDate?: string; - overdue: boolean; - userStartDate?: string; - userReleaseDate?: string; - project?: string; - projectId: number; + /** + * URL + */ + self: string; + id: string; + description: string; + name: string; + archived: boolean; + released: boolean; + startDate?: string; + releaseDate?: string; + overdue: boolean; + userStartDate?: string; + userReleaseDate?: string; + project?: string; + projectId: number; } export interface JiraStoredToken { - expires_in?: number; - access_token: string; - refresh_token?: string; - instance: string; + expires_in?: number; + access_token: string; + refresh_token?: string; + instance: string; } export interface JiraOAuthResult { - state?: string; - access_token: string; - refresh_token?: string; - expires_in?: number; - scope: string; + state?: string; + access_token: string; + refresh_token?: string; + expires_in?: number; + scope: string; } export interface JiraAPIAccessibleResource { - id: string; - url: string, - name: string, - scopes?: string[], - avatarUrl?: string, + id: string; + url: string; + name: string; + scopes?: string[]; + avatarUrl?: string; } export interface JiraCloudProjectSearchResponse { - nextPage: string; - maxResults: number; - startAt: number; - isLast: boolean; - values: JiraProject[]; + nextPage: string; + maxResults: number; + startAt: number; + isLast: boolean; + values: JiraProject[]; } -export type JiraOnPremProjectSearchResponse = JiraProject[]; \ No newline at end of file +export type JiraOnPremProjectSearchResponse = JiraProject[]; diff --git a/src/jira/WebhookTypes.ts b/src/jira/WebhookTypes.ts index a04980ac2..35aaa1715 100644 --- a/src/jira/WebhookTypes.ts +++ b/src/jira/WebhookTypes.ts @@ -1,40 +1,40 @@ import { JiraAccount, JiraComment, JiraIssue, JiraVersion } from "./Types"; export interface IJiraWebhookEvent { - timestamp: number; - webhookEvent: string; + timestamp: number; + webhookEvent: string; } export interface JiraCommentCreatedEvent extends IJiraWebhookEvent { - webhookEvent: "comment_created"; - comment: JiraComment; - issue: JiraIssue; + webhookEvent: "comment_created"; + comment: JiraComment; + issue: JiraIssue; } export interface JiraIssueEvent extends IJiraWebhookEvent { - webhookEvent: "issue_updated"|"issue_created"; - comment: JiraComment; - issue: JiraIssue; + webhookEvent: "issue_updated" | "issue_created"; + comment: JiraComment; + issue: JiraIssue; } export interface JiraIssueUpdatedEvent extends JiraIssueEvent { - webhookEvent: "issue_updated"; - user: JiraAccount; - changelog: { - id: string; - items: { - field: string; - fieldtype: string; - fieldId: string; - from: string|null; - fromString: string|null; - to: string|null; - toString: null; - }[]; - } + webhookEvent: "issue_updated"; + user: JiraAccount; + changelog: { + id: string; + items: { + field: string; + fieldtype: string; + fieldId: string; + from: string | null; + fromString: string | null; + to: string | null; + toString: null; + }[]; + }; } export interface JiraVersionEvent extends IJiraWebhookEvent { - webhookEvent: "version_created"|"version_updated"|"version_released"; - version: JiraVersion; -} \ No newline at end of file + webhookEvent: "version_created" | "version_updated" | "version_released"; + version: JiraVersion; +} diff --git a/src/jira/client/CloudClient.ts b/src/jira/client/CloudClient.ts index 383cb825d..7f3d2ac74 100644 --- a/src/jira/client/CloudClient.ts +++ b/src/jira/client/CloudClient.ts @@ -1,166 +1,191 @@ - -import axios from 'axios'; +import axios from "axios"; import QuickLRU from "@alloc/quick-lru"; -import { JiraAPIAccessibleResource, JiraIssue, JiraOAuthResult, JiraProject, JiraCloudProjectSearchResponse, JiraStoredToken } from '../Types'; -import { BridgeConfigJira, BridgeConfigJiraCloudOAuth } from '../../config/Config'; +import { + JiraAPIAccessibleResource, + JiraIssue, + JiraOAuthResult, + JiraProject, + JiraCloudProjectSearchResponse, + JiraStoredToken, +} from "../Types"; +import { + BridgeConfigJira, + BridgeConfigJiraCloudOAuth, +} from "../../config/Config"; import { Logger } from "matrix-appservice-bridge"; -import { HookshotJiraApi, JiraClient } from '../Client'; -import JiraApi from 'jira-client'; +import { HookshotJiraApi, JiraClient } from "../Client"; +import JiraApi from "jira-client"; import * as qs from "node:querystring"; const log = new Logger("JiraCloudClient"); const ACCESSIBLE_RESOURCE_CACHE_LIMIT = 100; const ACCESSIBLE_RESOURCE_CACHE_TTL_MS = 60000; - export class HookshotCloudJiraApi extends HookshotJiraApi { - async getIssue(issueIdOrKey: string): Promise { - return this.apiRequest(`/rest/api/3/issue/${issueIdOrKey}`); - } - - async addNewIssue(issue: JiraApi.IssueObject): Promise { - // V3 has a more complex format. - if (issue.fields?.description) { - issue.fields.description = { - "type": "doc", - "version": 1, - "content": [ - { - "type": "paragraph", - "content": [ - { - "text": issue.fields.description, - "type": "text" - } - ] - } - ] - }; - } - return super.addNewIssue(issue); - } - - async * getAllProjects(query?: string, maxResults = 10): AsyncIterable { - let response; - let startAt = 0; - do { - const params = qs.stringify({ - startAt, - maxResults, - query - }); - response = await this.apiRequest(`/rest/api/3/project/search?${params}`); - yield* response.values; - startAt += response.maxResults; - } while(!response.isLast) + async getIssue(issueIdOrKey: string): Promise { + return this.apiRequest(`/rest/api/3/issue/${issueIdOrKey}`); + } + + async addNewIssue(issue: JiraApi.IssueObject): Promise { + // V3 has a more complex format. + if (issue.fields?.description) { + issue.fields.description = { + type: "doc", + version: 1, + content: [ + { + type: "paragraph", + content: [ + { + text: issue.fields.description, + type: "text", + }, + ], + }, + ], + }; } + return super.addNewIssue(issue); + } + + async *getAllProjects( + query?: string, + maxResults = 10, + ): AsyncIterable { + let response; + let startAt = 0; + do { + const params = qs.stringify({ + startAt, + maxResults, + query, + }); + response = await this.apiRequest( + `/rest/api/3/project/search?${params}`, + ); + yield* response.values; + startAt += response.maxResults; + } while (!response.isLast); + } } - export class JiraCloudClient implements JiraClient { - - /** - * Cache of accessible resources for a user. - */ - static resourceCache = new QuickLRU>({ - maxSize: ACCESSIBLE_RESOURCE_CACHE_LIMIT, - maxAge: ACCESSIBLE_RESOURCE_CACHE_TTL_MS - }); - - constructor( - private storedToken: JiraStoredToken, - private readonly onTokenRefreshed: (newData: JiraStoredToken) => Promise, - private readonly config: BridgeConfigJira, - private readonly instanceHost: string) { - + /** + * Cache of accessible resources for a user. + */ + static resourceCache = new QuickLRU< + string, + Promise + >({ + maxSize: ACCESSIBLE_RESOURCE_CACHE_LIMIT, + maxAge: ACCESSIBLE_RESOURCE_CACHE_TTL_MS, + }); + + constructor( + private storedToken: JiraStoredToken, + private readonly onTokenRefreshed: ( + newData: JiraStoredToken, + ) => Promise, + private readonly config: BridgeConfigJira, + private readonly instanceHost: string, + ) {} + + private get bearer() { + return this.storedToken.access_token; + } + + async getAccessibleResources() { + try { + const existingPromise = JiraCloudClient.resourceCache.get(this.bearer); + if (existingPromise) { + return await existingPromise; + } + } catch { + // Existing failed promise, break out and try again. + JiraCloudClient.resourceCache.delete(this.bearer); } - - private get bearer() { - return this.storedToken.access_token; + await this.checkTokenAge(); + const promise = (async () => { + const res = await axios.get( + `https://${this.instanceHost}/oauth/token/accessible-resources`, + { + headers: { + Authorization: `Bearer ${this.bearer}`, + }, + responseType: "json", + }, + ); + return res.data as JiraAPIAccessibleResource[]; + })(); + JiraCloudClient.resourceCache.set(this.bearer, promise); + return promise; + } + + async checkTokenAge() { + if (!this.storedToken.refresh_token || !this.storedToken.expires_in) { + throw Error("Cannot refresh token, token does not support it"); } - - async getAccessibleResources() { - try { - const existingPromise = JiraCloudClient.resourceCache.get(this.bearer); - if (existingPromise) { - return await existingPromise; - } - } catch { - // Existing failed promise, break out and try again. - JiraCloudClient.resourceCache.delete(this.bearer); - } - await this.checkTokenAge(); - const promise = (async () => { - const res = await axios.get(`https://${this.instanceHost}/oauth/token/accessible-resources`, { - headers: { - Authorization: `Bearer ${this.bearer}` - }, - responseType: 'json', - }); - return res.data as JiraAPIAccessibleResource[]; - })(); - JiraCloudClient.resourceCache.set(this.bearer, promise); - return promise; + if (this.storedToken.expires_in + 60000 > Date.now()) { + return; } - - async checkTokenAge() { - if (!this.storedToken.refresh_token || !this.storedToken.expires_in) { - throw Error('Cannot refresh token, token does not support it'); - } - if (this.storedToken.expires_in + 60000 > Date.now()) { - return; - } - log.info(`Refreshing oauth token`); - if ("client_id" in (this.config.oauth || {}) === false) { - throw Error('Cannot refresh token, on-prem installs do not support it'); - } - const config = this.config.oauth as BridgeConfigJiraCloudOAuth; - // Refresh the token - const res = await axios.post(`https://${this.instanceHost}/oauth/token`, { - grant_type: "refresh_token", - client_id: config.client_id, - client_secret: config.client_secret, - refresh_token: this.storedToken.refresh_token, - }); - const data = res.data as JiraOAuthResult; - if (data.expires_in) { - data.expires_in += Date.now() + (data.expires_in * 1000); - } - this.storedToken = { - expires_in: data.expires_in, - refresh_token: data.refresh_token, - access_token: data.access_token, - instance: this.config.instanceName, - }; - this.onTokenRefreshed(this.storedToken); + log.info(`Refreshing oauth token`); + if ("client_id" in (this.config.oauth || {}) === false) { + throw Error("Cannot refresh token, on-prem installs do not support it"); } - - async getClientForUrl(url: URL) { - const resource = (await this.getAccessibleResources()).find((r) => new URL(r.url).origin === url.origin); - if (!resource) { - return null; - } - return this.getClientForResource(resource); + const config = this.config.oauth as BridgeConfigJiraCloudOAuth; + // Refresh the token + const res = await axios.post(`https://${this.instanceHost}/oauth/token`, { + grant_type: "refresh_token", + client_id: config.client_id, + client_secret: config.client_secret, + refresh_token: this.storedToken.refresh_token, + }); + const data = res.data as JiraOAuthResult; + if (data.expires_in) { + data.expires_in += Date.now() + data.expires_in * 1000; } - - async getClientForName(name: string) { - const resource = (await this.getAccessibleResources()).find((r) => r.name === name); - if (!resource) { - return null; - } - return this.getClientForResource(resource); + this.storedToken = { + expires_in: data.expires_in, + refresh_token: data.refresh_token, + access_token: data.access_token, + instance: this.config.instanceName, + }; + this.onTokenRefreshed(this.storedToken); + } + + async getClientForUrl(url: URL) { + const resource = (await this.getAccessibleResources()).find( + (r) => new URL(r.url).origin === url.origin, + ); + if (!resource) { + return null; } - - async getClientForResource(res: JiraAPIAccessibleResource) { - // Check token age - await this.checkTokenAge(); - return new HookshotCloudJiraApi({ - protocol: 'https', - host: this.instanceHost, - base: `/ex/jira/${res.id}`, - apiVersion: '3', - strictSSL: true, - bearer: this.bearer, - }, res); + return this.getClientForResource(resource); + } + + async getClientForName(name: string) { + const resource = (await this.getAccessibleResources()).find( + (r) => r.name === name, + ); + if (!resource) { + return null; } -} \ No newline at end of file + return this.getClientForResource(resource); + } + + async getClientForResource(res: JiraAPIAccessibleResource) { + // Check token age + await this.checkTokenAge(); + return new HookshotCloudJiraApi( + { + protocol: "https", + host: this.instanceHost, + base: `/ex/jira/${res.id}`, + apiVersion: "3", + strictSSL: true, + bearer: this.bearer, + }, + res, + ); + } +} diff --git a/src/jira/client/OnPremClient.ts b/src/jira/client/OnPremClient.ts index 1a65c4d86..ed6d5fad6 100644 --- a/src/jira/client/OnPremClient.ts +++ b/src/jira/client/OnPremClient.ts @@ -1,98 +1,111 @@ - -import { JiraAPIAccessibleResource, JiraProject, JiraStoredToken, JiraOnPremProjectSearchResponse } from '../Types'; -import { BridgeConfigJiraOnPremOAuth } from '../../config/Config'; -import { decodeJiraToken } from '../OAuth'; -import { KeyObject } from 'crypto'; -import { HookshotJiraApi, JiraClient } from '../Client'; -import JiraApi from 'jira-client'; +import { + JiraAPIAccessibleResource, + JiraProject, + JiraStoredToken, + JiraOnPremProjectSearchResponse, +} from "../Types"; +import { BridgeConfigJiraOnPremOAuth } from "../../config/Config"; +import { decodeJiraToken } from "../OAuth"; +import { KeyObject } from "crypto"; +import { HookshotJiraApi, JiraClient } from "../Client"; +import JiraApi from "jira-client"; function createSearchTerm(name?: string) { - return name?.toLowerCase()?.replaceAll(/[^a-z0-9]/g, '') || ''; + return name?.toLowerCase()?.replaceAll(/[^a-z0-9]/g, "") || ""; } export class HookshotOnPremJiraApi extends HookshotJiraApi { + constructor(options: JiraApi.JiraApiOptions, res: JiraAPIAccessibleResource) { + super(options, res); + } - constructor(options: JiraApi.JiraApiOptions, res: JiraAPIAccessibleResource) { - super(options, res); - } - - async * getAllProjects(search?: string): AsyncIterable { - // Note, status is ignored. - const results = await this.genericGet(`project`) as JiraOnPremProjectSearchResponse; - - // Reasonable search algorithm. - const searchTerm = search && createSearchTerm(search); - if (searchTerm) { - yield *results.filter(p => createSearchTerm(p.name).includes(searchTerm) || createSearchTerm(p.key).includes(searchTerm)); - return; - } + async *getAllProjects(search?: string): AsyncIterable { + // Note, status is ignored. + const results = (await this.genericGet( + `project`, + )) as JiraOnPremProjectSearchResponse; - yield *results; + // Reasonable search algorithm. + const searchTerm = search && createSearchTerm(search); + if (searchTerm) { + yield* results.filter( + (p) => + createSearchTerm(p.name).includes(searchTerm) || + createSearchTerm(p.key).includes(searchTerm), + ); + return; } + + yield* results; + } } -export class JiraOnPremClient implements JiraClient{ - private readonly token: string; - private readonly tokenSecret: string; - private readonly resource: Promise; - private readonly client: HookshotOnPremJiraApi; - private readonly instanceUrl: URL; - constructor( - storedToken: JiraStoredToken, - private readonly privateKey: KeyObject, - private readonly config: BridgeConfigJiraOnPremOAuth, - instanceUrl: string) { - const res = decodeJiraToken(storedToken.access_token); - this.instanceUrl = new URL(instanceUrl); - this.token = res.oauthToken; - this.tokenSecret = res.oauthTokenSecret; - this.client = new HookshotOnPremJiraApi({ - protocol: this.instanceUrl.protocol.replace(':', ''), - host: this.instanceUrl.hostname, - port: this.instanceUrl.port, - apiVersion: '2', - strictSSL: true, - oauth: { - consumer_key: this.config.consumerKey, - // This gets passed through several layers but will be used as - // the correct type in https://github.com/request/oauth-sign/blob/master/index.js#L103 - consumer_secret: this.privateKey as unknown as string, - access_token: this.token, - access_token_secret: this.tokenSecret, - } - }, {id: "on-prem", name: "not-set", url: instanceUrl, scopes: []}); - this.resource = this.client.getServerInfo().then((s) => ({ - id: "on-prem", - name: s.serverTitle, - url: instanceUrl, - scopes: [], - })); - } +export class JiraOnPremClient implements JiraClient { + private readonly token: string; + private readonly tokenSecret: string; + private readonly resource: Promise; + private readonly client: HookshotOnPremJiraApi; + private readonly instanceUrl: URL; + constructor( + storedToken: JiraStoredToken, + private readonly privateKey: KeyObject, + private readonly config: BridgeConfigJiraOnPremOAuth, + instanceUrl: string, + ) { + const res = decodeJiraToken(storedToken.access_token); + this.instanceUrl = new URL(instanceUrl); + this.token = res.oauthToken; + this.tokenSecret = res.oauthTokenSecret; + this.client = new HookshotOnPremJiraApi( + { + protocol: this.instanceUrl.protocol.replace(":", ""), + host: this.instanceUrl.hostname, + port: this.instanceUrl.port, + apiVersion: "2", + strictSSL: true, + oauth: { + consumer_key: this.config.consumerKey, + // This gets passed through several layers but will be used as + // the correct type in https://github.com/request/oauth-sign/blob/master/index.js#L103 + consumer_secret: this.privateKey as unknown as string, + access_token: this.token, + access_token_secret: this.tokenSecret, + }, + }, + { id: "on-prem", name: "not-set", url: instanceUrl, scopes: [] }, + ); + this.resource = this.client.getServerInfo().then((s) => ({ + id: "on-prem", + name: s.serverTitle, + url: instanceUrl, + scopes: [], + })); + } - async getAccessibleResources(): Promise { - // TODO: This. - return [await this.resource]; - } + async getAccessibleResources(): Promise { + // TODO: This. + return [await this.resource]; + } - async getClientForUrl(url: URL) { - if (this.instanceUrl.host !== url.host) { - return null; - } - return this.getClientForResource(await this.resource); + async getClientForUrl(url: URL) { + if (this.instanceUrl.host !== url.host) { + return null; } + return this.getClientForResource(await this.resource); + } - async getClientForName(name: string) { - const res = await this.resource; - if (res.name !== name) { - return null; - } - return this.getClientForResource(res); + async getClientForName(name: string) { + const res = await this.resource; + if (res.name !== name) { + return null; } + return this.getClientForResource(res); + } - async getClientForResource(res: JiraAPIAccessibleResource) { - if (res.id !== (await this.resource).id) { - return null; - } - return this.client; + async getClientForResource(res: JiraAPIAccessibleResource) { + if (res.id !== (await this.resource).id) { + return null; } -} \ No newline at end of file + return this.client; + } +} diff --git a/src/jira/index.ts b/src/jira/index.ts index c5e4bda6b..0d2fed5ee 100644 --- a/src/jira/index.ts +++ b/src/jira/index.ts @@ -1,4 +1,5 @@ import * as libRs from "../libRs"; export const generateJiraWebLinkFromIssue = libRs.generateJiraWeblinkFromIssue; -export const generateJiraWebLinkFromVersion = libRs.generateJiraWeblinkFromVersion; +export const generateJiraWebLinkFromVersion = + libRs.generateJiraWeblinkFromVersion; diff --git a/src/jira/oauth/CloudOAuth.ts b/src/jira/oauth/CloudOAuth.ts index 6225dce15..2793e3cd8 100644 --- a/src/jira/oauth/CloudOAuth.ts +++ b/src/jira/oauth/CloudOAuth.ts @@ -5,44 +5,51 @@ import axios from "axios"; import { JiraOAuthResult } from "../Types"; const JiraOnPremOAuthScopes = [ - // Reading issues, comments - "read:jira-work", - // Creating issues, comments - "write:jira-work", - // Reading user - "read:jira-user", - "read:me", - "read:account", - // To get a refresh token - "offline_access", + // Reading issues, comments + "read:jira-work", + // Creating issues, comments + "write:jira-work", + // Reading user + "read:jira-user", + "read:me", + "read:account", + // To get a refresh token + "offline_access", ]; - export class JiraCloudOAuth implements JiraOAuth { - constructor(private readonly config: BridgeConfigJiraCloudOAuth) { } - public async getAuthUrl(state: string) { - const options = { - audience: "api.atlassian.com", - client_id: this.config.client_id, - scope: JiraOnPremOAuthScopes.join(" "), - redirect_uri: this.config.redirect_uri, - state: state, - response_type: "code", - prompt: "consent", - }; - return `https://auth.atlassian.com/authorize?${qs.stringify(options)}`; - } + constructor(private readonly config: BridgeConfigJiraCloudOAuth) {} + public async getAuthUrl(state: string) { + const options = { + audience: "api.atlassian.com", + client_id: this.config.client_id, + scope: JiraOnPremOAuthScopes.join(" "), + redirect_uri: this.config.redirect_uri, + state: state, + response_type: "code", + prompt: "consent", + }; + return `https://auth.atlassian.com/authorize?${qs.stringify(options)}`; + } - public async exchangeRequestForToken(code: string): Promise { - const accessTokenRes = await axios.post("https://auth.atlassian.com/oauth/token", { - client_id: this.config.client_id, - client_secret: this.config.client_secret, - code: code, - grant_type: "authorization_code", - redirect_uri: this.config.redirect_uri, - }); - const result = accessTokenRes.data as { access_token: string, scope: string, expires_in: number, refresh_token: string}; - result.expires_in = Date.now() + (result.expires_in * 1000); - return result; - } + public async exchangeRequestForToken(code: string): Promise { + const accessTokenRes = await axios.post( + "https://auth.atlassian.com/oauth/token", + { + client_id: this.config.client_id, + client_secret: this.config.client_secret, + code: code, + grant_type: "authorization_code", + redirect_uri: this.config.redirect_uri, + }, + ); + const result = accessTokenRes.data as { + access_token: string; + scope: string; + expires_in: number; + refresh_token: string; + }; + result.expires_in = Date.now() + result.expires_in * 1000; + return result; + } } diff --git a/src/jira/oauth/OnPremOAuth.ts b/src/jira/oauth/OnPremOAuth.ts index d6fb5d0ee..1a86831d5 100644 --- a/src/jira/oauth/OnPremOAuth.ts +++ b/src/jira/oauth/OnPremOAuth.ts @@ -1,5 +1,5 @@ import { BridgeConfigJiraOnPremOAuth } from "../../config/Config"; -import Axios, { Method } from "axios" +import Axios, { Method } from "axios"; import qs from "querystring"; import { createPrivateKey, createSign, KeyObject } from "crypto"; import fs from "fs"; @@ -7,233 +7,347 @@ import { Logger } from "matrix-appservice-bridge"; import { encodeJiraToken, JiraOAuth } from "../OAuth"; import { JiraOAuthResult } from "../Types"; -const log = new Logger('JiraOnPremOAuth'); +const log = new Logger("JiraOnPremOAuth"); const NONCE_CHARS = [ - 'a','b','c','d','e','f','g','h','i','j','k','l','m','n', - 'o','p','q','r','s','t','u','v','w','x','y','z','A','B', - 'C','D','E','F','G','H','I','J','K','L','M','N','O','P', - 'Q','R','S','T','U','V','W','X','Y','Z','0','1','2','3', - '4','5','6','7','8','9' + "a", + "b", + "c", + "d", + "e", + "f", + "g", + "h", + "i", + "j", + "k", + "l", + "m", + "n", + "o", + "p", + "q", + "r", + "s", + "t", + "u", + "v", + "w", + "x", + "y", + "z", + "A", + "B", + "C", + "D", + "E", + "F", + "G", + "H", + "I", + "J", + "K", + "L", + "M", + "N", + "O", + "P", + "Q", + "R", + "S", + "T", + "U", + "V", + "W", + "X", + "Y", + "Z", + "0", + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", ]; export class JiraOnPremOAuth implements JiraOAuth { + private static nonce(nonceSize = 32) { + return [...Array(nonceSize)] + .map(() => NONCE_CHARS[Math.floor(Math.random() * NONCE_CHARS.length)]) + .join(""); + } - private static nonce(nonceSize = 32) { - return [...Array(nonceSize)].map(() => NONCE_CHARS[Math.floor(Math.random() * NONCE_CHARS.length)]).join(''); - } - - private static encodeData(toEncode: string): string { - return encodeURIComponent(toEncode).replace(/!/g, "%21") - .replace(/'/g, "%27") - .replace(/\(/g, "%28") - .replace(/\)/g, "%29") - .replace(/\*/g, "%2A"); - } + private static encodeData(toEncode: string): string { + return encodeURIComponent(toEncode) + .replace(/!/g, "%21") + .replace(/'/g, "%27") + .replace(/\(/g, "%28") + .replace(/\)/g, "%29") + .replace(/\*/g, "%2A"); + } - private static normalizeUrl(url: string|URL): string { - url = typeof url === "string" ? new URL(url) : url; - let port = ""; - if (url.port) { - if ((url.protocol == "http:" && url.port != "80" ) || - (url.protocol == "https:" && url.port != "443") ) { - port = ":" + url.port; - } - } - if (!url.pathname || url.pathname == "") { - url.pathname = "/"; - } - - return url.protocol + "//" + url.hostname + port + url.pathname; + private static normalizeUrl(url: string | URL): string { + url = typeof url === "string" ? new URL(url) : url; + let port = ""; + if (url.port) { + if ( + (url.protocol == "http:" && url.port != "80") || + (url.protocol == "https:" && url.port != "443") + ) { + port = ":" + url.port; + } } - - private static createSignatureBase (method: string, url: string, parameters: string): string { - return `${method.toUpperCase()}&${JiraOnPremOAuth.encodeData(JiraOnPremOAuth.normalizeUrl(url))}&${JiraOnPremOAuth.encodeData(parameters)}`; + if (!url.pathname || url.pathname == "") { + url.pathname = "/"; } - public readonly privateKey: KeyObject; - private stateToTokenSecret = new Map(); + return url.protocol + "//" + url.hostname + port + url.pathname; + } - constructor(private readonly config: BridgeConfigJiraOnPremOAuth, private readonly instanceUrl: string) { - // TODO: Make this async. - this.privateKey = createPrivateKey(fs.readFileSync(config.privateKey)); - } + private static createSignatureBase( + method: string, + url: string, + parameters: string, + ): string { + return `${method.toUpperCase()}&${JiraOnPremOAuth.encodeData(JiraOnPremOAuth.normalizeUrl(url))}&${JiraOnPremOAuth.encodeData(parameters)}`; + } + public readonly privateKey: KeyObject; + private stateToTokenSecret = new Map(); - public async exchangeRequestForToken(codeOrToken: string, verifier: string): Promise { - if (!verifier) { - throw Error('Missing verifier'); - } + constructor( + private readonly config: BridgeConfigJiraOnPremOAuth, + private readonly instanceUrl: string, + ) { + // TODO: Make this async. + this.privateKey = createPrivateKey(fs.readFileSync(config.privateKey)); + } - const result = await this.secureRequest<{oauth_token: string, oauth_token_secret: string}>(codeOrToken, "POST", `${this.instanceUrl}/plugins/servlet/oauth/access-token`, { - oauth_verifier: verifier - }); - return { - access_token: encodeJiraToken(result.oauth_token, result.oauth_token_secret), - scope: "" - } + public async exchangeRequestForToken( + codeOrToken: string, + verifier: string, + ): Promise { + if (!verifier) { + throw Error("Missing verifier"); } - public async getAuthUrl(state: string) { - // Need to fetch a token first. - const details = await this.getOAuthRequestToken(state); + const result = await this.secureRequest<{ + oauth_token: string; + oauth_token_secret: string; + }>( + codeOrToken, + "POST", + `${this.instanceUrl}/plugins/servlet/oauth/access-token`, + { + oauth_verifier: verifier, + }, + ); + return { + access_token: encodeJiraToken( + result.oauth_token, + result.oauth_token_secret, + ), + scope: "", + }; + } - if (!details.oauth_token || !details.oauth_token_secret) { - log.info(`Unexpected response from JIRA:`, JSON.stringify(details)); - throw Error('Unexpected OAuth response from server'); - } - this.stateToTokenSecret.set(state, details.oauth_token_secret); - return `${this.instanceUrl}/plugins/servlet/oauth/authorize?oauth_token=${details.oauth_token}`; - } + public async getAuthUrl(state: string) { + // Need to fetch a token first. + const details = await this.getOAuthRequestToken(state); - private async getOAuthRequestToken(state: string) { - const callbackUrl = new URL(this.config.redirect_uri); - callbackUrl.searchParams.set('state', state); - const results = this.secureRequest<{oauth_token: string, oauth_token_secret: string}>(null, "POST", `${this.instanceUrl}/plugins/servlet/oauth/request-token`, { - oauth_callback: callbackUrl.toString(), - }); - return results; + if (!details.oauth_token || !details.oauth_token_secret) { + log.info(`Unexpected response from JIRA:`, JSON.stringify(details)); + throw Error("Unexpected OAuth response from server"); } + this.stateToTokenSecret.set(state, details.oauth_token_secret); + return `${this.instanceUrl}/plugins/servlet/oauth/authorize?oauth_token=${details.oauth_token}`; + } - private async secureRequest( - oauthToken: string|null, - method: Method, - urlStr: string, - extraParams: Record = {}, - body: unknown = null, - contentType = "application/x-www-form-urlencoded" - ): Promise { - const orderedParameters = this.prepareParameters(oauthToken, method, urlStr, extraParams); - const url = new URL(urlStr); - - const headers: Record = {}; - headers["Authorization"]= JiraOnPremOAuth.buildAuthorizationHeaders(orderedParameters); - headers["Host"] = url.host; - - // Filter out any passed extra_params that are really to do with OAuth - for(const key in extraParams) { - if( JiraOnPremOAuth.isParameterNameAnOAuthParameter( key ) ) { - delete extraParams[key]; - } - } - log.info(`Requesting ${url}`, orderedParameters); - const req = await Axios.request({ - method, - headers: { - Authorization: JiraOnPremOAuth.buildAuthorizationHeaders(orderedParameters), - Host: url.host, - 'Content-Type': contentType, - }, - data: body || qs.stringify(extraParams), - url: url.toString(), - }); - return qs.parse(req.data) as unknown as T; - } + private async getOAuthRequestToken(state: string) { + const callbackUrl = new URL(this.config.redirect_uri); + callbackUrl.searchParams.set("state", state); + const results = this.secureRequest<{ + oauth_token: string; + oauth_token_secret: string; + }>( + null, + "POST", + `${this.instanceUrl}/plugins/servlet/oauth/request-token`, + { + oauth_callback: callbackUrl.toString(), + }, + ); + return results; + } - private prepareParameters(oauthToken: string|null, method: Method, urlStr: string, extraParams: Record = {}) { - const oauthParameters: Record = { - oauth_timestamp: Math.floor( Date.now() / 1000 ).toString(), - oauth_nonce: JiraOnPremOAuth.nonce(), - oauth_version: "1.0", - oauth_signature_method: "RSA-SHA1", - oauth_consumer_key: this.config.consumerKey, - ...(oauthToken && {oauth_token: oauthToken}), - ...extraParams, - }; - - const url = new URL(urlStr); - - for (const [key, value] of url.searchParams.entries() ) { - oauthParameters[key]= value; - } - - const sig = this.getSignatue(method, urlStr, JiraOnPremOAuth.normaliseRequestParams(oauthParameters)); - const orderedParameters = JiraOnPremOAuth.sortRequestParams( JiraOnPremOAuth.makeArrayOfArgumentsHash(oauthParameters) ); - orderedParameters[orderedParameters.length] = ["oauth_signature", sig]; - return orderedParameters; + private async secureRequest( + oauthToken: string | null, + method: Method, + urlStr: string, + extraParams: Record = {}, + body: unknown = null, + contentType = "application/x-www-form-urlencoded", + ): Promise { + const orderedParameters = this.prepareParameters( + oauthToken, + method, + urlStr, + extraParams, + ); + const url = new URL(urlStr); + + const headers: Record = {}; + headers["Authorization"] = + JiraOnPremOAuth.buildAuthorizationHeaders(orderedParameters); + headers["Host"] = url.host; + + // Filter out any passed extra_params that are really to do with OAuth + for (const key in extraParams) { + if (JiraOnPremOAuth.isParameterNameAnOAuthParameter(key)) { + delete extraParams[key]; + } } + log.info(`Requesting ${url}`, orderedParameters); + const req = await Axios.request({ + method, + headers: { + Authorization: + JiraOnPremOAuth.buildAuthorizationHeaders(orderedParameters), + Host: url.host, + "Content-Type": contentType, + }, + data: body || qs.stringify(extraParams), + url: url.toString(), + }); + return qs.parse(req.data) as unknown as T; + } + private prepareParameters( + oauthToken: string | null, + method: Method, + urlStr: string, + extraParams: Record = {}, + ) { + const oauthParameters: Record = { + oauth_timestamp: Math.floor(Date.now() / 1000).toString(), + oauth_nonce: JiraOnPremOAuth.nonce(), + oauth_version: "1.0", + oauth_signature_method: "RSA-SHA1", + oauth_consumer_key: this.config.consumerKey, + ...(oauthToken && { oauth_token: oauthToken }), + ...extraParams, + }; - private createSignature (signatureBase: string) { - if (!this.privateKey) { - throw Error('Cannot sign request, privateKey not ready'); - } - return createSign("RSA-SHA1").update(signatureBase).sign(this.privateKey, 'base64'); + const url = new URL(urlStr); + + for (const [key, value] of url.searchParams.entries()) { + oauthParameters[key] = value; } + const sig = this.getSignatue( + method, + urlStr, + JiraOnPremOAuth.normaliseRequestParams(oauthParameters), + ); + const orderedParameters = JiraOnPremOAuth.sortRequestParams( + JiraOnPremOAuth.makeArrayOfArgumentsHash(oauthParameters), + ); + orderedParameters[orderedParameters.length] = ["oauth_signature", sig]; + return orderedParameters; + } - private getSignatue(method: Method, url: string, parameters: string) { - return this.createSignature(JiraOnPremOAuth.createSignatureBase(method, url, parameters)); + private createSignature(signatureBase: string) { + if (!this.privateKey) { + throw Error("Cannot sign request, privateKey not ready"); } + return createSign("RSA-SHA1") + .update(signatureBase) + .sign(this.privateKey, "base64"); + } + private getSignatue(method: Method, url: string, parameters: string) { + return this.createSignature( + JiraOnPremOAuth.createSignatureBase(method, url, parameters), + ); + } - private static makeArrayOfArgumentsHash (argumentsHash: Record): [string, string][] { - const argumentPairs: [string, string][] = []; - for(const key in argumentsHash ) { - const value = argumentsHash[key]; - if (Array.isArray(value) ) { - for (let i = 0; i < value.length; i++) { - argumentPairs[argumentPairs.length] = [key, value[i]]; - } - } - else { - argumentPairs[argumentPairs.length] = [key, value]; - } + private static makeArrayOfArgumentsHash( + argumentsHash: Record, + ): [string, string][] { + const argumentPairs: [string, string][] = []; + for (const key in argumentsHash) { + const value = argumentsHash[key]; + if (Array.isArray(value)) { + for (let i = 0; i < value.length; i++) { + argumentPairs[argumentPairs.length] = [key, value[i]]; } - return argumentPairs; + } else { + argumentPairs[argumentPairs.length] = [key, value]; + } } + return argumentPairs; + } + // Sorts the encoded key value pairs by encoded name, then encoded value + private static sortRequestParams = function ( + pairs: [string, string][], + ): [string, string][] { + // Sort by name, then value. + return pairs.sort(function (a, b) { + if (a[0] == b[0]) { + return a[1] < b[1] ? -1 : 1; + } else return a[0] < b[0] ? -1 : 1; + }); + }; - - // Sorts the encoded key value pairs by encoded name, then encoded value - private static sortRequestParams= function(pairs: [string, string][]): [string, string][] { - // Sort by name, then value. - return pairs.sort(function(a,b) { - if ( a[0]== b[0] ) { - return a[1] < b[1] ? -1 : 1; - } - else return a[0] < b[0] ? -1 : 1; - }); + private static normaliseRequestParams(args: Record) { + let pairs = JiraOnPremOAuth.makeArrayOfArgumentsHash(args); + // First encode them #3.4.1.3.2 .1 + for (let i = 0; i < pairs.length; i++) { + pairs[i][0] = JiraOnPremOAuth.encodeData(pairs[i][0]); + pairs[i][1] = JiraOnPremOAuth.encodeData(pairs[i][1]); } - private static normaliseRequestParams(args: Record) { - let pairs = JiraOnPremOAuth.makeArrayOfArgumentsHash(args); - // First encode them #3.4.1.3.2 .1 - for(let i=0;i { - constructor(private readonly as: Appservice, private readonly tokenStore: UserTokenStore) { - super(as.botIntent, "openproject") - } + constructor( + private readonly as: Appservice, + private readonly tokenStore: UserTokenStore, + ) { + super(as.botIntent, "openproject"); + } - protected async checkFallback(roomId: string, connectionId: OpenProjectGrantConnectionId, sender?: string) { - if (!sender) { - // Cannot validate without a sender. - return false; - } - if (this.as.isNamespacedUser(sender)) { - // Bridge is always valid. - return true; - } - try { - await JiraProjectConnection.assertUserHasAccessToProject(this.tokenStore, sender, connectionId.url); - return true; - } catch { - return false; - } + protected async checkFallback( + roomId: string, + connectionId: OpenProjectGrantConnectionId, + sender?: string, + ) { + if (!sender) { + // Cannot validate without a sender. + return false; + } + if (this.as.isNamespacedUser(sender)) { + // Bridge is always valid. + return true; } -} \ No newline at end of file + try { + await JiraProjectConnection.assertUserHasAccessToProject( + this.tokenStore, + sender, + connectionId.url, + ); + return true; + } catch { + return false; + } + } +} diff --git a/src/openproject/Router.ts b/src/openproject/Router.ts index 729878597..6680519ec 100644 --- a/src/openproject/Router.ts +++ b/src/openproject/Router.ts @@ -9,88 +9,102 @@ import { OAuthRequest, OAuthRequestResult } from "../tokens/oauth"; const log = new Logger("OpenProjectWebhooksRouter"); export class OpenProjectWebhooksRouter { - - public static IsRequest(req: Request): boolean { - if (req.headers['x-atlassian-webhook-identifier']) { - return true; // Cloud - } else if (req.headers['user-agent']?.match(/JIRA/)) { - return true; // JIRA On-prem - } - return false; + public static IsRequest(req: Request): boolean { + if (req.headers["x-atlassian-webhook-identifier"]) { + return true; // Cloud + } else if (req.headers["user-agent"]?.match(/JIRA/)) { + return true; // JIRA On-prem } + return false; + } - constructor(private readonly config: BridgeOpenProjectConfig, private readonly queue: MessageQueue) { + constructor( + private readonly config: BridgeOpenProjectConfig, + private readonly queue: MessageQueue, + ) {} + /** + * Verifies a webhook request for a valid signature. + * @throws If the request is invalid + * @param req The express request. + */ + public verifyWebhookRequest(req: Request, _res: never, buffer: Buffer): void { + const signature = req.headers["x-op-signature"]?.slice("sha1=".length); + if (!signature) { + throw new ApiError("No signature provided on request", ErrCode.BadToken); } - /** - * Verifies a webhook request for a valid signature. - * @throws If the request is invalid - * @param req The express request. - */ - public verifyWebhookRequest(req: Request, _res: never, buffer: Buffer): void { - const signature = req.headers['x-op-signature']?.slice('sha1='.length); - if (!signature) { - throw new ApiError("No signature provided on request", ErrCode.BadToken); - } - - const calculatedSecret = createHmac('sha1', this.config.webhook.secret).update(buffer).digest('hex'); - if (signature !== calculatedSecret) { - throw new ApiError("Signature did not match", ErrCode.BadToken); - } - return; + const calculatedSecret = createHmac("sha1", this.config.webhook.secret) + .update(buffer) + .digest("hex"); + if (signature !== calculatedSecret) { + throw new ApiError("Signature did not match", ErrCode.BadToken); } + return; + } + private onWebhook( + req: Request, + res: Response, + ) { + const payload = req.body; + res.status(200).send("OK"); + this.queue.push({ + eventName: `openproject.${payload.action}`, + data: payload, + sender: "GithubWebhooks", + }); + } - private onWebhook(req: Request, res: Response) { - const payload = req.body; - res.status(200).send('OK'); - this.queue.push({ - eventName: `openproject.${payload.action}`, - data: payload, - sender: 'GithubWebhooks', - }) + private async onOAuth( + req: Request, + res: Response, + ) { + let result: OAuthRequestResult; + if (typeof req.query.state !== "string") { + throw new ApiError("Missing 'state' parameter", ErrCode.BadValue); } - - private async onOAuth(req: Request, res: Response) { - let result: OAuthRequestResult; - if (typeof req.query.state !== "string") { - throw new ApiError("Missing 'state' parameter", ErrCode.BadValue); - } - if (typeof req.query.code !== "string") { - throw new ApiError("Missing 'code' parameter", ErrCode.BadValue); - } - const { state, code } = req.query; - log.info(`Got new OpenProject oauth request (${state.substring(0, 8)})`); - try { - result = await this.queue.pushWait({ - eventName: "openproject.oauth.response", - sender: "GithubWebhooks", - data: { - state, - code, - }, - }); - } catch (ex) { - log.error("Failed to handle oauth request:", ex); - throw new ApiError("Encountered an error handing oauth request", ErrCode.Unknown); - } - - switch (result) { - case OAuthRequestResult.Success: - return res.send(`

Your account has been bridged

`); - case OAuthRequestResult.UserNotFound: - return res.status(404).send(`

Could not find user which authorised this request. Has it timed out?

`); - default: - return res.status(404).send(`

Unknown failure

`); - } + if (typeof req.query.code !== "string") { + throw new ApiError("Missing 'code' parameter", ErrCode.BadValue); + } + const { state, code } = req.query; + log.info(`Got new OpenProject oauth request (${state.substring(0, 8)})`); + try { + result = await this.queue.pushWait({ + eventName: "openproject.oauth.response", + sender: "GithubWebhooks", + data: { + state, + code, + }, + }); + } catch (ex) { + log.error("Failed to handle oauth request:", ex); + throw new ApiError( + "Encountered an error handing oauth request", + ErrCode.Unknown, + ); } - public getRouter() { - const router = Router(); - router.use(json({ verify: this.verifyWebhookRequest.bind(this)})); - router.post("/webhook", this.onWebhook.bind(this)); - router.get("/oauth", this.onOAuth.bind(this)); - return router; + switch (result) { + case OAuthRequestResult.Success: + return res.send(`

Your account has been bridged

`); + case OAuthRequestResult.UserNotFound: + return res + .status(404) + .send( + `

Could not find user which authorised this request. Has it timed out?

`, + ); + default: + return res.status(404).send(`

Unknown failure

`); } -} \ No newline at end of file + } + + public getRouter() { + const router = Router(); + router.use(json({ verify: this.verifyWebhookRequest.bind(this) })); + router.post("/webhook", this.onWebhook.bind(this)); + router.get("/oauth", this.onOAuth.bind(this)); + return router; + } +} diff --git a/src/openproject/adminCommands.ts b/src/openproject/adminCommands.ts index 463e9c8c5..3f64fed21 100644 --- a/src/openproject/adminCommands.ts +++ b/src/openproject/adminCommands.ts @@ -3,25 +3,33 @@ import { botCommand } from "../BotCommands"; import { Logger } from "matrix-appservice-bridge"; import { BridgePermissionLevel } from "../config/Config"; -const log = new Logger('OpenProjectBotCommands'); +const log = new Logger("OpenProjectBotCommands"); export class OpenProjectBotCommands extends AdminRoomCommandHandler { - @botCommand("openproject login", {help: "Log in to OpenProject", category: Category.OpenProject, permissionLevel: BridgePermissionLevel.login}) - public async loginCommand() { - if (!this.tokenStore.openProjectOAuth) { - this.sendNotice(`Bot is not configured with JIRA OAuth support.`); - return; - } - const state = this.tokenStore.createStateForOAuth(this.userId); - const url = await this.tokenStore.openProjectOAuth.getAuthUrl(state); - await this.sendNotice(`Open ${url} to link your account to the bridge.`); + @botCommand("openproject login", { + help: "Log in to OpenProject", + category: Category.OpenProject, + permissionLevel: BridgePermissionLevel.login, + }) + public async loginCommand() { + if (!this.tokenStore.openProjectOAuth) { + this.sendNotice(`Bot is not configured with JIRA OAuth support.`); + return; } + const state = this.tokenStore.createStateForOAuth(this.userId); + const url = await this.tokenStore.openProjectOAuth.getAuthUrl(state); + await this.sendNotice(`Open ${url} to link your account to the bridge.`); + } - @botCommand("openproject logout", {help: "Log out of OpenProject", category: Category.OpenProject, permissionLevel: BridgePermissionLevel.login}) - public async logoutCommand() { - if (await this.tokenStore.clearUserToken('openproject', this.userId)) { - return this.sendNotice(`You have been logged out of OpenProject.`); - } - return this.sendNotice(`You are not logged into OpenProject.`); + @botCommand("openproject logout", { + help: "Log out of OpenProject", + category: Category.OpenProject, + permissionLevel: BridgePermissionLevel.login, + }) + public async logoutCommand() { + if (await this.tokenStore.clearUserToken("openproject", this.userId)) { + return this.sendNotice(`You have been logged out of OpenProject.`); } + return this.sendNotice(`You are not logged into OpenProject.`); + } } diff --git a/src/openproject/client.ts b/src/openproject/client.ts index 778ab326d..86979bff8 100644 --- a/src/openproject/client.ts +++ b/src/openproject/client.ts @@ -1,75 +1,104 @@ import axios, { Method } from "axios"; -import { OpenProjectProject, OpenProjectStoredToken, OpenProjectUser } from "./types"; +import { + OpenProjectProject, + OpenProjectStoredToken, + OpenProjectUser, +} from "./types"; import { Logger } from "matrix-appservice-bridge"; import { OpenProjectOAuth } from "./oauth"; +const log = new Logger("OpenProjectAPIClient"); -const log = new Logger('OpenProjectAPIClient'); - - -type OpenProjectProjectWithUrl = OpenProjectProject&{project_url: string}; +type OpenProjectProjectWithUrl = OpenProjectProject & { project_url: string }; export class OpenProjectAPIClient { - private storedToken: OpenProjectStoredToken; - constructor(private readonly baseUrl: URL, tokenInfo: string, private readonly oauth: OpenProjectOAuth, private readonly onTokenRefreshed: (token: OpenProjectStoredToken) => void) { - this.storedToken = JSON.parse(tokenInfo); - } + private storedToken: OpenProjectStoredToken; + constructor( + private readonly baseUrl: URL, + tokenInfo: string, + private readonly oauth: OpenProjectOAuth, + private readonly onTokenRefreshed: (token: OpenProjectStoredToken) => void, + ) { + this.storedToken = JSON.parse(tokenInfo); + } + private async apiRequest( + path: string, + method: Method = "GET", + data?: R, + ): Promise { + await this.checkTokenAge(); + const url = `${this.baseUrl.origin}/${this.baseUrl.pathname}${path}`; + const res = await axios.request({ + url, + method: method, + data, + headers: { + Authorization: `Bearer ${this.storedToken.access_token}`, + }, + responseType: "json", + }); + return res.data; + } - private async apiRequest(path: string, method: Method = 'GET', data?: R): Promise { - await this.checkTokenAge(); - const url = `${this.baseUrl.origin}/${this.baseUrl.pathname}${path}`; - const res = await axios.request({ url, - method: method, - data, - headers: { - Authorization: `Bearer ${this.storedToken.access_token}` - }, - responseType: 'json', - }); - return res.data; + private async checkTokenAge() { + if (!this.storedToken.refresh_token || !this.storedToken.expires_in) { + throw Error("Cannot refresh token, token does not support it"); } - - private async checkTokenAge() { - if (!this.storedToken.refresh_token || !this.storedToken.expires_in) { - throw Error('Cannot refresh token, token does not support it'); - } - if (this.storedToken.expires_in + 60000 > Date.now()) { - return; - } - log.info(`Refreshing oauth token`); - const data = await this.oauth.exchangeRefreshToken(this.storedToken.refresh_token); - this.storedToken = { - expires_in: data.expires_in, - refresh_token: data.refresh_token, - access_token: data.access_token, - }; - this.onTokenRefreshed(this.storedToken); + if (this.storedToken.expires_in + 60000 > Date.now()) { + return; } + log.info(`Refreshing oauth token`); + const data = await this.oauth.exchangeRefreshToken( + this.storedToken.refresh_token, + ); + this.storedToken = { + expires_in: data.expires_in, + refresh_token: data.refresh_token, + access_token: data.access_token, + }; + this.onTokenRefreshed(this.storedToken); + } - async getIdentity(userId = 'me'): Promise { - return this.apiRequest(`/api/v3/users/${encodeURIComponent(userId)}`); - } + async getIdentity(userId = "me"): Promise { + return this.apiRequest( + `/api/v3/users/${encodeURIComponent(userId)}`, + ); + } - async searchProjects(nameAndIdentifier?: string): Promise { - // See https://www.openproject.org/docs/api/endpoints/projects/ - let projects: OpenProjectProject[]; - if (nameAndIdentifier) { - const query = [ - { "name_and_identifier": { "operator": "~", "values": [nameAndIdentifier] } } - ] - projects = (await this.apiRequest<{_embedded: { elements: OpenProjectProject[]}}>( - `/api/v3/projects?filters=${encodeURIComponent(JSON.stringify(query))}` - ))._embedded.elements; - } else { - projects = (await this.apiRequest<{_embedded: { elements: OpenProjectProject[]}}>(`/api/v3/projects`))._embedded.elements; - } - // Note: We take the first page of results here for now. - return projects.map((p) => ({...p, project_url: `${this.baseUrl}projects/${p.id}`})) + async searchProjects( + nameAndIdentifier?: string, + ): Promise { + // See https://www.openproject.org/docs/api/endpoints/projects/ + let projects: OpenProjectProject[]; + if (nameAndIdentifier) { + const query = [ + { name_and_identifier: { operator: "~", values: [nameAndIdentifier] } }, + ]; + projects = ( + await this.apiRequest<{ + _embedded: { elements: OpenProjectProject[] }; + }>( + `/api/v3/projects?filters=${encodeURIComponent(JSON.stringify(query))}`, + ) + )._embedded.elements; + } else { + projects = ( + await this.apiRequest<{ + _embedded: { elements: OpenProjectProject[] }; + }>(`/api/v3/projects`) + )._embedded.elements; } - + // Note: We take the first page of results here for now. + return projects.map((p) => ({ + ...p, + project_url: `${this.baseUrl}projects/${p.id}`, + })); + } - async getProject(projectId: number): Promise { - return this.apiRequest(`/api/v3/projects/${encodeURIComponent(projectId)}`); - } + async getProject(projectId: number): Promise { + return this.apiRequest( + `/api/v3/projects/${encodeURIComponent(projectId)}`, + ); + } } diff --git a/src/openproject/format.ts b/src/openproject/format.ts index 97fda6d52..fa0efdd4f 100644 --- a/src/openproject/format.ts +++ b/src/openproject/format.ts @@ -1,114 +1,145 @@ import { OpenProjectEventsNames } from "../Connections/OpenProjectConnection"; -import { OpenProjectWorkPackageCacheState, workPackageToCacheState } from "./state"; +import { + OpenProjectWorkPackageCacheState, + workPackageToCacheState, +} from "./state"; import { OpenProjectWorkPackage } from "./types"; - -export function formatWorkPackageForMatrix(pkg: OpenProjectWorkPackage, baseURL: URL) { - const url = new URL(baseURL.href + `projects/${pkg._embedded.project.identifier}/work_packages/${pkg.id}`, baseURL).toString(); - return { - 'org.matrix.matrix-hookshot.openproject.work_package': { - id: pkg.id, - subject: pkg.subject, - description: { - plain: pkg.description.raw, - html: pkg.description.html, - }, - url, - author: { - name: pkg._embedded.author.name, - url: new URL(baseURL.href + `users/${pkg._embedded.author.id}`, baseURL).toString(), - }, - assignee: pkg._embedded.assignee && { - name: pkg._embedded.assignee?.name, - url: new URL(baseURL.href + `users/${pkg._embedded.assignee?.id}`, baseURL).toString(), - }, - status: { - name: pkg._embedded.status.name, - color: pkg._embedded.status.color, - }, - type: { - name: pkg._embedded.type.name, - color: pkg._embedded.type.color, - } - }, - 'org.matrix.matrix-hookshot.openproject.project': { - id: pkg._embedded.project.id, - name: pkg._embedded.project.name, - url: new URL(baseURL.href + `projects/${pkg._embedded.project.id}`, baseURL).toString(), - }, - external_url: url, - } +export function formatWorkPackageForMatrix( + pkg: OpenProjectWorkPackage, + baseURL: URL, +) { + const url = new URL( + baseURL.href + + `projects/${pkg._embedded.project.identifier}/work_packages/${pkg.id}`, + baseURL, + ).toString(); + return { + "org.matrix.matrix-hookshot.openproject.work_package": { + id: pkg.id, + subject: pkg.subject, + description: { + plain: pkg.description.raw, + html: pkg.description.html, + }, + url, + author: { + name: pkg._embedded.author.name, + url: new URL( + baseURL.href + `users/${pkg._embedded.author.id}`, + baseURL, + ).toString(), + }, + assignee: pkg._embedded.assignee && { + name: pkg._embedded.assignee?.name, + url: new URL( + baseURL.href + `users/${pkg._embedded.assignee?.id}`, + baseURL, + ).toString(), + }, + status: { + name: pkg._embedded.status.name, + color: pkg._embedded.status.color, + }, + type: { + name: pkg._embedded.type.name, + color: pkg._embedded.type.color, + }, + }, + "org.matrix.matrix-hookshot.openproject.project": { + id: pkg._embedded.project.id, + name: pkg._embedded.project.name, + url: new URL( + baseURL.href + `projects/${pkg._embedded.project.id}`, + baseURL, + ).toString(), + }, + external_url: url, + }; } -export function formatWorkPackageDiff(old: OpenProjectWorkPackageCacheState, pkg: OpenProjectWorkPackage): {changes: string[], postfix?: string, eventKind: OpenProjectEventsNames}|null { - const changes: string[] = []; - let postfix: undefined|string; - let eventKind: OpenProjectEventsNames = "work_package:updated"; - const current = workPackageToCacheState(pkg); - // {user} {...changes} {issueUrl} - if (old.assignee !== current.assignee) { - if (current.assignee) { - changes.push(`assigned **${pkg._embedded.assignee?.name}**`); - } else { - changes.push(`removed assignee`); - } - eventKind = "work_package:assignee_changed"; - } - if (old.description.raw !== current.description.raw) { - if (current.description) { - changes.push(`updated the description`); - postfix = current.description.raw; - } else { - changes.push(`removed the description`); - } - eventKind = "work_package:description_changed"; - } - if (old.dueDate !== current.dueDate) { - if (current.dueDate) { - changes.push(`set the due date to \`${current.dueDate}\``); - } else { - changes.push(`removed the due date`); - } - eventKind = "work_package:duedate_changed"; - } - if (old.percentageDone !== current.percentageDone) { - if (current.percentageDone) { - changes.push(`set the work completed percentage to **${current.percentageDone}%**`); - } - // No point sending anything about removal. - eventKind = "work_package:workpercent_changed"; +export function formatWorkPackageDiff( + old: OpenProjectWorkPackageCacheState, + pkg: OpenProjectWorkPackage, +): { + changes: string[]; + postfix?: string; + eventKind: OpenProjectEventsNames; +} | null { + const changes: string[] = []; + let postfix: undefined | string; + let eventKind: OpenProjectEventsNames = "work_package:updated"; + const current = workPackageToCacheState(pkg); + // {user} {...changes} {issueUrl} + if (old.assignee !== current.assignee) { + if (current.assignee) { + changes.push(`assigned **${pkg._embedded.assignee?.name}**`); + } else { + changes.push(`removed assignee`); } - if (old.priority?.id !== current.priority?.id) { - changes.push(`changed the priority from **${old.priority?.name}** to **${current.priority?.name ?? "none"}**`); - eventKind = "work_package:priority_changed"; + eventKind = "work_package:assignee_changed"; + } + if (old.description.raw !== current.description.raw) { + if (current.description) { + changes.push(`updated the description`); + postfix = current.description.raw; + } else { + changes.push(`removed the description`); } - if (old.responsible !== current.responsible) { - if (current.responsible) { - changes.push(`set ${pkg._embedded.responsible?.name} as responsible`); - } else { - changes.push(`removed responsible user`); - } - eventKind = "work_package:responsible_changed"; + eventKind = "work_package:description_changed"; + } + if (old.dueDate !== current.dueDate) { + if (current.dueDate) { + changes.push(`set the due date to \`${current.dueDate}\``); + } else { + changes.push(`removed the due date`); } - if (old.status.id !== current.status.id) { - changes.push(`changed the status from **${old.status?.name}** to **${current.status?.name ?? "none"}**`); + eventKind = "work_package:duedate_changed"; + } + if (old.percentageDone !== current.percentageDone) { + if (current.percentageDone) { + changes.push( + `set the work completed percentage to **${current.percentageDone}%**`, + ); } - if (old.subject !== current.subject) { - // Implictly named - changes.push(`updated the subject`); - eventKind = "work_package:subject_changed"; - } - if (old.type !== current.type) { - changes.push(`changed the type to **${current.type}**`); + // No point sending anything about removal. + eventKind = "work_package:workpercent_changed"; + } + if (old.priority?.id !== current.priority?.id) { + changes.push( + `changed the priority from **${old.priority?.name}** to **${current.priority?.name ?? "none"}**`, + ); + eventKind = "work_package:priority_changed"; + } + if (old.responsible !== current.responsible) { + if (current.responsible) { + changes.push(`set ${pkg._embedded.responsible?.name} as responsible`); + } else { + changes.push(`removed responsible user`); } + eventKind = "work_package:responsible_changed"; + } + if (old.status.id !== current.status.id) { + changes.push( + `changed the status from **${old.status?.name}** to **${current.status?.name ?? "none"}**`, + ); + } + if (old.subject !== current.subject) { + // Implictly named + changes.push(`updated the subject`); + eventKind = "work_package:subject_changed"; + } + if (old.type !== current.type) { + changes.push(`changed the type to **${current.type}**`); + } - if (changes.length === 0) { - // Unknown change - return null; - } - return { - changes, - postfix, - eventKind - } -} \ No newline at end of file + if (changes.length === 0) { + // Unknown change + return null; + } + return { + changes, + postfix, + eventKind, + }; +} diff --git a/src/openproject/oauth.ts b/src/openproject/oauth.ts index 9f51119ae..ddadde69f 100644 --- a/src/openproject/oauth.ts +++ b/src/openproject/oauth.ts @@ -3,85 +3,99 @@ import { UserTokenStore } from "../tokens/UserTokenStore"; import { OAuthRequest, OAuthRequestResult } from "../tokens/oauth"; import { Logger } from "matrix-appservice-bridge"; -const log = new Logger('OpenProjectOAuth'); +const log = new Logger("OpenProjectOAuth"); interface TokenResponse { - access_token: string; - token_type: string; - expires_in: number; - refresh_token: string; - scope: "api_v3"; - created_at: number; + access_token: string; + token_type: string; + expires_in: number; + refresh_token: string; + scope: "api_v3"; + created_at: number; } - export class OpenProjectOAuth { - constructor(private readonly tokenStore: UserTokenStore, private readonly oauthConfig: BridgeOpenProjectOAuthConfig, private readonly baseUrl: URL) { } - public async getAuthUrl(state: string) { - const url = new URL("/oauth/authorize", this.baseUrl); - url.searchParams.set('client_id', this.oauthConfig.clientId); - url.searchParams.set('redirect_uri', this.oauthConfig.redirectUri); - url.searchParams.set('state', state); - url.searchParams.set('response_type', "code"); - url.searchParams.set('scope', "api_v3"); - return url.toString(); - } + constructor( + private readonly tokenStore: UserTokenStore, + private readonly oauthConfig: BridgeOpenProjectOAuthConfig, + private readonly baseUrl: URL, + ) {} + public async getAuthUrl(state: string) { + const url = new URL("/oauth/authorize", this.baseUrl); + url.searchParams.set("client_id", this.oauthConfig.clientId); + url.searchParams.set("redirect_uri", this.oauthConfig.redirectUri); + url.searchParams.set("state", state); + url.searchParams.set("response_type", "code"); + url.searchParams.set("scope", "api_v3"); + return url.toString(); + } - public async exchangeRefreshToken(refreshToken: string): Promise { - const url = new URL("/oauth/token", this.baseUrl); - const params = new URLSearchParams(); - params.set('client_id', this.oauthConfig.clientId); - params.set('client_secret', this.oauthConfig.clientSecret); - params.set('refresh_token', refreshToken); - params.set('grant_type', "refresh_token"); - const res = await fetch(url, { method: 'POST', body: params.toString(), headers: {'Content-Type': 'application/x-www-form-urlencoded'}}); - const data = await res.json() as TokenResponse; - if (res.status !== 200) { - throw Error(`Unexpected status ${res.status}`); - } - return data; + public async exchangeRefreshToken( + refreshToken: string, + ): Promise { + const url = new URL("/oauth/token", this.baseUrl); + const params = new URLSearchParams(); + params.set("client_id", this.oauthConfig.clientId); + params.set("client_secret", this.oauthConfig.clientSecret); + params.set("refresh_token", refreshToken); + params.set("grant_type", "refresh_token"); + const res = await fetch(url, { + method: "POST", + body: params.toString(), + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + }); + const data = (await res.json()) as TokenResponse; + if (res.status !== 200) { + throw Error(`Unexpected status ${res.status}`); } + return data; + } - public async exchangeRequestForToken(code: string): Promise { - const url = new URL("/oauth/token", this.baseUrl); - const params = new URLSearchParams(); - params.set('client_id', this.oauthConfig.clientId); - params.set('client_secret', this.oauthConfig.clientSecret); - params.set('redirect_uri', this.oauthConfig.redirectUri); - params.set('code', code); - params.set('grant_type', "authorization_code"); - const res = await fetch(url, { method: 'POST', body: params.toString(), headers: {'Content-Type': 'application/x-www-form-urlencoded'}}); - const data = await res.json() as TokenResponse; - if (res.status !== 200) { - throw Error(`Unexpected status ${res.status}`); - } - return data; + public async exchangeRequestForToken(code: string): Promise { + const url = new URL("/oauth/token", this.baseUrl); + const params = new URLSearchParams(); + params.set("client_id", this.oauthConfig.clientId); + params.set("client_secret", this.oauthConfig.clientSecret); + params.set("redirect_uri", this.oauthConfig.redirectUri); + params.set("code", code); + params.set("grant_type", "authorization_code"); + const res = await fetch(url, { + method: "POST", + body: params.toString(), + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + }); + const data = (await res.json()) as TokenResponse; + if (res.status !== 200) { + throw Error(`Unexpected status ${res.status}`); } + return data; + } - public async handleOAuth({state, code}: OAuthRequest): Promise { - const userId = this.tokenStore.getUserIdForOAuthState(state); - if (!userId) { - return OAuthRequestResult.UserNotFound; - } - try { - const now = Date.now(); - const tokenInfo = await this.exchangeRequestForToken(code); - if (!tokenInfo.scope.includes("api_v3")) { - // Logout? - return OAuthRequestResult.InvalidScope; - } - await this.tokenStore.storeOpenProjectToken(userId, { - access_token: tokenInfo.access_token, - refresh_token: tokenInfo.refresh_token, - expires_in: now + (tokenInfo.expires_in * 1000), - }); - - return OAuthRequestResult.Success; - } catch (ex) { - log.warn(`Failed to handle JIRA oauth token exchange`, ex); - return OAuthRequestResult.UnknownFailure; - } + public async handleOAuth({ + state, + code, + }: OAuthRequest): Promise { + const userId = this.tokenStore.getUserIdForOAuthState(state); + if (!userId) { + return OAuthRequestResult.UserNotFound; } + try { + const now = Date.now(); + const tokenInfo = await this.exchangeRequestForToken(code); + if (!tokenInfo.scope.includes("api_v3")) { + // Logout? + return OAuthRequestResult.InvalidScope; + } + await this.tokenStore.storeOpenProjectToken(userId, { + access_token: tokenInfo.access_token, + refresh_token: tokenInfo.refresh_token, + expires_in: now + tokenInfo.expires_in * 1000, + }); - + return OAuthRequestResult.Success; + } catch (ex) { + log.warn(`Failed to handle JIRA oauth token exchange`, ex); + return OAuthRequestResult.UnknownFailure; + } + } } diff --git a/src/openproject/state.ts b/src/openproject/state.ts index 075b59c98..ea27a8480 100644 --- a/src/openproject/state.ts +++ b/src/openproject/state.ts @@ -1,29 +1,31 @@ import { OpenProjectWorkPackage } from "./types"; -export function workPackageToCacheState(pkg: OpenProjectWorkPackage): OpenProjectWorkPackageCacheState { - return { - subject: pkg.subject, - description: pkg.description, - status: pkg._embedded.status, - assignee: pkg._embedded.assignee?.id, - responsible: pkg._embedded.responsible?.id, - priority: pkg._embedded.priority, - type: pkg._embedded.type.id, - project: pkg._embedded.project.id, - dueDate: pkg.dueDate, - percentageDone: pkg.percentageDone, - } +export function workPackageToCacheState( + pkg: OpenProjectWorkPackage, +): OpenProjectWorkPackageCacheState { + return { + subject: pkg.subject, + description: pkg.description, + status: pkg._embedded.status, + assignee: pkg._embedded.assignee?.id, + responsible: pkg._embedded.responsible?.id, + priority: pkg._embedded.priority, + type: pkg._embedded.type.id, + project: pkg._embedded.project.id, + dueDate: pkg.dueDate, + percentageDone: pkg.percentageDone, + }; } export interface OpenProjectWorkPackageCacheState { - subject: string, - description: OpenProjectWorkPackage['description'], - status: OpenProjectWorkPackage['_embedded']['status'], - assignee?: number, - responsible?: number, - priority?: OpenProjectWorkPackage['_embedded']['priority'], - type: number, - project: number, - dueDate: string|null, - percentageDone: number|null, -} \ No newline at end of file + subject: string; + description: OpenProjectWorkPackage["description"]; + status: OpenProjectWorkPackage["_embedded"]["status"]; + assignee?: number; + responsible?: number; + priority?: OpenProjectWorkPackage["_embedded"]["priority"]; + type: number; + project: number; + dueDate: string | null; + percentageDone: number | null; +} diff --git a/src/openproject/types.ts b/src/openproject/types.ts index 8788404a8..1e590b50f 100644 --- a/src/openproject/types.ts +++ b/src/openproject/types.ts @@ -1,108 +1,104 @@ - - type StringDate = string; export interface OpenProjectUser { - id: number, - name: string, - login: string, - createdAt: StringDate, - updatedAt: StringDate, - avatar: string, - status: 'active' + id: number; + name: string; + login: string; + createdAt: StringDate; + updatedAt: StringDate; + avatar: string; + status: "active"; } export interface OpenProjectStatus { - id: number, - name: string, - isClosed: boolean, - isDefault: boolean, - isReadonly: boolean, - color: string, + id: number; + name: string; + isClosed: boolean; + isDefault: boolean; + isReadonly: boolean; + color: string; } export interface OpenProjectType { - id: number, - name: string, - isDefault: boolean, - createdAt: StringDate, - updatedAt: StringDate, - color: string, + id: number; + name: string; + isDefault: boolean; + createdAt: StringDate; + updatedAt: StringDate; + color: string; } export interface OpenProjectPriority { - id: number, - name: string, - isDefault: boolean, - isActive: boolean, - createdAt: StringDate, - updatedAt: StringDate, - color: string, + id: number; + name: string; + isDefault: boolean; + isActive: boolean; + createdAt: StringDate; + updatedAt: StringDate; + color: string; } export interface OpenProjectProject { - id: number, - identifier: string, - name: string, - active: boolean, - public: boolean, - createdAt: StringDate, - updatedAt: StringDate, - description: { - format: 'markdown', - raw: string, - html: string, - }, + id: number; + identifier: string; + name: string; + active: boolean; + public: boolean; + createdAt: StringDate; + updatedAt: StringDate; + description: { + format: "markdown"; + raw: string; + html: string; + }; } export interface OpenProjectWorkPackage { - _type: 'WorkPackage', - id: number, - lockVersion: number, - subject: string, - description: { format: 'markdown', raw: '', html: '' }, - scheduleManually: boolean, - startDate: null, - dueDate: string|null, - derivedStartDate: null, - derivedDueDate: null, - estimatedTime: null, - derivedEstimatedTime: null, - derivedRemainingTime: null, - duration: null, - ignoreNonWorkingDays: boolean, - percentageDone: number|null, - derivedPercentageDone: null, - createdAt: StringDate, - updatedAt: StringDate, - _embedded: { - // attachments: [Object], - // relations: [Object], - type: OpenProjectType, - priority: OpenProjectPriority, - project: OpenProjectProject, - status: OpenProjectStatus, - author: OpenProjectUser, - responsible?: OpenProjectUser, - assignee?: OpenProjectUser, - // customActions: [] - }, - _links: { - self: object, - } - + _type: "WorkPackage"; + id: number; + lockVersion: number; + subject: string; + description: { format: "markdown"; raw: ""; html: "" }; + scheduleManually: boolean; + startDate: null; + dueDate: string | null; + derivedStartDate: null; + derivedDueDate: null; + estimatedTime: null; + derivedEstimatedTime: null; + derivedRemainingTime: null; + duration: null; + ignoreNonWorkingDays: boolean; + percentageDone: number | null; + derivedPercentageDone: null; + createdAt: StringDate; + updatedAt: StringDate; + _embedded: { + // attachments: [Object], + // relations: [Object], + type: OpenProjectType; + priority: OpenProjectPriority; + project: OpenProjectProject; + status: OpenProjectStatus; + author: OpenProjectUser; + responsible?: OpenProjectUser; + assignee?: OpenProjectUser; + // customActions: [] + }; + _links: { + self: object; + }; } export interface OpenProjectWebhookPayloadWorkPackage { - action: 'work_package:created'|'work_package:updated', - work_package: OpenProjectWorkPackage + action: "work_package:created" | "work_package:updated"; + work_package: OpenProjectWorkPackage; } export type OpenProjectWebhookPayload = OpenProjectWebhookPayloadWorkPackage; - export interface OpenProjectStoredToken { - expires_in: number; - access_token: string; - refresh_token: string; -} \ No newline at end of file + expires_in: number; + access_token: string; + refresh_token: string; +} diff --git a/src/openproject/widgetApi.ts b/src/openproject/widgetApi.ts index 0e87ebe38..88080201b 100644 --- a/src/openproject/widgetApi.ts +++ b/src/openproject/widgetApi.ts @@ -4,23 +4,29 @@ import { UserTokenStore } from "../tokens/UserTokenStore"; import { Response } from "express"; import { ApiError, ErrCode } from "../api"; - export class OpenProjectWidgetAPI { - public static async getAuth(req: ProvisioningRequest, res: Response, tokenStore: UserTokenStore) { - if (!req.userId) { - throw new ApiError('Missing userId'); - } - const user = await tokenStore.getOpenProjectForUser(req.userId); - if (user) { - const ident = await user.getIdentity(); - res.json({ user: { name: ident.name }, authenticated: true }); - } else { - if (!tokenStore.openProjectOAuth) { - throw new ApiError('OAuth is not supported', ErrCode.UnsupportedOperation); - } - const stateId = tokenStore.createStateForOAuth(req.userId); - const authUrl = await tokenStore.openProjectOAuth.getAuthUrl(stateId); - res.json({authUrl, authenticated: false, stateId}); - } + public static async getAuth( + req: ProvisioningRequest, + res: Response, + tokenStore: UserTokenStore, + ) { + if (!req.userId) { + throw new ApiError("Missing userId"); + } + const user = await tokenStore.getOpenProjectForUser(req.userId); + if (user) { + const ident = await user.getIdentity(); + res.json({ user: { name: ident.name }, authenticated: true }); + } else { + if (!tokenStore.openProjectOAuth) { + throw new ApiError( + "OAuth is not supported", + ErrCode.UnsupportedOperation, + ); + } + const stateId = tokenStore.createStateForOAuth(req.userId); + const authUrl = await tokenStore.openProjectOAuth.getAuthUrl(stateId); + res.json({ authUrl, authenticated: false, stateId }); } -} \ No newline at end of file + } +} diff --git a/src/tokens/UserTokenStore.ts b/src/tokens/UserTokenStore.ts index f35209036..b5a49dd2e 100644 --- a/src/tokens/UserTokenStore.ts +++ b/src/tokens/UserTokenStore.ts @@ -5,8 +5,13 @@ import { promises as fs } from "fs"; import { Logger } from "matrix-appservice-bridge"; import { isJiraCloudInstance, JiraClient } from "../jira/Client"; import { JiraStoredToken } from "../jira/Types"; -import { BridgeConfig, BridgeConfigJira, BridgeConfigJiraOnPremOAuth, BridgePermissionLevel } from "../config/Config"; -import { randomUUID } from 'node:crypto'; +import { + BridgeConfig, + BridgeConfigJira, + BridgeConfigJiraOnPremOAuth, + BridgePermissionLevel, +} from "../config/Config"; +import { randomUUID } from "node:crypto"; import { GitHubOAuthToken } from "../github/Types"; import { ApiError, ErrCode } from "../api"; import { JiraOAuth } from "../jira/OAuth"; @@ -16,331 +21,457 @@ import { JiraOnPremClient } from "../jira/client/OnPremClient"; import { JiraCloudClient } from "../jira/client/CloudClient"; import { TokenError, TokenErrorCode } from "../errors"; import { TypedEmitter } from "tiny-typed-emitter"; -import { hashId, TokenEncryption, stringToAlgo } from "../libRs"; +import { hashId, TokenEncryption, stringToAlgo } from "../libRs"; import { OpenProjectOAuth } from "../openproject/oauth"; import { OpenProjectStoredToken } from "../openproject/types"; import { OpenProjectAPIClient } from "../openproject/client"; const ACCOUNT_DATA_TYPE = "uk.half-shot.matrix-hookshot.github.password-store:"; -const ACCOUNT_DATA_GITLAB_TYPE = "uk.half-shot.matrix-hookshot.gitlab.password-store:"; -const ACCOUNT_DATA_JIRA_TYPE = "uk.half-shot.matrix-hookshot.jira.password-store:"; -const ACCOUNT_DATA_OPENPROJECT_TYPE = "uk.half-shot.matrix-hookshot.jira.token-store:"; +const ACCOUNT_DATA_GITLAB_TYPE = + "uk.half-shot.matrix-hookshot.gitlab.password-store:"; +const ACCOUNT_DATA_JIRA_TYPE = + "uk.half-shot.matrix-hookshot.jira.password-store:"; +const ACCOUNT_DATA_OPENPROJECT_TYPE = + "uk.half-shot.matrix-hookshot.jira.token-store:"; const LEGACY_ACCOUNT_DATA_TYPE = "uk.half-shot.matrix-github.password-store:"; -const LEGACY_ACCOUNT_DATA_GITLAB_TYPE = "uk.half-shot.matrix-github.gitlab.password-store:"; +const LEGACY_ACCOUNT_DATA_GITLAB_TYPE = + "uk.half-shot.matrix-github.gitlab.password-store:"; const log = new Logger("UserTokenStore"); -export type TokenType = "github"|"gitlab"|"jira"|"openproject"|"generic"; -export const AllowedTokenTypes = ["github", "gitlab", "jira", "openproject", "generic"]; +export type TokenType = + | "github" + | "gitlab" + | "jira" + | "openproject" + | "generic"; +export const AllowedTokenTypes = [ + "github", + "gitlab", + "jira", + "openproject", + "generic", +]; interface StoredTokenData { - encrypted: string|string[]; - keyId: string; - algorithm: 'rsa'|'rsa-pkcs1v15'; - instance?: string; + encrypted: string | string[]; + keyId: string; + algorithm: "rsa" | "rsa-pkcs1v15"; + instance?: string; } interface DeletedTokenData { - deleted: true; + deleted: true; } -function tokenKey(type: TokenType, userId: string, legacy = false, instanceUrl?: string) { - if (type === "github") { - return `${legacy ? LEGACY_ACCOUNT_DATA_TYPE : ACCOUNT_DATA_TYPE}${userId}`; - } - if (type === "jira") { - return `${ACCOUNT_DATA_JIRA_TYPE}${userId}`; - } - if (type === "openproject") { - return `${ACCOUNT_DATA_OPENPROJECT_TYPE}${userId}`; - } - if (!instanceUrl) { - throw Error(`Expected instanceUrl for ${type}`); - } - return `${legacy ? LEGACY_ACCOUNT_DATA_GITLAB_TYPE : ACCOUNT_DATA_GITLAB_TYPE}${instanceUrl}${userId}`; +function tokenKey( + type: TokenType, + userId: string, + legacy = false, + instanceUrl?: string, +) { + if (type === "github") { + return `${legacy ? LEGACY_ACCOUNT_DATA_TYPE : ACCOUNT_DATA_TYPE}${userId}`; + } + if (type === "jira") { + return `${ACCOUNT_DATA_JIRA_TYPE}${userId}`; + } + if (type === "openproject") { + return `${ACCOUNT_DATA_OPENPROJECT_TYPE}${userId}`; + } + if (!instanceUrl) { + throw Error(`Expected instanceUrl for ${type}`); + } + return `${legacy ? LEGACY_ACCOUNT_DATA_GITLAB_TYPE : ACCOUNT_DATA_GITLAB_TYPE}${instanceUrl}${userId}`; } const OAUTH_TIMEOUT_MS = 1000 * 60 * 30; interface Emitter { - onNewToken: (type: TokenType, userId: string, token: string, instanceUrl?: string) => void, + onNewToken: ( + type: TokenType, + userId: string, + token: string, + instanceUrl?: string, + ) => void; } export class UserTokenStore extends TypedEmitter { - - public static async fromKeyPath(keyPath: string, intent: Intent, config: BridgeConfig) { - log.info(`Loading token key file ${keyPath}`); - const key = await fs.readFile(keyPath); - return new UserTokenStore(key, intent, config); + public static async fromKeyPath( + keyPath: string, + intent: Intent, + config: BridgeConfig, + ) { + log.info(`Loading token key file ${keyPath}`); + const key = await fs.readFile(keyPath); + return new UserTokenStore(key, intent, config); + } + + private oauthSessionStore: Map< + string, + { userId: string; timeout: NodeJS.Timeout } + > = new Map(); + private userTokens: Map; + public readonly jiraOAuth?: JiraOAuth; + public readonly openProjectOAuth?: OpenProjectOAuth; + private tokenEncryption: TokenEncryption; + private readonly keyId: string; + constructor( + key: Buffer, + private readonly intent: Intent, + private readonly config: BridgeConfig, + ) { + super(); + this.tokenEncryption = new TokenEncryption(key); + this.userTokens = new Map(); + this.keyId = hashId(key.toString("utf-8")); + if (config.jira?.oauth) { + if ("client_id" in config.jira.oauth) { + this.jiraOAuth = new JiraCloudOAuth(config.jira.oauth); + } else if (config.jira.url) { + this.jiraOAuth = new JiraOnPremOAuth( + config.jira.oauth, + config.jira.url, + ); + } else { + throw Error("jira oauth misconfigured"); + } } - - private oauthSessionStore: Map = new Map(); - private userTokens: Map; - public readonly jiraOAuth?: JiraOAuth; - public readonly openProjectOAuth?: OpenProjectOAuth; - private tokenEncryption: TokenEncryption; - private readonly keyId: string; - constructor(key: Buffer, private readonly intent: Intent, private readonly config: BridgeConfig) { - super(); - this.tokenEncryption = new TokenEncryption(key); - this.userTokens = new Map(); - this.keyId = hashId(key.toString('utf-8')); - if (config.jira?.oauth) { - if ("client_id" in config.jira.oauth) { - this.jiraOAuth = new JiraCloudOAuth(config.jira.oauth); - } else if (config.jira.url) { - this.jiraOAuth = new JiraOnPremOAuth(config.jira.oauth, config.jira.url); - } else { - throw Error('jira oauth misconfigured'); - } - } - if (config.openProject?.oauth) { - this.openProjectOAuth = new OpenProjectOAuth(this, config.openProject.oauth, config.openProject.baseURL); - } + if (config.openProject?.oauth) { + this.openProjectOAuth = new OpenProjectOAuth( + this, + config.openProject.oauth, + config.openProject.baseURL, + ); } + } - public stop() { - for (const session of this.oauthSessionStore.values()) { - clearTimeout(session.timeout); - } + public stop() { + for (const session of this.oauthSessionStore.values()) { + clearTimeout(session.timeout); } - - public async storeUserToken(type: TokenType, userId: string, token: string, instanceUrl?: string): Promise { - if (!this.config.checkPermission(userId, type, BridgePermissionLevel.login)) { - throw new ApiError('User does not have permission to log in to service', ErrCode.ForbiddenUser); - } - const key = tokenKey(type, userId, false, instanceUrl); - const tokenParts: string[] = this.tokenEncryption.encrypt(token); - const data: StoredTokenData = { - encrypted: tokenParts, - keyId: this.keyId, - algorithm: "rsa-pkcs1v15", - instance: instanceUrl, - }; - await this.intent.underlyingClient.setAccountData(key, data); - this.userTokens.set(key, token); - log.info(`Stored new ${type} token for ${userId}`); - this.emit("onNewToken", type, userId, token, instanceUrl); + } + + public async storeUserToken( + type: TokenType, + userId: string, + token: string, + instanceUrl?: string, + ): Promise { + if ( + !this.config.checkPermission(userId, type, BridgePermissionLevel.login) + ) { + throw new ApiError( + "User does not have permission to log in to service", + ErrCode.ForbiddenUser, + ); } - - public async clearUserToken(type: TokenType, userId: string, instanceUrl?: string): Promise { - if (!AllowedTokenTypes.includes(type)) { - throw Error('Unknown token type'); - } - const key = tokenKey(type, userId, false, instanceUrl); - const obj = await this.intent.underlyingClient.getSafeAccountData(key); - if (!obj || "deleted" in obj) { - // Token not stored - return false; - } - await this.intent.underlyingClient.setAccountData(key, {deleted: true}); - this.userTokens.delete(key); - return true; + const key = tokenKey(type, userId, false, instanceUrl); + const tokenParts: string[] = this.tokenEncryption.encrypt(token); + const data: StoredTokenData = { + encrypted: tokenParts, + keyId: this.keyId, + algorithm: "rsa-pkcs1v15", + instance: instanceUrl, + }; + await this.intent.underlyingClient.setAccountData(key, data); + this.userTokens.set(key, token); + log.info(`Stored new ${type} token for ${userId}`); + this.emit("onNewToken", type, userId, token, instanceUrl); + } + + public async clearUserToken( + type: TokenType, + userId: string, + instanceUrl?: string, + ): Promise { + if (!AllowedTokenTypes.includes(type)) { + throw Error("Unknown token type"); } - - public async storeJiraToken(userId: string, token: JiraStoredToken) { - return this.storeUserToken("jira", userId, JSON.stringify(token)); + const key = tokenKey(type, userId, false, instanceUrl); + const obj = await this.intent.underlyingClient.getSafeAccountData< + StoredTokenData | DeletedTokenData + >(key); + if (!obj || "deleted" in obj) { + // Token not stored + return false; } - - public async storeOpenProjectToken(userId: string, token: OpenProjectStoredToken) { - return this.storeUserToken("openproject", userId, JSON.stringify(token)); + await this.intent.underlyingClient.setAccountData(key, { deleted: true }); + this.userTokens.delete(key); + return true; + } + + public async storeJiraToken(userId: string, token: JiraStoredToken) { + return this.storeUserToken("jira", userId, JSON.stringify(token)); + } + + public async storeOpenProjectToken( + userId: string, + token: OpenProjectStoredToken, + ) { + return this.storeUserToken("openproject", userId, JSON.stringify(token)); + } + + public async getUserToken( + type: TokenType, + userId: string, + instanceUrl?: string, + ): Promise { + if (!AllowedTokenTypes.includes(type)) { + throw Error("Unknown token type"); } - - public async getUserToken(type: TokenType, userId: string, instanceUrl?: string): Promise { - if (!AllowedTokenTypes.includes(type)) { - throw Error('Unknown token type'); - } - const key = tokenKey(type, userId, false, instanceUrl); - const existingToken = this.userTokens.get(key); - if (existingToken) { - return existingToken; - } - try { - let obj = await this.intent.underlyingClient.getSafeAccountData(key); - if (!obj) { - obj = await this.intent.underlyingClient.getSafeAccountData(tokenKey(type, userId, true, instanceUrl)); - } - if (!obj || "deleted" in obj) { - return null; - } - // For legacy we just assume it's the current configured key. - const algorithm = stringToAlgo(obj.algorithm ?? "rsa"); - const keyId = obj.keyId ?? this.keyId; - - if (keyId !== this.keyId) { - throw new Error(`Stored data was encrypted with a different key to the one currently configured`); - } - - const encryptedParts = typeof obj.encrypted === "string" ? [obj.encrypted] : obj.encrypted; - const token = this.tokenEncryption.decrypt(encryptedParts, algorithm); - this.userTokens.set(key, token); - return token; - } catch (ex) { - log.error(`Failed to get ${type} token for user ${userId}`, ex); - } + const key = tokenKey(type, userId, false, instanceUrl); + const existingToken = this.userTokens.get(key); + if (existingToken) { + return existingToken; + } + try { + let obj = await this.intent.underlyingClient.getSafeAccountData< + StoredTokenData | DeletedTokenData + >(key); + if (!obj) { + obj = await this.intent.underlyingClient.getSafeAccountData< + StoredTokenData | DeletedTokenData + >(tokenKey(type, userId, true, instanceUrl)); + } + if (!obj || "deleted" in obj) { return null; + } + // For legacy we just assume it's the current configured key. + const algorithm = stringToAlgo(obj.algorithm ?? "rsa"); + const keyId = obj.keyId ?? this.keyId; + + if (keyId !== this.keyId) { + throw new Error( + `Stored data was encrypted with a different key to the one currently configured`, + ); + } + + const encryptedParts = + typeof obj.encrypted === "string" ? [obj.encrypted] : obj.encrypted; + const token = this.tokenEncryption.decrypt(encryptedParts, algorithm); + this.userTokens.set(key, token); + return token; + } catch (ex) { + log.error(`Failed to get ${type} token for user ${userId}`, ex); } - - public async storeGenericToken(namespace: string, key: string, token: string) { - const finalTokenKey = `generic:${namespace}:${key}` - const tokenParts: string[] = this.tokenEncryption.encrypt(token); - const data: StoredTokenData = { - encrypted: tokenParts, - keyId: this.keyId, - algorithm: "rsa-pkcs1v15", - }; - await this.intent.underlyingClient.setAccountData(finalTokenKey, data); - log.debug(`Stored token ${namespace}`); + return null; + } + + public async storeGenericToken( + namespace: string, + key: string, + token: string, + ) { + const finalTokenKey = `generic:${namespace}:${key}`; + const tokenParts: string[] = this.tokenEncryption.encrypt(token); + const data: StoredTokenData = { + encrypted: tokenParts, + keyId: this.keyId, + algorithm: "rsa-pkcs1v15", + }; + await this.intent.underlyingClient.setAccountData(finalTokenKey, data); + log.debug(`Stored token ${namespace}`); + } + + public async getGenericToken( + namespace: string, + key: string, + ): Promise { + const finalTokenKey = `generic:${namespace}:${key}`; + const obj = await this.intent.underlyingClient.getSafeAccountData< + StoredTokenData | DeletedTokenData + >(finalTokenKey); + if (!obj || "deleted" in obj) { + return null; } - - public async getGenericToken(namespace: string, key: string): Promise { - const finalTokenKey = `generic:${namespace}:${key}` - const obj = await this.intent.underlyingClient.getSafeAccountData(finalTokenKey); - if (!obj || "deleted" in obj) { - return null; - } - // For legacy we just assume it's the current configured key. - const algorithm = stringToAlgo(obj.algorithm ?? "rsa"); - const keyId = obj.keyId ?? this.keyId; - - if (keyId !== this.keyId) { - throw new Error(`Stored data was encrypted with a different key to the one currently configured`); - } - - const encryptedParts = typeof obj.encrypted === "string" ? [obj.encrypted] : obj.encrypted; - const token = this.tokenEncryption.decrypt(encryptedParts, algorithm); - return token; + // For legacy we just assume it's the current configured key. + const algorithm = stringToAlgo(obj.algorithm ?? "rsa"); + const keyId = obj.keyId ?? this.keyId; + + if (keyId !== this.keyId) { + throw new Error( + `Stored data was encrypted with a different key to the one currently configured`, + ); } - public static parseGitHubToken(token: string): GitHubOAuthToken { - if (!token.startsWith('{')) { - // Old style token - return { access_token: token, token_type: 'pat' }; - } else { - return JSON.parse(token); - } + const encryptedParts = + typeof obj.encrypted === "string" ? [obj.encrypted] : obj.encrypted; + const token = this.tokenEncryption.decrypt(encryptedParts, algorithm); + return token; + } + + public static parseGitHubToken(token: string): GitHubOAuthToken { + if (!token.startsWith("{")) { + // Old style token + return { access_token: token, token_type: "pat" }; + } else { + return JSON.parse(token); } + } - public async getGitHubToken(userId: string) { - const storeTokenResponse = await this.getUserToken("github", userId); - if (!storeTokenResponse) { - return null; - } - - let senderToken = UserTokenStore.parseGitHubToken(storeTokenResponse); - const date = Date.now(); - if (senderToken.expires_in && senderToken.expires_in < date) { - log.info(`GitHub access token for ${userId} has expired ${senderToken.expires_in} < ${date}, attempting refresh`); - if (!this.config.github?.oauth) { - throw new TokenError(TokenErrorCode.EXPIRED, "GitHub oauth not configured, cannot refresh token"); - } - if (senderToken.refresh_token && senderToken.refresh_token_expires_in && senderToken?.refresh_token_expires_in > date) { - // Needs a refresh. - const refreshResult = await GithubInstance.refreshAccessToken( - senderToken.refresh_token, - this.config.github.oauth.client_id, - this.config.github.oauth.client_secret, - this.config.github.baseUrl - ); - if (!refreshResult.access_token) { - throw Error('Refresh token response had the wrong response format!'); - } - senderToken = { - access_token: refreshResult.access_token, - expires_in: refreshResult.expires_in && ((parseInt(refreshResult.expires_in) * 1000) + date), - token_type: refreshResult.token_type, - refresh_token: refreshResult.refresh_token, - refresh_token_expires_in: refreshResult.refresh_token_expires_in && ((parseInt(refreshResult.refresh_token_expires_in) * 1000) + date), - } as GitHubOAuthToken; - await this.storeUserToken("github", userId, JSON.stringify(senderToken)); - } else { - log.error(`GitHub access token for ${userId} has expired, and the refresh token is stale or not given`); - throw new TokenError(TokenErrorCode.EXPIRED, `GitHub access token for ${userId} has expired, and the refresh token is stale or not given`); - } - } - return senderToken.access_token; + public async getGitHubToken(userId: string) { + const storeTokenResponse = await this.getUserToken("github", userId); + if (!storeTokenResponse) { + return null; } - public async getOctokitForUser(userId: string) { - if (!this.config.github) { - throw Error('GitHub is not configured'); + let senderToken = UserTokenStore.parseGitHubToken(storeTokenResponse); + const date = Date.now(); + if (senderToken.expires_in && senderToken.expires_in < date) { + log.info( + `GitHub access token for ${userId} has expired ${senderToken.expires_in} < ${date}, attempting refresh`, + ); + if (!this.config.github?.oauth) { + throw new TokenError( + TokenErrorCode.EXPIRED, + "GitHub oauth not configured, cannot refresh token", + ); + } + if ( + senderToken.refresh_token && + senderToken.refresh_token_expires_in && + senderToken?.refresh_token_expires_in > date + ) { + // Needs a refresh. + const refreshResult = await GithubInstance.refreshAccessToken( + senderToken.refresh_token, + this.config.github.oauth.client_id, + this.config.github.oauth.client_secret, + this.config.github.baseUrl, + ); + if (!refreshResult.access_token) { + throw Error("Refresh token response had the wrong response format!"); } - const res = await this.getGitHubToken(userId); - return res ? GithubInstance.createUserOctokit(res, this.config.github.baseUrl) : null; + senderToken = { + access_token: refreshResult.access_token, + expires_in: + refreshResult.expires_in && + parseInt(refreshResult.expires_in) * 1000 + date, + token_type: refreshResult.token_type, + refresh_token: refreshResult.refresh_token, + refresh_token_expires_in: + refreshResult.refresh_token_expires_in && + parseInt(refreshResult.refresh_token_expires_in) * 1000 + date, + } as GitHubOAuthToken; + await this.storeUserToken( + "github", + userId, + JSON.stringify(senderToken), + ); + } else { + log.error( + `GitHub access token for ${userId} has expired, and the refresh token is stale or not given`, + ); + throw new TokenError( + TokenErrorCode.EXPIRED, + `GitHub access token for ${userId} has expired, and the refresh token is stale or not given`, + ); + } } + return senderToken.access_token; + } - public async getOpenProjectForUser(userId: string) { - if (!this.config.openProject || !this.openProjectOAuth) { - throw Error('OpenProject is not configured'); - } - const res = await this.getUserToken('openproject', userId); - return res ? new OpenProjectAPIClient(this.config.openProject.baseURL, res, this.openProjectOAuth, (newToken) => this.storeOpenProjectToken(userId, newToken)) : null; + public async getOctokitForUser(userId: string) { + if (!this.config.github) { + throw Error("GitHub is not configured"); } - - public async getGitLabForUser(userId: string, instanceUrl: string) { - const senderToken = await this.getUserToken("gitlab", userId, instanceUrl); - if (!senderToken) { - return null; - } - return new GitLabClient(instanceUrl, senderToken); + const res = await this.getGitHubToken(userId); + return res + ? GithubInstance.createUserOctokit(res, this.config.github.baseUrl) + : null; + } + + public async getOpenProjectForUser(userId: string) { + if (!this.config.openProject || !this.openProjectOAuth) { + throw Error("OpenProject is not configured"); + } + const res = await this.getUserToken("openproject", userId); + return res + ? new OpenProjectAPIClient( + this.config.openProject.baseURL, + res, + this.openProjectOAuth, + (newToken) => this.storeOpenProjectToken(userId, newToken), + ) + : null; + } + + public async getGitLabForUser(userId: string, instanceUrl: string) { + const senderToken = await this.getUserToken("gitlab", userId, instanceUrl); + if (!senderToken) { + return null; + } + return new GitLabClient(instanceUrl, senderToken); + } + + public async getJiraForUser( + userId: string, + instanceUrl?: string, + ): Promise { + if (!this.config.jira?.oauth) { + throw Error("Jira not configured"); } - public async getJiraForUser(userId: string, instanceUrl?: string): Promise { - if (!this.config.jira?.oauth) { - throw Error('Jira not configured'); - } - - let instance = instanceUrl && new URL(instanceUrl).host; + let instance = instanceUrl && new URL(instanceUrl).host; - if (!instance || isJiraCloudInstance(instance)) { - instance = BridgeConfigJira.CLOUD_INSTANCE_NAME; - } - - let jsonData = await this.getUserToken("jira", userId, instance); - // XXX: Legacy fallback - if (!jsonData && instance === BridgeConfigJira.CLOUD_INSTANCE_NAME) { - jsonData = await this.getUserToken("jira", userId); - } - if (!jsonData) { - return null; - } - const storedToken = JSON.parse(jsonData) as JiraStoredToken; - if (!storedToken.instance) { - // Legacy stored tokens don't include the cloud instance string. - storedToken.instance = BridgeConfigJira.CLOUD_INSTANCE_NAME; - } - if (storedToken.instance === BridgeConfigJira.CLOUD_INSTANCE_NAME) { - return new JiraCloudClient(storedToken, (data) => { - return this.storeJiraToken(userId, data); - }, this.config.jira, instance); - } else if (this.config.jira.url) { - return new JiraOnPremClient( - storedToken, - (this.jiraOAuth as JiraOnPremOAuth).privateKey, - this.config.jira.oauth as BridgeConfigJiraOnPremOAuth, - this.config.jira.url, - ); - } - throw Error('Could not determine type of client'); + if (!instance || isJiraCloudInstance(instance)) { + instance = BridgeConfigJira.CLOUD_INSTANCE_NAME; } - public createStateForOAuth(userId: string): string { - const state = randomUUID(); - this.oauthSessionStore.set(state, { - userId, - timeout: setTimeout(() => this.oauthSessionStore.delete(state), OAUTH_TIMEOUT_MS), - }); - return state; + let jsonData = await this.getUserToken("jira", userId, instance); + // XXX: Legacy fallback + if (!jsonData && instance === BridgeConfigJira.CLOUD_INSTANCE_NAME) { + jsonData = await this.getUserToken("jira", userId); } - - public getUserIdForOAuthState(state: string, remove = true) { - const result = this.oauthSessionStore.get(state); - if (!result) { - return null; - } - if (remove) { - clearTimeout(result.timeout); - this.oauthSessionStore.delete(state); - } - return result.userId; + if (!jsonData) { + return null; + } + const storedToken = JSON.parse(jsonData) as JiraStoredToken; + if (!storedToken.instance) { + // Legacy stored tokens don't include the cloud instance string. + storedToken.instance = BridgeConfigJira.CLOUD_INSTANCE_NAME; + } + if (storedToken.instance === BridgeConfigJira.CLOUD_INSTANCE_NAME) { + return new JiraCloudClient( + storedToken, + (data) => { + return this.storeJiraToken(userId, data); + }, + this.config.jira, + instance, + ); + } else if (this.config.jira.url) { + return new JiraOnPremClient( + storedToken, + (this.jiraOAuth as JiraOnPremOAuth).privateKey, + this.config.jira.oauth as BridgeConfigJiraOnPremOAuth, + this.config.jira.url, + ); + } + throw Error("Could not determine type of client"); + } + + public createStateForOAuth(userId: string): string { + const state = randomUUID(); + this.oauthSessionStore.set(state, { + userId, + timeout: setTimeout( + () => this.oauthSessionStore.delete(state), + OAUTH_TIMEOUT_MS, + ), + }); + return state; + } + + public getUserIdForOAuthState(state: string, remove = true) { + const result = this.oauthSessionStore.get(state); + if (!result) { + return null; + } + if (remove) { + clearTimeout(result.timeout); + this.oauthSessionStore.delete(state); } + return result.userId; + } } diff --git a/src/tokens/oauth.ts b/src/tokens/oauth.ts index 64b04a650..2b4bcf1a7 100644 --- a/src/tokens/oauth.ts +++ b/src/tokens/oauth.ts @@ -1,10 +1,10 @@ export interface OAuthRequest { - state: string; - code: string; + state: string; + code: string; } export enum OAuthRequestResult { - UnknownFailure, - Success, - InvalidScope, - UserNotFound, -} \ No newline at end of file + UnknownFailure, + Success, + InvalidScope, + UserNotFound, +} diff --git a/tests/AdminRoomTest.ts b/tests/AdminRoomTest.ts index 78cc888b3..a2da15992 100644 --- a/tests/AdminRoomTest.ts +++ b/tests/AdminRoomTest.ts @@ -9,22 +9,40 @@ import { IntentMock } from "./utils/IntentMock"; const ROOM_ID = "!foo:bar"; -function createAdminRoom(data: any = {admin_user: "@admin:bar"}): [AdminRoom, IntentMock] { - const intent = IntentMock.create("@admin:bar"); - if (!data.admin_user) { - data.admin_user = "@admin:bar"; - } - return [new AdminRoom(ROOM_ID, data, NotifFilter.getDefaultContent(), intent, {} as UserTokenStore, DefaultConfig, {} as ConnectionManager), intent]; -} +function createAdminRoom( + data: any = { admin_user: "@admin:bar" }, +): [AdminRoom, IntentMock] { + const intent = IntentMock.create("@admin:bar"); + if (!data.admin_user) { + data.admin_user = "@admin:bar"; + } + return [ + new AdminRoom( + ROOM_ID, + data, + NotifFilter.getDefaultContent(), + intent, + {} as UserTokenStore, + DefaultConfig, + {} as ConnectionManager, + ), + intent, + ]; +} describe("AdminRoom", () => { - it("will present help text", async () => { - const [adminRoom, intent] = createAdminRoom(); - await adminRoom.handleCommand("$foo:bar", "help"); - expect(intent.sentEvents).to.have.lengthOf(1); - expect(intent.sentEvents[0]).to.deep.equal({ - roomId: ROOM_ID, - content: AdminRoom.helpMessage(undefined, ["Github", "Gitlab", "Jira", "OpenProject"]), - }); + it("will present help text", async () => { + const [adminRoom, intent] = createAdminRoom(); + await adminRoom.handleCommand("$foo:bar", "help"); + expect(intent.sentEvents).to.have.lengthOf(1); + expect(intent.sentEvents[0]).to.deep.equal({ + roomId: ROOM_ID, + content: AdminRoom.helpMessage(undefined, [ + "Github", + "Gitlab", + "Jira", + "OpenProject", + ]), }); -}) + }); +}); diff --git a/tests/FeedReader.spec.ts b/tests/FeedReader.spec.ts index cf8670cc7..3189d08ea 100644 --- a/tests/FeedReader.spec.ts +++ b/tests/FeedReader.spec.ts @@ -6,80 +6,88 @@ import { IConnection } from "../src/Connections"; import { FeedEntry, FeedReader } from "../src/feeds/FeedReader"; import { MessageQueue, MessageQueueMessage } from "../src/MessageQueue"; import { MemoryStorageProvider } from "../src/Stores/MemoryStorageProvider"; -import { Server, createServer } from 'http'; +import { Server, createServer } from "http"; import { AddressInfo } from "net"; class MockConnectionManager extends EventEmitter { - constructor( - public connections: IConnection[] - ) { - super(); - } - - getAllConnectionsOfType() { - return this.connections; - } + constructor(public connections: IConnection[]) { + super(); + } + + getAllConnectionsOfType() { + return this.connections; + } } class MockMessageQueue extends EventEmitter implements MessageQueue { - subscribe(eventGlob: string): void { - this.emit('subscribed', eventGlob); - } + subscribe(eventGlob: string): void { + this.emit("subscribed", eventGlob); + } - unsubscribe(eventGlob: string): void { - this.emit('unsubscribed', eventGlob); - } + unsubscribe(eventGlob: string): void { + this.emit("unsubscribed", eventGlob); + } - async push(data: MessageQueueMessage, single?: boolean): Promise { - this.emit('pushed', data, single); - } + async push( + data: MessageQueueMessage, + single?: boolean, + ): Promise { + this.emit("pushed", data, single); + } - async pushWait(): Promise { - throw new Error('Not yet implemented'); - } + async pushWait(): Promise { + throw new Error("Not yet implemented"); + } } -async function constructFeedReader(feedResponse: () => {headers: Record, data: string}) { - const httpServer = await new Promise(resolve => { - const srv = createServer((_req, res) => { - res.writeHead(200); - const { headers, data } = feedResponse(); - Object.entries(headers).forEach(([key,value]) => { - res.setHeader(key, value); - }); - res.write(data); - res.end(); - }).listen(0, '127.0.0.1', () => { - resolve(srv); - }); - }); - const address = httpServer.address() as AddressInfo; - const feedUrl = `http://127.0.0.1:${address.port}/` - const config = new BridgeConfigFeeds({ - enabled: true, - pollIntervalSeconds: 1, - pollTimeoutSeconds: 1, +async function constructFeedReader( + feedResponse: () => { headers: Record; data: string }, +) { + const httpServer = await new Promise((resolve) => { + const srv = createServer((_req, res) => { + res.writeHead(200); + const { headers, data } = feedResponse(); + Object.entries(headers).forEach(([key, value]) => { + res.setHeader(key, value); + }); + res.write(data); + res.end(); + }).listen(0, "127.0.0.1", () => { + resolve(srv); }); - const cm = new MockConnectionManager([{ feedUrl } as unknown as IConnection]) as unknown as ConnectionManager - const mq = new MockMessageQueue(); - const events: MessageQueueMessage[] = []; - mq.on('pushed', (data) => { if (data.eventName === 'feed.entry') {events.push(data);} }); + }); + const address = httpServer.address() as AddressInfo; + const feedUrl = `http://127.0.0.1:${address.port}/`; + const config = new BridgeConfigFeeds({ + enabled: true, + pollIntervalSeconds: 1, + pollTimeoutSeconds: 1, + }); + const cm = new MockConnectionManager([ + { feedUrl } as unknown as IConnection, + ]) as unknown as ConnectionManager; + const mq = new MockMessageQueue(); + const events: MessageQueueMessage[] = []; + mq.on("pushed", (data) => { + if (data.eventName === "feed.entry") { + events.push(data); + } + }); - const storage = new MemoryStorageProvider(); - // Ensure we don't initial sync by storing a guid. - await storage.storeFeedGuids(feedUrl, '-test-guid-'); - const feedReader = new FeedReader( - config, cm, mq, storage, - ); - // eslint-disable-next-line mocha/no-top-level-hooks - after(() => httpServer.close()); - return {config, cm, events, feedReader, feedUrl, httpServer, storage}; + const storage = new MemoryStorageProvider(); + // Ensure we don't initial sync by storing a guid. + await storage.storeFeedGuids(feedUrl, "-test-guid-"); + const feedReader = new FeedReader(config, cm, mq, storage); + // eslint-disable-next-line mocha/no-top-level-hooks + after(() => httpServer.close()); + return { config, cm, events, feedReader, feedUrl, httpServer, storage }; } describe("FeedReader", () => { - it("should correctly handle empty titles", async () => { - const { events, feedReader, feedUrl } = await constructFeedReader(() => ({ - headers: {}, data: ` + it("should correctly handle empty titles", async () => { + const { events, feedReader, feedUrl } = await constructFeedReader(() => ({ + headers: {}, + data: ` test feedhttp://test/ @@ -91,20 +99,21 @@ describe("FeedReader", () => { Wed, 12 Apr 2023 09:53:00 GMT - ` - })); + `, + })); - await feedReader.pollFeed(feedUrl); - feedReader.stop(); - expect(events).to.have.lengthOf(1); + await feedReader.pollFeed(feedUrl); + feedReader.stop(); + expect(events).to.have.lengthOf(1); - expect(events[0].data.feed.title).to.equal(null); - expect(events[0].data.title).to.equal(null); - }); + expect(events[0].data.feed.title).to.equal(null); + expect(events[0].data.title).to.equal(null); + }); - it("should handle RSS 2.0 feeds", async () => { - const { events, feedReader, feedUrl } = await constructFeedReader(() => ({ - headers: {}, data: ` + it("should handle RSS 2.0 feeds", async () => { + const { events, feedReader, feedUrl } = await constructFeedReader(() => ({ + headers: {}, + data: ` @@ -125,24 +134,27 @@ describe("FeedReader", () => { - ` - })); + `, + })); - await feedReader.pollFeed(feedUrl); - feedReader.stop(); - expect(events).to.have.lengthOf(1); + await feedReader.pollFeed(feedUrl); + feedReader.stop(); + expect(events).to.have.lengthOf(1); - expect(events[0].data.feed.title).to.equal('RSS Title'); - expect(events[0].data.author).to.equal('John Doe'); - expect(events[0].data.title).to.equal('Example entry'); - expect(events[0].data.summary).to.equal('Here is some text containing an interesting description.'); - expect(events[0].data.link).to.equal('http://www.example.com/blog/post/1'); - expect(events[0].data.pubdate).to.equal('Sun, 6 Sep 2009 16:20:00 +0000'); - }); + expect(events[0].data.feed.title).to.equal("RSS Title"); + expect(events[0].data.author).to.equal("John Doe"); + expect(events[0].data.title).to.equal("Example entry"); + expect(events[0].data.summary).to.equal( + "Here is some text containing an interesting description.", + ); + expect(events[0].data.link).to.equal("http://www.example.com/blog/post/1"); + expect(events[0].data.pubdate).to.equal("Sun, 6 Sep 2009 16:20:00 +0000"); + }); - it("should handle RSS feeds with a permalink url", async () => { - const { events, feedReader, feedUrl } = await constructFeedReader(() => ({ - headers: {}, data: ` + it("should handle RSS feeds with a permalink url", async () => { + const { events, feedReader, feedUrl } = await constructFeedReader(() => ({ + headers: {}, + data: ` @@ -162,24 +174,27 @@ describe("FeedReader", () => { - ` - })); + `, + })); - await feedReader.pollFeed(feedUrl); - feedReader.stop(); - expect(events).to.have.lengthOf(1); + await feedReader.pollFeed(feedUrl); + feedReader.stop(); + expect(events).to.have.lengthOf(1); - expect(events[0].data.feed.title).to.equal('RSS Title'); - expect(events[0].data.author).to.equal('John Doe'); - expect(events[0].data.title).to.equal('Example entry'); - expect(events[0].data.summary).to.equal('Here is some text containing an interesting description.'); - expect(events[0].data.link).to.equal('http://www.example.com/blog/post/1'); - expect(events[0].data.pubdate).to.equal('Sun, 6 Sep 2009 16:20:00 +0000'); - }); + expect(events[0].data.feed.title).to.equal("RSS Title"); + expect(events[0].data.author).to.equal("John Doe"); + expect(events[0].data.title).to.equal("Example entry"); + expect(events[0].data.summary).to.equal( + "Here is some text containing an interesting description.", + ); + expect(events[0].data.link).to.equal("http://www.example.com/blog/post/1"); + expect(events[0].data.pubdate).to.equal("Sun, 6 Sep 2009 16:20:00 +0000"); + }); - it("should handle Atom feeds", async () => { - const { events, feedReader, feedUrl } = await constructFeedReader(() => ({ - headers: {}, data: ` + it("should handle Atom feeds", async () => { + const { events, feedReader, feedUrl } = await constructFeedReader(() => ({ + headers: {}, + data: ` @@ -203,24 +218,27 @@ describe("FeedReader", () => { - ` - })); + `, + })); - await feedReader.pollFeed(feedUrl); - feedReader.stop(); - expect(events).to.have.lengthOf(1); + await feedReader.pollFeed(feedUrl); + feedReader.stop(); + expect(events).to.have.lengthOf(1); - expect(events[0].data.feed.title).to.equal('Example Feed'); - expect(events[0].data.title).to.equal('Atom-Powered Robots Run Amok'); - expect(events[0].data.author).to.equal('John Doe'); - expect(events[0].data.summary).to.equal('Some text.'); - expect(events[0].data.link).to.equal('http://example.org/2003/12/13/atom03'); - expect(events[0].data.pubdate).to.equal('Sat, 13 Dec 2003 18:30:02 +0000'); - }); + expect(events[0].data.feed.title).to.equal("Example Feed"); + expect(events[0].data.title).to.equal("Atom-Powered Robots Run Amok"); + expect(events[0].data.author).to.equal("John Doe"); + expect(events[0].data.summary).to.equal("Some text."); + expect(events[0].data.link).to.equal( + "http://example.org/2003/12/13/atom03", + ); + expect(events[0].data.pubdate).to.equal("Sat, 13 Dec 2003 18:30:02 +0000"); + }); - it("should not duplicate feed entries", async () => { - const { events, feedReader, feedUrl } = await constructFeedReader(() => ({ - headers: {}, data: ` + it("should not duplicate feed entries", async () => { + const { events, feedReader, feedUrl } = await constructFeedReader(() => ({ + headers: {}, + data: ` @@ -234,20 +252,21 @@ describe("FeedReader", () => { Some text. - ` - })); + `, + })); - await feedReader.pollFeed(feedUrl); - await feedReader.pollFeed(feedUrl); - await feedReader.pollFeed(feedUrl); - feedReader.stop(); - expect(events).to.have.lengthOf(1); - }); + await feedReader.pollFeed(feedUrl); + await feedReader.pollFeed(feedUrl); + await feedReader.pollFeed(feedUrl); + feedReader.stop(); + expect(events).to.have.lengthOf(1); + }); - it("should always hash to the same value for Atom feeds", async () => { - const expectedHash = ['md5:d41d8cd98f00b204e9800998ecf8427e']; - const { feedReader, feedUrl, storage } = await constructFeedReader(() => ({ - headers: {}, data: ` + it("should always hash to the same value for Atom feeds", async () => { + const expectedHash = ["md5:d41d8cd98f00b204e9800998ecf8427e"]; + const { feedReader, feedUrl, storage } = await constructFeedReader(() => ({ + headers: {}, + data: ` @@ -256,23 +275,24 @@ describe("FeedReader", () => { http://example.com/test/123 - ` - })); + `, + })); - await feedReader.pollFeed(feedUrl); - feedReader.stop(); - const items = await storage.hasSeenFeedGuids(feedUrl, ...expectedHash); - expect(items).to.deep.equal(expectedHash); - }); + await feedReader.pollFeed(feedUrl); + feedReader.stop(); + const items = await storage.hasSeenFeedGuids(feedUrl, ...expectedHash); + expect(items).to.deep.equal(expectedHash); + }); - it("should always hash to the same value for RSS feeds", async () => { - const expectedHash = [ - 'md5:98bafde155b931e656ad7c137cd7711e', // guid - 'md5:72eec3c0d59ff91a80f0073ee4f8511a', // link - 'md5:7c5dd7e5988ff388ab2a402ce7feb2f0', // title - ]; - const { feedReader, feedUrl, storage } = await constructFeedReader(() => ({ - headers: {}, data: ` + it("should always hash to the same value for RSS feeds", async () => { + const expectedHash = [ + "md5:98bafde155b931e656ad7c137cd7711e", // guid + "md5:72eec3c0d59ff91a80f0073ee4f8511a", // link + "md5:7c5dd7e5988ff388ab2a402ce7feb2f0", // title + ]; + const { feedReader, feedUrl, storage } = await constructFeedReader(() => ({ + headers: {}, + data: ` @@ -291,12 +311,12 @@ describe("FeedReader", () => { - ` - })); + `, + })); - await feedReader.pollFeed(feedUrl); - feedReader.stop(); - const items = await storage.hasSeenFeedGuids(feedUrl, ...expectedHash); - expect(items).to.deep.equal(expectedHash); - }); + await feedReader.pollFeed(feedUrl); + feedReader.stop(); + const items = await storage.hasSeenFeedGuids(feedUrl, ...expectedHash); + expect(items).to.deep.equal(expectedHash); + }); }); diff --git a/tests/FormatUtilTest.ts b/tests/FormatUtilTest.ts index 315f2ad3c..6864e3f15 100644 --- a/tests/FormatUtilTest.ts +++ b/tests/FormatUtilTest.ts @@ -3,141 +3,156 @@ import { expect } from "chai"; import { JiraIssue, JiraProject } from "../src/jira/Types"; const SIMPLE_ISSUE = { - id: 123, - number: 123, - state: "open", - title: "A simple title", - full_name: "evilcorp/lab", - url: "https://github.com/evilcorp/lab/issues/123", - html_url: "https://github.com/evilcorp/lab/issues/123", - repository_url: "https://api.github.com/repos/evilcorp/lab", + id: 123, + number: 123, + state: "open", + title: "A simple title", + full_name: "evilcorp/lab", + url: "https://github.com/evilcorp/lab/issues/123", + html_url: "https://github.com/evilcorp/lab/issues/123", + repository_url: "https://api.github.com/repos/evilcorp/lab", }; const SIMPLE_REPO = { - id: 123, - description: "A simple description", - full_name: "evilcorp/lab", - html_url: "https://github.com/evilcorp/lab", + id: 123, + description: "A simple description", + full_name: "evilcorp/lab", + html_url: "https://github.com/evilcorp/lab", }; const SIMPLE_JIRA_ISSUE = { - id: "test-issue", - self: "http://example-api.url.com/issue-url", - key: "TEST-001", - fields: { - summary: "summary", - issuetype: "foo", - project: { - self: "http://example-api.url.com/project-url", - id: "test-project", - key: "TEST", - name: "Test Project", - projectTypeKey: "project-type-key", - simplified: false, - avatarUrls: {} - } as JiraProject, - assignee: null, - priority: "1", - status: "open", - }, + id: "test-issue", + self: "http://example-api.url.com/issue-url", + key: "TEST-001", + fields: { + summary: "summary", + issuetype: "foo", + project: { + self: "http://example-api.url.com/project-url", + id: "test-project", + key: "TEST", + name: "Test Project", + projectTypeKey: "project-type-key", + simplified: false, + avatarUrls: {}, + } as JiraProject, + assignee: null, + priority: "1", + status: "open", + }, } as JiraIssue; describe("FormatUtilTest", () => { - it("should correctly formats a repo room name", () => { - expect(FormatUtil.formatRepoRoomName(SIMPLE_REPO)).to.equal( - "evilcorp/lab: A simple description", - ); - }); + it("should correctly formats a repo room name", () => { + expect(FormatUtil.formatRepoRoomName(SIMPLE_REPO)).to.equal( + "evilcorp/lab: A simple description", + ); + }); - it("should correctly formats a issue room name", () => { - expect(FormatUtil.formatIssueRoomName(SIMPLE_ISSUE, SIMPLE_REPO)).to.equal( - "evilcorp/lab#123: A simple title", - ); - }); + it("should correctly formats a issue room name", () => { + expect(FormatUtil.formatIssueRoomName(SIMPLE_ISSUE, SIMPLE_REPO)).to.equal( + "evilcorp/lab#123: A simple title", + ); + }); - it("should correctly generate a partial body for a Github repo", () => { - expect(FormatUtil.getPartialBodyForGithubRepo(SIMPLE_REPO)).to.deep.equal({ - "external_url": "https://github.com/evilcorp/lab", - "uk.half-shot.matrix-hookshot.github.repo": { - id: 123, - name: "evilcorp/lab", - url: "https://github.com/evilcorp/lab", - }, - }); + it("should correctly generate a partial body for a Github repo", () => { + expect(FormatUtil.getPartialBodyForGithubRepo(SIMPLE_REPO)).to.deep.equal({ + external_url: "https://github.com/evilcorp/lab", + "uk.half-shot.matrix-hookshot.github.repo": { + id: 123, + name: "evilcorp/lab", + url: "https://github.com/evilcorp/lab", + }, }); + }); - it("should correctly generate a partial body for a Github issue", () => { - expect(FormatUtil.getPartialBodyForGithubIssue(SIMPLE_REPO, SIMPLE_ISSUE)).to.deep.equal({ - "external_url": "https://github.com/evilcorp/lab/issues/123", - "uk.half-shot.matrix-hookshot.github.issue": { - id: 123, - number: 123, - title: "A simple title", - url: "https://github.com/evilcorp/lab/issues/123", - }, - "uk.half-shot.matrix-hookshot.github.repo": { - id: 123, - name: "evilcorp/lab", - url: "https://github.com/evilcorp/lab", - }, - }); + it("should correctly generate a partial body for a Github issue", () => { + expect( + FormatUtil.getPartialBodyForGithubIssue(SIMPLE_REPO, SIMPLE_ISSUE), + ).to.deep.equal({ + external_url: "https://github.com/evilcorp/lab/issues/123", + "uk.half-shot.matrix-hookshot.github.issue": { + id: 123, + number: 123, + title: "A simple title", + url: "https://github.com/evilcorp/lab/issues/123", + }, + "uk.half-shot.matrix-hookshot.github.repo": { + id: 123, + name: "evilcorp/lab", + url: "https://github.com/evilcorp/lab", + }, }); + }); - it("should correctly formats a room topic", () => { - expect(FormatUtil.formatRoomTopic(SIMPLE_ISSUE)).to.equal( - "Status: open | https://github.com/evilcorp/lab/issues/123", - ); - }); + it("should correctly formats a room topic", () => { + expect(FormatUtil.formatRoomTopic(SIMPLE_ISSUE)).to.equal( + "Status: open | https://github.com/evilcorp/lab/issues/123", + ); + }); - it("should correctly format one simple label", () => { - expect(FormatUtil.formatLabels([{name: "foo"}])).to.deep.equal({ - plain: "foo", - html: "foo" - }); + it("should correctly format one simple label", () => { + expect(FormatUtil.formatLabels([{ name: "foo" }])).to.deep.equal({ + plain: "foo", + html: "foo", }); + }); - it("should correctly format many simple labels", () => { - expect(FormatUtil.formatLabels([{name: "foo"},{name: "bar"}])).to.deep.equal({ - plain: "foo, bar", - html: "foo bar" - }); + it("should correctly format many simple labels", () => { + expect( + FormatUtil.formatLabels([{ name: "foo" }, { name: "bar" }]), + ).to.deep.equal({ + plain: "foo, bar", + html: "foo bar", }); + }); - it("should correctly format one detailed label", () => { - expect(FormatUtil.formatLabels([{name: "foo", color: 'FFFFFF', description: 'My label'}])).to.deep.equal({ - plain: "foo", - html: "foo" - }); + it("should correctly format one detailed label", () => { + expect( + FormatUtil.formatLabels([ + { name: "foo", color: "FFFFFF", description: "My label" }, + ]), + ).to.deep.equal({ + plain: "foo", + html: 'foo', }); + }); - it("should correctly format many detailed labels", () => { - expect(FormatUtil.formatLabels([ - {name: "foo", color: 'FFFFFF', description: 'My label'}, - {name: "bar", color: 'AACCEE', description: 'My other label'}, - ])).to.deep.equal({ - plain: "foo, bar", - html: "foo " - + "bar" - },); + it("should correctly format many detailed labels", () => { + expect( + FormatUtil.formatLabels([ + { name: "foo", color: "FFFFFF", description: "My label" }, + { name: "bar", color: "AACCEE", description: "My other label" }, + ]), + ).to.deep.equal({ + plain: "foo, bar", + html: + 'foo ' + + 'bar', }); + }); - it("should correctly format a JIRA issue", () => { - expect(FormatUtil.getPartialBodyForJiraIssue(SIMPLE_JIRA_ISSUE)).to.deep.equal({ - "external_url": "http://example-api.url.com/browse/TEST-001", - "uk.half-shot.matrix-hookshot.jira.issue": { - "api_url": "http://example-api.url.com/issue-url", - "id": "test-issue", - "key": "TEST-001", - }, - "uk.half-shot.matrix-hookshot.jira.project": { - "api_url": "http://example-api.url.com/project-url", - "id": "test-project", - "key": "TEST", - }, - }); + it("should correctly format a JIRA issue", () => { + expect( + FormatUtil.getPartialBodyForJiraIssue(SIMPLE_JIRA_ISSUE), + ).to.deep.equal({ + external_url: "http://example-api.url.com/browse/TEST-001", + "uk.half-shot.matrix-hookshot.jira.issue": { + api_url: "http://example-api.url.com/issue-url", + id: "test-issue", + key: "TEST-001", + }, + "uk.half-shot.matrix-hookshot.jira.project": { + api_url: "http://example-api.url.com/project-url", + id: "test-project", + key: "TEST", + }, }); + }); - it("should hash an ID", () => { - expect(FormatUtil.hashId("foobar")).to.equal('3858f62230ac3c915f300c664312c63f'); - }); + it("should hash an ID", () => { + expect(FormatUtil.hashId("foobar")).to.equal( + "3858f62230ac3c915f300c664312c63f", + ); + }); }); diff --git a/tests/HookFilter.ts b/tests/HookFilter.ts index 16fa1423d..cad745780 100644 --- a/tests/HookFilter.ts +++ b/tests/HookFilter.ts @@ -1,56 +1,86 @@ import { expect } from "chai"; -import { HookFilter } from '../src/HookFilter'; +import { HookFilter } from "../src/HookFilter"; -const DEFAULT_SET = ['default-allowed', 'default-allowed-but-ignored']; -const ENABLED_SET = ['enabled-hook', 'enabled-but-ignored']; +const DEFAULT_SET = ["default-allowed", "default-allowed-but-ignored"]; +const ENABLED_SET = ["enabled-hook", "enabled-but-ignored"]; describe("HookFilter", () => { - let filter: HookFilter; + let filter: HookFilter; - beforeEach(() => { - filter = new HookFilter(ENABLED_SET); + beforeEach(() => { + filter = new HookFilter(ENABLED_SET); + }); + + describe("shouldSkip", () => { + it("should allow a hook named in enabled set", () => { + expect(filter.shouldSkip("enabled-hook")).to.be.false; }); - describe('shouldSkip', () => { - it('should allow a hook named in enabled set', () => { - expect(filter.shouldSkip('enabled-hook')).to.be.false; - }); + it("should not allow a hook not named in enabled set", () => { + expect(filter.shouldSkip("not-enabled-hook")).to.be.true; + }); + }); - it('should not allow a hook not named in enabled set', () => { - expect(filter.shouldSkip('not-enabled-hook')).to.be.true; - }); + describe("convertIgnoredHooksToEnabledHooks", () => { + it("should correctly provide a list of default hooks", () => { + expect( + HookFilter.convertIgnoredHooksToEnabledHooks([], [], DEFAULT_SET), + ).to.have.members(DEFAULT_SET); }); - - describe('convertIgnoredHooksToEnabledHooks', () => { - it('should correctly provide a list of default hooks', () => { - expect(HookFilter.convertIgnoredHooksToEnabledHooks([], [], DEFAULT_SET)).to.have.members(DEFAULT_SET); - }); - - it('should correctly include default and enabled hooks when ignored hooks is set', () => { - expect(HookFilter.convertIgnoredHooksToEnabledHooks(ENABLED_SET, ['my-ignored-hook'], DEFAULT_SET)).to.have.members([ - ...ENABLED_SET, ...DEFAULT_SET - ]); - }); - - it('should deduplicate', () => { - expect(HookFilter.convertIgnoredHooksToEnabledHooks(DEFAULT_SET, [], DEFAULT_SET)).to.have.members(DEFAULT_SET); - }); - - it('should correctly exclude ignored hooks', () => { - expect(HookFilter.convertIgnoredHooksToEnabledHooks([], [DEFAULT_SET[0]], DEFAULT_SET)).to.not.include([ - DEFAULT_SET[0] - ]); - }); - - it('should handle ignored root hooks', () => { - const defaultHooks = ['myhook', 'myhook.foo', 'myhook.foo.bar']; - expect(HookFilter.convertIgnoredHooksToEnabledHooks([], ['myhook.foo.bar'], defaultHooks)).to.have.members([ - 'myhook', 'myhook.foo' - ]); - expect(HookFilter.convertIgnoredHooksToEnabledHooks([], ['myhook.foo'], defaultHooks)).to.have.members([ - 'myhook' - ]); - expect(HookFilter.convertIgnoredHooksToEnabledHooks([], ['myhook'], defaultHooks)).to.be.empty; - }); + + it("should correctly include default and enabled hooks when ignored hooks is set", () => { + expect( + HookFilter.convertIgnoredHooksToEnabledHooks( + ENABLED_SET, + ["my-ignored-hook"], + DEFAULT_SET, + ), + ).to.have.members([...ENABLED_SET, ...DEFAULT_SET]); + }); + + it("should deduplicate", () => { + expect( + HookFilter.convertIgnoredHooksToEnabledHooks( + DEFAULT_SET, + [], + DEFAULT_SET, + ), + ).to.have.members(DEFAULT_SET); + }); + + it("should correctly exclude ignored hooks", () => { + expect( + HookFilter.convertIgnoredHooksToEnabledHooks( + [], + [DEFAULT_SET[0]], + DEFAULT_SET, + ), + ).to.not.include([DEFAULT_SET[0]]); + }); + + it("should handle ignored root hooks", () => { + const defaultHooks = ["myhook", "myhook.foo", "myhook.foo.bar"]; + expect( + HookFilter.convertIgnoredHooksToEnabledHooks( + [], + ["myhook.foo.bar"], + defaultHooks, + ), + ).to.have.members(["myhook", "myhook.foo"]); + expect( + HookFilter.convertIgnoredHooksToEnabledHooks( + [], + ["myhook.foo"], + defaultHooks, + ), + ).to.have.members(["myhook"]); + expect( + HookFilter.convertIgnoredHooksToEnabledHooks( + [], + ["myhook"], + defaultHooks, + ), + ).to.be.empty; }); + }); }); diff --git a/tests/IntentUtilsTest.ts b/tests/IntentUtilsTest.ts index b611cb240..04c7ac236 100644 --- a/tests/IntentUtilsTest.ts +++ b/tests/IntentUtilsTest.ts @@ -7,52 +7,64 @@ const ROOM_ID = "!foo:bar"; const SENDER_USER_ID = "@my_target:foo"; describe("IntentUtils", () => { - describe("ensureUserIsInRoom", () => { - it("no-ops if the user is already joined to the room", () => { - const targetIntent = IntentMock.create(SENDER_USER_ID); - targetIntent.ensureJoined = () => { /* No-op */ }; - const matrixClient = MatrixClientMock.create(); - ensureUserIsInRoom(targetIntent, matrixClient, ROOM_ID); - }); + describe("ensureUserIsInRoom", () => { + it("no-ops if the user is already joined to the room", () => { + const targetIntent = IntentMock.create(SENDER_USER_ID); + targetIntent.ensureJoined = () => { + /* No-op */ + }; + const matrixClient = MatrixClientMock.create(); + ensureUserIsInRoom(targetIntent, matrixClient, ROOM_ID); + }); - it("invites the user to the room and joins", () => { - const targetIntent = IntentMock.create(SENDER_USER_ID); - const matrixClient = MatrixClientMock.create(); - let hasInvited = false; - // This should fail the first time, then pass once we've tried to invite the user - targetIntent.ensureJoined = (roomId: string) => { - if (hasInvited) { - return; - } - expect(roomId).to.equal(ROOM_ID); - throw new MatrixError({ errcode: "M_FORBIDDEN", error: "Test forced error"}, 401, {}) - }; + it("invites the user to the room and joins", () => { + const targetIntent = IntentMock.create(SENDER_USER_ID); + const matrixClient = MatrixClientMock.create(); + let hasInvited = false; + // This should fail the first time, then pass once we've tried to invite the user + targetIntent.ensureJoined = (roomId: string) => { + if (hasInvited) { + return; + } + expect(roomId).to.equal(ROOM_ID); + throw new MatrixError( + { errcode: "M_FORBIDDEN", error: "Test forced error" }, + 401, + {}, + ); + }; - // This should invite the puppet user. - matrixClient.inviteUser = (userId: string, roomId: string) => { - expect(userId).to.equal(SENDER_USER_ID); - expect(roomId).to.equal(ROOM_ID); - hasInvited = true; - } + // This should invite the puppet user. + matrixClient.inviteUser = (userId: string, roomId: string) => { + expect(userId).to.equal(SENDER_USER_ID); + expect(roomId).to.equal(ROOM_ID); + hasInvited = true; + }; - ensureUserIsInRoom(targetIntent, matrixClient, ROOM_ID); - // Only pass if we've actually bothered to invite the bot. - expect(hasInvited).to.be.true; - }); + ensureUserIsInRoom(targetIntent, matrixClient, ROOM_ID); + // Only pass if we've actually bothered to invite the bot. + expect(hasInvited).to.be.true; + }); - it("invites the user to the room and handles the failure", () => { - const targetIntent = IntentMock.create(SENDER_USER_ID); - const matrixClient = MatrixClientMock.create(); - - // This should fail the first time, then pass once we've tried to invite the user - targetIntent.ensureJoined = () => { - throw new MatrixError({ errcode: "FORCED_FAILURE", error: "Test forced error"}, 500, { }) - }; - try { - ensureUserIsInRoom(targetIntent, matrixClient, ROOM_ID); - } catch (ex) { - expect(ex.message).to.contain(`Could not ensure that ${SENDER_USER_ID} is in ${ROOM_ID}`) - } - }); - }) -}); \ No newline at end of file + it("invites the user to the room and handles the failure", () => { + const targetIntent = IntentMock.create(SENDER_USER_ID); + const matrixClient = MatrixClientMock.create(); + + // This should fail the first time, then pass once we've tried to invite the user + targetIntent.ensureJoined = () => { + throw new MatrixError( + { errcode: "FORCED_FAILURE", error: "Test forced error" }, + 500, + {}, + ); + }; + try { + ensureUserIsInRoom(targetIntent, matrixClient, ROOM_ID); + } catch (ex) { + expect(ex.message).to.contain( + `Could not ensure that ${SENDER_USER_ID} is in ${ROOM_ID}`, + ); + } + }); + }); +}); diff --git a/tests/MessageQueueTest.ts b/tests/MessageQueueTest.ts index 00974fb5c..e9c2baa60 100644 --- a/tests/MessageQueueTest.ts +++ b/tests/MessageQueueTest.ts @@ -4,50 +4,50 @@ import { createMessageQueue } from "../src/MessageQueue/MessageQueue"; const mq = createMessageQueue(); describe("MessageQueueTest", () => { - describe("LocalMq", () => { - it("should be able to push an event, and listen for it", (done) => { - mq.subscribe("fakeevent"); - mq.on("fakeevent", (msg) => { - expect(msg).to.deep.equal({ - sender: "foo", - eventName: "fakeevent", - messageId: "foooo", - data: 51, - }); - done(); - }); - mq.push({ - sender: "foo", - eventName: "fakeevent", - messageId: "foooo", - data: 51, - }); + describe("LocalMq", () => { + it("should be able to push an event, and listen for it", (done) => { + mq.subscribe("fakeevent"); + mq.on("fakeevent", (msg) => { + expect(msg).to.deep.equal({ + sender: "foo", + eventName: "fakeevent", + messageId: "foooo", + data: 51, }); + done(); + }); + mq.push({ + sender: "foo", + eventName: "fakeevent", + messageId: "foooo", + data: 51, + }); + }); - it("should be able to push an event, and respond to it", async () => { - mq.subscribe("fakeevent2"); - mq.subscribe("response.fakeevent2"); - mq.on("fakeevent2", async (msg) => { - expect(msg).to.deep.equal({ - sender: "foo", - eventName: "fakeevent2", - messageId: "foooo", - data: 49, - }); - await mq.push({ - sender: "foo", - eventName: "response.fakeevent2", - messageId: "foooo", - data: "worked", - }); - }); - const response = await mq.pushWait({ - sender: "foo", - eventName: "fakeevent2", - messageId: "foooo", - data: 49, - }); - expect(response).to.equal("worked"); + it("should be able to push an event, and respond to it", async () => { + mq.subscribe("fakeevent2"); + mq.subscribe("response.fakeevent2"); + mq.on("fakeevent2", async (msg) => { + expect(msg).to.deep.equal({ + sender: "foo", + eventName: "fakeevent2", + messageId: "foooo", + data: 49, + }); + await mq.push({ + sender: "foo", + eventName: "response.fakeevent2", + messageId: "foooo", + data: "worked", }); + }); + const response = await mq.pushWait({ + sender: "foo", + eventName: "fakeevent2", + messageId: "foooo", + data: 49, + }); + expect(response).to.equal("worked"); }); + }); }); diff --git a/tests/config/config.ts b/tests/config/config.ts index f942edc9f..c9401c763 100644 --- a/tests/config/config.ts +++ b/tests/config/config.ts @@ -2,89 +2,101 @@ import { BridgeConfig } from "../../src/config/Config"; import { DefaultConfigRoot } from "../../src/config/Defaults"; import { expect } from "chai"; - describe("Config/BridgeConfig", () => { - describe("will handle the legacy queue.monolitihc option", () => { - it("with no parameters", () => { - const config = new BridgeConfig({ ...DefaultConfigRoot, queue: { - monolithic: true - }}); - expect(config.queue).to.be.undefined; - expect(config.cache?.redisUri).to.equal("redis://localhost:6379"); - }); + describe("will handle the legacy queue.monolitihc option", () => { + it("with no parameters", () => { + const config = new BridgeConfig({ + ...DefaultConfigRoot, + queue: { + monolithic: true, + }, + }); + expect(config.queue).to.be.undefined; + expect(config.cache?.redisUri).to.equal("redis://localhost:6379"); + }); - it("with a host parameter", () => { - const config = new BridgeConfig({ ...DefaultConfigRoot, queue: { - monolithic: true, - host: 'bark' - }}); - expect(config.queue).to.be.undefined; - expect(config.cache?.redisUri).to.equal("redis://bark:6379"); - }); + it("with a host parameter", () => { + const config = new BridgeConfig({ + ...DefaultConfigRoot, + queue: { + monolithic: true, + host: "bark", + }, + }); + expect(config.queue).to.be.undefined; + expect(config.cache?.redisUri).to.equal("redis://bark:6379"); + }); - it("with a port parameter", () => { - const config = new BridgeConfig({ ...DefaultConfigRoot, queue: { - monolithic: true, - port: 6379, - }}); - expect(config.queue).to.be.undefined; - expect(config.cache?.redisUri).to.equal("redis://localhost:6379"); - }); + it("with a port parameter", () => { + const config = new BridgeConfig({ + ...DefaultConfigRoot, + queue: { + monolithic: true, + port: 6379, + }, + }); + expect(config.queue).to.be.undefined; + expect(config.cache?.redisUri).to.equal("redis://localhost:6379"); + }); - it("with a host and port parameter", () => { - const config = new BridgeConfig({ ...DefaultConfigRoot, queue: { - monolithic: true, - host: 'bark', - port: 6379, - }}); - expect(config.queue).to.be.undefined; - expect(config.cache?.redisUri).to.equal("redis://bark:6379"); - }); + it("with a host and port parameter", () => { + const config = new BridgeConfig({ + ...DefaultConfigRoot, + queue: { + monolithic: true, + host: "bark", + port: 6379, + }, + }); + expect(config.queue).to.be.undefined; + expect(config.cache?.redisUri).to.equal("redis://bark:6379"); + }); - it("with monolithic disabled", () => { - const config = new BridgeConfig({ - ...DefaultConfigRoot, - encryption: undefined, - queue: { - monolithic: false - } - }); - expect(config.queue).to.deep.equal({ - monolithic: false, - }); - expect(config.cache?.redisUri).to.equal("redis://localhost:6379"); - }); + it("with monolithic disabled", () => { + const config = new BridgeConfig({ + ...DefaultConfigRoot, + encryption: undefined, + queue: { + monolithic: false, + }, + }); + expect(config.queue).to.deep.equal({ + monolithic: false, + }); + expect(config.cache?.redisUri).to.equal("redis://localhost:6379"); }); + }); - describe("will handle the queue option", () => { - it("with redisUri", () => { - const config = new BridgeConfig({ ...DefaultConfigRoot, - encryption: undefined, - queue: { - redisUri: "redis://localhost:6379" - }, - cache: undefined - }); - expect(config.queue).to.deep.equal({ - redisUri: "redis://localhost:6379" - }); - expect(config.cache).to.be.undefined; - }); + describe("will handle the queue option", () => { + it("with redisUri", () => { + const config = new BridgeConfig({ + ...DefaultConfigRoot, + encryption: undefined, + queue: { + redisUri: "redis://localhost:6379", + }, + cache: undefined, + }); + expect(config.queue).to.deep.equal({ + redisUri: "redis://localhost:6379", + }); + expect(config.cache).to.be.undefined; }); + }); - describe("will handle the cache option", () => { - it("with redisUri", () => { - const config = new BridgeConfig({ - ...DefaultConfigRoot, - cache: { - redisUri: "redis://localhost:6379" - }, - queue: undefined, - }); - expect(config.cache).to.deep.equal({ - redisUri: "redis://localhost:6379" - }); - expect(config.queue).to.be.undefined; - }); + describe("will handle the cache option", () => { + it("with redisUri", () => { + const config = new BridgeConfig({ + ...DefaultConfigRoot, + cache: { + redisUri: "redis://localhost:6379", + }, + queue: undefined, + }); + expect(config.cache).to.deep.equal({ + redisUri: "redis://localhost:6379", + }); + expect(config.queue).to.be.undefined; }); -}) \ No newline at end of file + }); +}); diff --git a/tests/config/permissions.ts b/tests/config/permissions.ts index 33487469d..2d831e72b 100644 --- a/tests/config/permissions.ts +++ b/tests/config/permissions.ts @@ -2,132 +2,184 @@ import { BridgePermissions } from "../../src/libRs"; import { expect } from "chai"; function genBridgePermissions(actor: string, service: string, level: string) { - return new BridgePermissions([ + return new BridgePermissions([ + { + actor, + services: [ { - actor, - services: [ - { - service, - level - } - ], - } - ]); + service, + level, + }, + ], + }, + ]); } describe("Config/BridgePermissions", () => { - describe("checkAction", () => { - it("will return false for an empty actor set", () => { - const bridgePermissions = new BridgePermissions([]); - expect(bridgePermissions.checkAction("@foo:bar", "empty-service", "commands")).to.be.false; - }); + describe("checkAction", () => { + it("will return false for an empty actor set", () => { + const bridgePermissions = new BridgePermissions([]); + expect( + bridgePermissions.checkAction("@foo:bar", "empty-service", "commands"), + ).to.be.false; + }); - it("will return false for an insufficent level", () => { - const bridgePermissions = genBridgePermissions('@foo:bar', 'my-service', 'login'); - expect(bridgePermissions.checkAction("@foo:bar", "my-service", "notifications")).to.be.false; - }); + it("will return false for an insufficent level", () => { + const bridgePermissions = genBridgePermissions( + "@foo:bar", + "my-service", + "login", + ); + expect( + bridgePermissions.checkAction( + "@foo:bar", + "my-service", + "notifications", + ), + ).to.be.false; + }); - it("will return false if the there are no matching services", () => { - const bridgePermissions = genBridgePermissions('@foo:bar', 'my-service', 'login'); - expect(bridgePermissions.checkAction("@foo:bar", "other-service", "login")).to.be.false; - }); + it("will return false if the there are no matching services", () => { + const bridgePermissions = genBridgePermissions( + "@foo:bar", + "my-service", + "login", + ); + expect( + bridgePermissions.checkAction("@foo:bar", "other-service", "login"), + ).to.be.false; + }); - it("will return false if the target does not match", () => { - const bridgePermissions = genBridgePermissions('@foo:bar', 'my-service', 'login'); - expect(bridgePermissions.checkAction("@foo:baz", "my-service", "login")).to.be.false; - }); + it("will return false if the target does not match", () => { + const bridgePermissions = genBridgePermissions( + "@foo:bar", + "my-service", + "login", + ); + expect(bridgePermissions.checkAction("@foo:baz", "my-service", "login")) + .to.be.false; + }); - it("will return true if there is a matching level and service", () => { - const bridgePermissions = genBridgePermissions('@foo:bar', 'my-service', 'login'); - expect(bridgePermissions.checkAction("@foo:bar", "my-service", "login")).to.be.true; - }); + it("will return true if there is a matching level and service", () => { + const bridgePermissions = genBridgePermissions( + "@foo:bar", + "my-service", + "login", + ); + expect(bridgePermissions.checkAction("@foo:bar", "my-service", "login")) + .to.be.true; + }); - it("will return true for a matching actor domain", () => { - const bridgePermissions = genBridgePermissions('bar', 'my-service', 'login'); - expect(bridgePermissions.checkAction("@foo:bar", "my-service", "login")).to.be.true; - }); + it("will return true for a matching actor domain", () => { + const bridgePermissions = genBridgePermissions( + "bar", + "my-service", + "login", + ); + expect(bridgePermissions.checkAction("@foo:bar", "my-service", "login")) + .to.be.true; + }); - it("will return true for a wildcard actor", () => { - const bridgePermissions = genBridgePermissions('*', 'my-service', 'login'); - expect(bridgePermissions.checkAction("@foo:bar", "my-service", "login")).to.be.true; - }); + it("will return true for a wildcard actor", () => { + const bridgePermissions = genBridgePermissions( + "*", + "my-service", + "login", + ); + expect(bridgePermissions.checkAction("@foo:bar", "my-service", "login")) + .to.be.true; + }); - it("will return true for a wildcard service", () => { - const bridgePermissions = genBridgePermissions('@foo:bar', '*', 'login'); - expect(bridgePermissions.checkAction("@foo:bar", "my-service", "login")).to.be.true; - }); + it("will return true for a wildcard service", () => { + const bridgePermissions = genBridgePermissions("@foo:bar", "*", "login"); + expect(bridgePermissions.checkAction("@foo:bar", "my-service", "login")) + .to.be.true; + }); - it("will return false if a user is not present in a room", () => { - const bridgePermissions = genBridgePermissions('!foo:bar', 'my-service', 'login'); - expect(bridgePermissions.checkAction("@foo:bar", "my-service", "login")).to.be.false; - }); + it("will return false if a user is not present in a room", () => { + const bridgePermissions = genBridgePermissions( + "!foo:bar", + "my-service", + "login", + ); + expect(bridgePermissions.checkAction("@foo:bar", "my-service", "login")) + .to.be.false; + }); - it("will return true if a user is present in a room", () => { - const bridgePermissions = genBridgePermissions('!foo:bar', 'my-service', 'login'); - bridgePermissions.addMemberToCache('!foo:bar', '@foo:bar'); - expect(bridgePermissions.checkAction("@foo:bar", "my-service", "login")).to.be.true; - }); + it("will return true if a user is present in a room", () => { + const bridgePermissions = genBridgePermissions( + "!foo:bar", + "my-service", + "login", + ); + bridgePermissions.addMemberToCache("!foo:bar", "@foo:bar"); + expect(bridgePermissions.checkAction("@foo:bar", "my-service", "login")) + .to.be.true; + }); - it("will fall through and return true for multiple permission sets", () => { - const bridgePermissions = new BridgePermissions([ - { - actor: "not-you", - services: [ - { - service: "my-service", - level: "login" - } - ], - }, - { - actor: "or-you", - services: [ - { - service: "my-service", - level: "login" - } - ], - }, - { - actor: "@foo:bar", - services: [ - { - service: "my-service", - level: "commands" - } - ], - } - ]); - expect(bridgePermissions.checkAction("@foo:bar", "my-service", "commands")).to.be.true; - expect(bridgePermissions.checkAction("@foo:bar", "my-service", "login")).to.be.false; - }); - }) + it("will fall through and return true for multiple permission sets", () => { + const bridgePermissions = new BridgePermissions([ + { + actor: "not-you", + services: [ + { + service: "my-service", + level: "login", + }, + ], + }, + { + actor: "or-you", + services: [ + { + service: "my-service", + level: "login", + }, + ], + }, + { + actor: "@foo:bar", + services: [ + { + service: "my-service", + level: "commands", + }, + ], + }, + ]); + expect( + bridgePermissions.checkAction("@foo:bar", "my-service", "commands"), + ).to.be.true; + expect(bridgePermissions.checkAction("@foo:bar", "my-service", "login")) + .to.be.false; + }); + }); - describe("permissionsCheckActionAny", () => { - it("will return false for an empty actor set", () => { - const bridgePermissions = new BridgePermissions([]); - expect(bridgePermissions.checkActionAny("@foo:bar", "commands")).to.be.false; - }); + describe("permissionsCheckActionAny", () => { + it("will return false for an empty actor set", () => { + const bridgePermissions = new BridgePermissions([]); + expect(bridgePermissions.checkActionAny("@foo:bar", "commands")).to.be + .false; + }); - it(`will return false for a service with an insufficent level`, () => { - const bridgePermissions = genBridgePermissions("@foo:bar", "fake-service", "commands"); - expect( - bridgePermissions.checkActionAny( - "@foo:bar", - "login" - ) - ).to.be.false; - }); - for (const actor of ["@foo:bar", "bar", "*"]) { - it(`will return true for a service defintion of '${actor}' that has a sufficent level`, () => { - const bridgePermissions = genBridgePermissions("@foo:bar", "fake-service", "commands"); - expect( - bridgePermissions.checkActionAny( - "@foo:bar", - "commands" - ) - ).to.be.true; - }); - } - }) -}) \ No newline at end of file + it(`will return false for a service with an insufficent level`, () => { + const bridgePermissions = genBridgePermissions( + "@foo:bar", + "fake-service", + "commands", + ); + expect(bridgePermissions.checkActionAny("@foo:bar", "login")).to.be.false; + }); + for (const actor of ["@foo:bar", "bar", "*"]) { + it(`will return true for a service defintion of '${actor}' that has a sufficent level`, () => { + const bridgePermissions = genBridgePermissions( + "@foo:bar", + "fake-service", + "commands", + ); + expect(bridgePermissions.checkActionAny("@foo:bar", "commands")).to.be + .true; + }); + } + }); +}); diff --git a/tests/connections/FeedTest.spec.ts b/tests/connections/FeedTest.spec.ts index 7359349ea..f8a294fc0 100644 --- a/tests/connections/FeedTest.spec.ts +++ b/tests/connections/FeedTest.spec.ts @@ -8,121 +8,138 @@ import { expect } from "chai"; const ROOM_ID = "!foo:bar"; const FEED_URL = "https://example.com/feed.xml"; const FEED_ENTRY_DEFAULTS: FeedEntry = { - feed: { - title: "Test feed", - url: FEED_URL, - }, - title: "Foo", - link: "foo/bar", - pubdate: "today!", - summary: "fibble fobble", - author: "Me!", - fetchKey: randomUUID(), -} + feed: { + title: "Test feed", + url: FEED_URL, + }, + title: "Foo", + link: "foo/bar", + pubdate: "today!", + summary: "fibble fobble", + author: "Me!", + fetchKey: randomUUID(), +}; function createFeed( - state: Partial = { } + state: Partial = {}, ): [FeedConnection, IntentMock] { - const as = AppserviceMock.create(); - const intent = as.getIntentForUserId('@webhooks:example.test'); - const connection = new FeedConnection(ROOM_ID, "foobar", { - label: undefined, - template: undefined, - notifyOnFailure: undefined, - url: FEED_URL, - ...state - }, intent); - return [connection, intent]; + const as = AppserviceMock.create(); + const intent = as.getIntentForUserId("@webhooks:example.test"); + const connection = new FeedConnection( + ROOM_ID, + "foobar", + { + label: undefined, + template: undefined, + notifyOnFailure: undefined, + url: FEED_URL, + ...state, + }, + intent, + ); + return [connection, intent]; } describe("FeedConnection", () => { - it("will handle simple feed message", async () => { - const [connection, intent] = createFeed(); - await connection.handleFeedEntry({ - ...FEED_ENTRY_DEFAULTS, - }); - const matrixEvt = intent.sentEvents[0]; - expect(matrixEvt).to.not.be.undefined; - expect(matrixEvt.roomId).to.equal(ROOM_ID); - expect(matrixEvt.content.external_url).to.equal(FEED_ENTRY_DEFAULTS.link); - expect(matrixEvt.content.body).to.equal("New post in Test feed: [Foo](foo/bar)"); + it("will handle simple feed message", async () => { + const [connection, intent] = createFeed(); + await connection.handleFeedEntry({ + ...FEED_ENTRY_DEFAULTS, }); + const matrixEvt = intent.sentEvents[0]; + expect(matrixEvt).to.not.be.undefined; + expect(matrixEvt.roomId).to.equal(ROOM_ID); + expect(matrixEvt.content.external_url).to.equal(FEED_ENTRY_DEFAULTS.link); + expect(matrixEvt.content.body).to.equal( + "New post in Test feed: [Foo](foo/bar)", + ); + }); - it("will handle simple feed message without a title and link ", async () => { - const [connection, intent] = createFeed(); - await connection.handleFeedEntry({ - ...FEED_ENTRY_DEFAULTS, - title: null, - link: null, - }); - const matrixEvt =intent.sentEvents[0]; - expect(matrixEvt).to.not.be.undefined; - expect(matrixEvt.roomId).to.equal(ROOM_ID); - expect(matrixEvt.content.external_url).to.be.undefined; - expect(matrixEvt.content.body).to.equal("New post in Test feed"); + it("will handle simple feed message without a title and link ", async () => { + const [connection, intent] = createFeed(); + await connection.handleFeedEntry({ + ...FEED_ENTRY_DEFAULTS, + title: null, + link: null, }); + const matrixEvt = intent.sentEvents[0]; + expect(matrixEvt).to.not.be.undefined; + expect(matrixEvt.roomId).to.equal(ROOM_ID); + expect(matrixEvt.content.external_url).to.be.undefined; + expect(matrixEvt.content.body).to.equal("New post in Test feed"); + }); - it("will handle simple feed message with a missing title ", async () => { - const [connection, intent] = createFeed(); - await connection.handleFeedEntry({ - ...FEED_ENTRY_DEFAULTS, - title: null, - }); - const matrixEvt =intent.sentEvents[0]; - expect(matrixEvt).to.not.be.undefined; - expect(matrixEvt.roomId).to.equal(ROOM_ID); - expect(matrixEvt.content.body).to.equal("New post in Test feed: [foo/bar](foo/bar)"); + it("will handle simple feed message with a missing title ", async () => { + const [connection, intent] = createFeed(); + await connection.handleFeedEntry({ + ...FEED_ENTRY_DEFAULTS, + title: null, }); + const matrixEvt = intent.sentEvents[0]; + expect(matrixEvt).to.not.be.undefined; + expect(matrixEvt.roomId).to.equal(ROOM_ID); + expect(matrixEvt.content.body).to.equal( + "New post in Test feed: [foo/bar](foo/bar)", + ); + }); - it("will handle simple feed message with a missing link ", async () => { - const [connection, intent] = createFeed(); - await connection.handleFeedEntry({ - ...FEED_ENTRY_DEFAULTS, - link: null, - }); - const matrixEvt =intent.sentEvents[0]; - expect(matrixEvt).to.not.be.undefined; - expect(matrixEvt.roomId).to.equal(ROOM_ID); - expect(matrixEvt.content.body).to.equal("New post in Test feed: Foo"); + it("will handle simple feed message with a missing link ", async () => { + const [connection, intent] = createFeed(); + await connection.handleFeedEntry({ + ...FEED_ENTRY_DEFAULTS, + link: null, }); + const matrixEvt = intent.sentEvents[0]; + expect(matrixEvt).to.not.be.undefined; + expect(matrixEvt.roomId).to.equal(ROOM_ID); + expect(matrixEvt.content.body).to.equal("New post in Test feed: Foo"); + }); - it("will handle simple feed message with all the template options possible ", async () => { - const [connection, intent] = createFeed({ - template: `$FEEDNAME $FEEDURL $FEEDTITLE $TITLE $LINK $AUTHOR $DATE $SUMMARY` - }); - await connection.handleFeedEntry({ - ...FEED_ENTRY_DEFAULTS, - }); - const matrixEvt =intent.sentEvents[0]; - expect(matrixEvt).to.not.be.undefined; - expect(matrixEvt.roomId).to.equal(ROOM_ID); - expect(matrixEvt.content.body).to.equal("Test feed https://example.com/feed.xml Test feed Foo [Foo](foo/bar) Me! today! fibble fobble"); + it("will handle simple feed message with all the template options possible ", async () => { + const [connection, intent] = createFeed({ + template: `$FEEDNAME $FEEDURL $FEEDTITLE $TITLE $LINK $AUTHOR $DATE $SUMMARY`, }); + await connection.handleFeedEntry({ + ...FEED_ENTRY_DEFAULTS, + }); + const matrixEvt = intent.sentEvents[0]; + expect(matrixEvt).to.not.be.undefined; + expect(matrixEvt.roomId).to.equal(ROOM_ID); + expect(matrixEvt.content.body).to.equal( + "Test feed https://example.com/feed.xml Test feed Foo [Foo](foo/bar) Me! today! fibble fobble", + ); + }); - it("will handle html in the feed summary ", async () => { - const [connection, intent] = createFeed({ - template: `$FEEDNAME $SUMMARY` - }); - await connection.handleFeedEntry({ - ...FEED_ENTRY_DEFAULTS, - summary: "

Some HTML with which should be ignored and an

" - }); - const matrixEvt =intent.sentEvents[0]; - expect(matrixEvt).to.not.be.undefined; - expect(matrixEvt.roomId).to.equal(ROOM_ID); - expect(matrixEvt.content.body).to.equal('Test feed

Some HTML with which should be ignored and an

'); + it("will handle html in the feed summary ", async () => { + const [connection, intent] = createFeed({ + template: `$FEEDNAME $SUMMARY`, + }); + await connection.handleFeedEntry({ + ...FEED_ENTRY_DEFAULTS, + summary: + "

Some HTML with which should be ignored and an

", }); + const matrixEvt = intent.sentEvents[0]; + expect(matrixEvt).to.not.be.undefined; + expect(matrixEvt.roomId).to.equal(ROOM_ID); + expect(matrixEvt.content.body).to.equal( + 'Test feed

Some HTML with which should be ignored and an

', + ); + }); - it("will handle partial html in the feed summary ", async () => { - const [connection, intent] = createFeed({ - template: `$FEEDNAME $SUMMARY` - }); - await connection.handleFeedEntry({ - ...FEED_ENTRY_DEFAULTS, - summary: "

Some HTML with which should be ignored and an

" - }); - const matrixEvt =intent.sentEvents[0]; - expect(matrixEvt).to.not.be.undefined; - expect(matrixEvt.roomId).to.equal(ROOM_ID); - expect(matrixEvt.content.body).to.equal('Test feed

Some HTML with which should be ignored and an

'); + it("will handle partial html in the feed summary ", async () => { + const [connection, intent] = createFeed({ + template: `$FEEDNAME $SUMMARY`, + }); + await connection.handleFeedEntry({ + ...FEED_ENTRY_DEFAULTS, + summary: + "

Some HTML with which should be ignored and an

", }); -}) + const matrixEvt = intent.sentEvents[0]; + expect(matrixEvt).to.not.be.undefined; + expect(matrixEvt.roomId).to.equal(ROOM_ID); + expect(matrixEvt.content.body).to.equal( + 'Test feed

Some HTML with which should be ignored and an

', + ); + }); +}); diff --git a/tests/connections/GenericHookTest.ts b/tests/connections/GenericHookTest.ts index 211341e69..bd53363fc 100644 --- a/tests/connections/GenericHookTest.ts +++ b/tests/connections/GenericHookTest.ts @@ -1,8 +1,17 @@ import { assert, expect } from "chai"; import { Appservice, Intent, MatrixError } from "matrix-bot-sdk"; -import { BridgeConfigGenericWebhooks, BridgeGenericWebhooksConfigYAML } from "../../src/config/sections"; -import { GenericHookConnection, GenericHookConnectionState } from "../../src/Connections/GenericHook"; -import { MessageSenderClient, IMatrixSendMessage } from "../../src/MatrixSender"; +import { + BridgeConfigGenericWebhooks, + BridgeGenericWebhooksConfigYAML, +} from "../../src/config/sections"; +import { + GenericHookConnection, + GenericHookConnectionState, +} from "../../src/Connections/GenericHook"; +import { + MessageSenderClient, + IMatrixSendMessage, +} from "../../src/MatrixSender"; import { LocalMQ } from "../../src/MessageQueue/LocalMQ"; import { AppserviceMock } from "../utils/AppserviceMock"; import { MemoryStorageProvider } from "../../src/Stores/MemoryStorageProvider"; @@ -13,468 +22,603 @@ import { WebhookTransformer } from "../../src/generic/transformer"; const ROOM_ID = "!foo:bar"; -const V1TFFunction = "result = `The answer to '${data.question}' is ${data.answer}`;"; -const V2TFFunction = "result = {plain: `The answer to '${data.question}' is ${data.answer}`, version: 'v2'}"; -const V2TFFunctionWithReturn = "result = {plain: `The answer to '${data.question}' is ${data.answer}`, version: 'v2'}; return;"; - -async function testSimpleWebhook(connection: GenericHookConnection, mq: LocalMQ, testValue: string) { - const webhookData = {simple: testValue}; - const messagePromise = handleMessage(mq); - await connection.onGenericHook(webhookData); - expect(await messagePromise).to.deep.equal({ - roomId: ROOM_ID, - sender: connection.getUserId(), - content: { - body: "Received webhook data:\n\n```json\n\n{\n \"simple\": \"" + testValue + "\"\n}\n\n```", - format: "org.matrix.custom.html", - formatted_body: "

Received webhook data:

{\n  \"simple\": \"" + testValue + "\"\n}

", - msgtype: "m.notice", - "uk.half-shot.hookshot.webhook_data": webhookData, - }, - type: 'm.room.message', - }); +const V1TFFunction = + "result = `The answer to '${data.question}' is ${data.answer}`;"; +const V2TFFunction = + "result = {plain: `The answer to '${data.question}' is ${data.answer}`, version: 'v2'}"; +const V2TFFunctionWithReturn = + "result = {plain: `The answer to '${data.question}' is ${data.answer}`, version: 'v2'}; return;"; + +async function testSimpleWebhook( + connection: GenericHookConnection, + mq: LocalMQ, + testValue: string, +) { + const webhookData = { simple: testValue }; + const messagePromise = handleMessage(mq); + await connection.onGenericHook(webhookData); + expect(await messagePromise).to.deep.equal({ + roomId: ROOM_ID, + sender: connection.getUserId(), + content: { + body: + 'Received webhook data:\n\n```json\n\n{\n "simple": "' + + testValue + + '"\n}\n\n```', + format: "org.matrix.custom.html", + formatted_body: + '

Received webhook data:

{\n  "simple": "' +
+        testValue +
+        '"\n}

', + msgtype: "m.notice", + "uk.half-shot.hookshot.webhook_data": webhookData, + }, + type: "m.room.message", + }); } -const ConfigDefaults = {enabled: true, urlPrefix: "https://example.com/webhookurl"}; +const ConfigDefaults = { + enabled: true, + urlPrefix: "https://example.com/webhookurl", +}; function createGenericHook( - state: Partial = { }, - config: Partial = { } + state: Partial = {}, + config: Partial = {}, ): [GenericHookConnection, LocalMQ, Appservice, Intent] { - const mq = new LocalMQ(); - mq.subscribe('*'); - const storage = new MemoryStorageProvider(); - const messageClient = new MessageSenderClient(mq); - const as = AppserviceMock.create(); - const intent = as.getIntentForUserId('@webhooks:example.test'); - const connection = new GenericHookConnection(ROOM_ID, { - name: "some-name", - transformationFunction: undefined, - waitForComplete: undefined, - ...state, - }, "foobar", "foobar", messageClient, new BridgeConfigGenericWebhooks({ - ...ConfigDefaults, - ...config, - }), as, intent, storage); - return [connection, mq, as, intent]; + const mq = new LocalMQ(); + mq.subscribe("*"); + const storage = new MemoryStorageProvider(); + const messageClient = new MessageSenderClient(mq); + const as = AppserviceMock.create(); + const intent = as.getIntentForUserId("@webhooks:example.test"); + const connection = new GenericHookConnection( + ROOM_ID, + { + name: "some-name", + transformationFunction: undefined, + waitForComplete: undefined, + ...state, + }, + "foobar", + "foobar", + messageClient, + new BridgeConfigGenericWebhooks({ + ...ConfigDefaults, + ...config, + }), + as, + intent, + storage, + ); + return [connection, mq, as, intent]; } function handleMessage(mq: LocalMQ): Promise { - return new Promise(r => mq.once('matrix.message', (msg) => { - mq.push({ - eventName: 'response.matrix.message', - messageId: msg.messageId, - sender: 'TestSender', - data: { 'eventId': '$foo:bar' }, - }); - r(msg.data as IMatrixSendMessage); - })); + return new Promise((r) => + mq.once("matrix.message", (msg) => { + mq.push({ + eventName: "response.matrix.message", + messageId: msg.messageId, + sender: "TestSender", + data: { eventId: "$foo:bar" }, + }); + r(msg.data as IMatrixSendMessage); + }), + ); } describe("GenericHookConnection", () => { - before(async () => { - await WebhookTransformer.initialiseQuickJS(); - }) - - it("will handle simple hook events", async () => { - const [connection, mq] = createGenericHook(); - await testSimpleWebhook(connection, mq, "data"); - }); - - it("will handle a hook event containing text", async () => { - const webhookData = {text: "simple-message"}; - const [connection, mq] = createGenericHook(); - const messagePromise = handleMessage(mq); - await connection.onGenericHook(webhookData); - expect(await messagePromise).to.deep.equal({ - roomId: ROOM_ID, - sender: connection.getUserId(), - content: { - body: "simple-message", - format: "org.matrix.custom.html", - formatted_body: "

simple-message

", - msgtype: "m.notice", - "uk.half-shot.hookshot.webhook_data": webhookData, - }, - type: 'm.room.message', - }); - }); - - it("will handle a hook event containing markdown", async () => { - const webhookData = {text: "**bold-message** _italic-message_"}; - const [connection, mq] = createGenericHook(); - const messagePromise = handleMessage(mq); - await connection.onGenericHook(webhookData); - expect(await messagePromise).to.deep.equal({ - roomId: ROOM_ID, - sender: connection.getUserId(), - content: { - body: "**bold-message** _italic-message_", - format: "org.matrix.custom.html", - formatted_body: "

bold-message italic-message

", - msgtype: "m.notice", - "uk.half-shot.hookshot.webhook_data": webhookData, - }, - type: 'm.room.message', - }); - }); - - it("will handle a hook event containing markdown with newlines", async () => { - const webhookData = {text: "# Oh wow\n\n`some-code`"}; - const [connection, mq] = createGenericHook(); - const messagePromise = handleMessage(mq); - await connection.onGenericHook(webhookData); - expect(await messagePromise).to.deep.equal({ - roomId: ROOM_ID, - sender: connection.getUserId(), - content: { - body: "# Oh wow\n\n`some-code`", - format: "org.matrix.custom.html", - formatted_body: "

Oh wow

\n

some-code

", - msgtype: "m.notice", - "uk.half-shot.hookshot.webhook_data": webhookData, - }, - type: 'm.room.message', - }); + before(async () => { + await WebhookTransformer.initialiseQuickJS(); + }); + + it("will handle simple hook events", async () => { + const [connection, mq] = createGenericHook(); + await testSimpleWebhook(connection, mq, "data"); + }); + + it("will handle a hook event containing text", async () => { + const webhookData = { text: "simple-message" }; + const [connection, mq] = createGenericHook(); + const messagePromise = handleMessage(mq); + await connection.onGenericHook(webhookData); + expect(await messagePromise).to.deep.equal({ + roomId: ROOM_ID, + sender: connection.getUserId(), + content: { + body: "simple-message", + format: "org.matrix.custom.html", + formatted_body: "

simple-message

", + msgtype: "m.notice", + "uk.half-shot.hookshot.webhook_data": webhookData, + }, + type: "m.room.message", }); + }); - it("will handle a hook event containing html", async () => { - const webhookData = {text: "simple-message", html: "simple-message"}; - const [connection, mq] = createGenericHook(); - const messagePromise = handleMessage(mq); - await connection.onGenericHook(webhookData); - expect(await messagePromise).to.deep.equal({ - roomId: ROOM_ID, - sender: connection.getUserId(), - content: { - body: "simple-message", - format: "org.matrix.custom.html", - formatted_body: "simple-message", - msgtype: "m.notice", - "uk.half-shot.hookshot.webhook_data": webhookData, - }, - type: 'm.room.message', - }); + it("will handle a hook event containing markdown", async () => { + const webhookData = { text: "**bold-message** _italic-message_" }; + const [connection, mq] = createGenericHook(); + const messagePromise = handleMessage(mq); + await connection.onGenericHook(webhookData); + expect(await messagePromise).to.deep.equal({ + roomId: ROOM_ID, + sender: connection.getUserId(), + content: { + body: "**bold-message** _italic-message_", + format: "org.matrix.custom.html", + formatted_body: + "

bold-message italic-message

", + msgtype: "m.notice", + "uk.half-shot.hookshot.webhook_data": webhookData, + }, + type: "m.room.message", }); + }); - it("will handle a hook event containing a username", async () => { - const webhookData = {username: "Bobs-integration", type: 42}; - const [connection, mq] = createGenericHook(); - const messagePromise = handleMessage(mq); - await connection.onGenericHook(webhookData); - expect(await messagePromise).to.deep.equal({ - roomId: ROOM_ID, - sender: connection.getUserId(), - content: { - body: "**Bobs-integration**: Received webhook data:\n\n```json\n\n{\n \"username\": \"Bobs-integration\",\n \"type\": 42\n}\n\n```", - format: "org.matrix.custom.html", - formatted_body: "Bobs-integration:

Received webhook data:

{\n  \"username\": \"Bobs-integration\",\n  \"type\": 42\n}

", - msgtype: "m.notice", - "uk.half-shot.hookshot.webhook_data": webhookData, - }, - type: 'm.room.message', - }); + it("will handle a hook event containing markdown with newlines", async () => { + const webhookData = { text: "# Oh wow\n\n`some-code`" }; + const [connection, mq] = createGenericHook(); + const messagePromise = handleMessage(mq); + await connection.onGenericHook(webhookData); + expect(await messagePromise).to.deep.equal({ + roomId: ROOM_ID, + sender: connection.getUserId(), + content: { + body: "# Oh wow\n\n`some-code`", + format: "org.matrix.custom.html", + formatted_body: "

Oh wow

\n

some-code

", + msgtype: "m.notice", + "uk.half-shot.hookshot.webhook_data": webhookData, + }, + type: "m.room.message", }); - - it("will handle a hook event with a v1 transformation function", async () => { - const webhookData = {question: 'What is the meaning of life?', answer: 42}; - const [connection, mq] = createGenericHook({name: 'test', transformationFunction: V1TFFunction}, { - allowJsTransformationFunctions: true, - } - ); - const messagePromise = handleMessage(mq); - await connection.onGenericHook(webhookData); - expect(await messagePromise).to.deep.equal({ - roomId: ROOM_ID, - sender: connection.getUserId(), - content: { - body: "Received webhook: The answer to 'What is the meaning of life?' is 42", - format: "org.matrix.custom.html", - formatted_body: "

Received webhook: The answer to 'What is the meaning of life?' is 42

", - msgtype: "m.notice", - "uk.half-shot.hookshot.webhook_data": webhookData, - }, - type: 'm.room.message', - }); + }); + + it("will handle a hook event containing html", async () => { + const webhookData = { + text: "simple-message", + html: "simple-message", + }; + const [connection, mq] = createGenericHook(); + const messagePromise = handleMessage(mq); + await connection.onGenericHook(webhookData); + expect(await messagePromise).to.deep.equal({ + roomId: ROOM_ID, + sender: connection.getUserId(), + content: { + body: "simple-message", + format: "org.matrix.custom.html", + formatted_body: "simple-message", + msgtype: "m.notice", + "uk.half-shot.hookshot.webhook_data": webhookData, + }, + type: "m.room.message", }); + }); - it("will handle a hook event with a v2 transformation function", async () => { - const webhookData = {question: 'What is the meaning of life?', answer: 42}; - const [connection, mq] = createGenericHook({name: 'test', transformationFunction: V2TFFunction}, { - allowJsTransformationFunctions: true, - } - ); - const messagePromise = handleMessage(mq); - await connection.onGenericHook(webhookData); - expect(await messagePromise).to.deep.equal({ - roomId: ROOM_ID, - sender: connection.getUserId(), - content: { - body: "The answer to 'What is the meaning of life?' is 42", - format: "org.matrix.custom.html", - formatted_body: "

The answer to 'What is the meaning of life?' is 42

", - msgtype: "m.notice", - "uk.half-shot.hookshot.webhook_data": webhookData, - }, - type: 'm.room.message', - }); + it("will handle a hook event containing a username", async () => { + const webhookData = { username: "Bobs-integration", type: 42 }; + const [connection, mq] = createGenericHook(); + const messagePromise = handleMessage(mq); + await connection.onGenericHook(webhookData); + expect(await messagePromise).to.deep.equal({ + roomId: ROOM_ID, + sender: connection.getUserId(), + content: { + body: '**Bobs-integration**: Received webhook data:\n\n```json\n\n{\n "username": "Bobs-integration",\n "type": 42\n}\n\n```', + format: "org.matrix.custom.html", + formatted_body: + 'Bobs-integration:

Received webhook data:

{\n  "username": "Bobs-integration",\n  "type": 42\n}

', + msgtype: "m.notice", + "uk.half-shot.hookshot.webhook_data": webhookData, + }, + type: "m.room.message", }); - - it("will handle a hook event with mentions", async () => { - const webhookData = {}; - const [connection, mq] = createGenericHook({name: 'test', transformationFunction: - `result = {plain: "Test test", mentions: { room: true, user_ids: ["@foo:bar"], ignored_data: true}, version: 'v2'}`}, { - allowJsTransformationFunctions: true, - } - ); - const messagePromise = handleMessage(mq); - await connection.onGenericHook(webhookData); - expect(await messagePromise).to.deep.equal({ - roomId: ROOM_ID, - sender: connection.getUserId(), - content: { - body: "Test test", - formatted_body: "

Test test

", - format: "org.matrix.custom.html", - msgtype: "m.notice", - "m.mentions": { - room: true, - user_ids: ["@foo:bar"], - }, - "uk.half-shot.hookshot.webhook_data": {}, - }, - type: 'm.room.message', - }); + }); + + it("will handle a hook event with a v1 transformation function", async () => { + const webhookData = { + question: "What is the meaning of life?", + answer: 42, + }; + const [connection, mq] = createGenericHook( + { name: "test", transformationFunction: V1TFFunction }, + { + allowJsTransformationFunctions: true, + }, + ); + const messagePromise = handleMessage(mq); + await connection.onGenericHook(webhookData); + expect(await messagePromise).to.deep.equal({ + roomId: ROOM_ID, + sender: connection.getUserId(), + content: { + body: "Received webhook: The answer to 'What is the meaning of life?' is 42", + format: "org.matrix.custom.html", + formatted_body: + "

Received webhook: The answer to 'What is the meaning of life?' is 42

", + msgtype: "m.notice", + "uk.half-shot.hookshot.webhook_data": webhookData, + }, + type: "m.room.message", }); - - - it("will handle a hook event with a top-level return", async () => { - const webhookData = {question: 'What is the meaning of life?', answer: 42}; - const [connection, mq] = createGenericHook({name: 'test', transformationFunction: V2TFFunctionWithReturn}, { - allowJsTransformationFunctions: true, - } - ); - const messagePromise = handleMessage(mq); - await connection.onGenericHook(webhookData); - expect(await messagePromise).to.deep.equal({ - roomId: ROOM_ID, - sender: connection.getUserId(), - content: { - body: "The answer to 'What is the meaning of life?' is 42", - format: "org.matrix.custom.html", - formatted_body: "

The answer to 'What is the meaning of life?' is 42

", - msgtype: "m.notice", - "uk.half-shot.hookshot.webhook_data": webhookData, - }, - type: 'm.room.message', - }); + }); + + it("will handle a hook event with a v2 transformation function", async () => { + const webhookData = { + question: "What is the meaning of life?", + answer: 42, + }; + const [connection, mq] = createGenericHook( + { name: "test", transformationFunction: V2TFFunction }, + { + allowJsTransformationFunctions: true, + }, + ); + const messagePromise = handleMessage(mq); + await connection.onGenericHook(webhookData); + expect(await messagePromise).to.deep.equal({ + roomId: ROOM_ID, + sender: connection.getUserId(), + content: { + body: "The answer to 'What is the meaning of life?' is 42", + format: "org.matrix.custom.html", + formatted_body: + "

The answer to 'What is the meaning of life?' is 42

", + msgtype: "m.notice", + "uk.half-shot.hookshot.webhook_data": webhookData, + }, + type: "m.room.message", }); - - it("will fail to handle a webhook with an invalid script", async () => { - const webhookData = {question: 'What is the meaning of life?', answer: 42}; - const [connection, mq] = createGenericHook({name: 'test', transformationFunction: "bibble bobble"}, { - allowJsTransformationFunctions: true, - } - ); - const messagePromise = handleMessage(mq); - await connection.onGenericHook(webhookData); - expect(await messagePromise).to.deep.equal({ - roomId: ROOM_ID, - sender: connection.getUserId(), - content: { - body: "Webhook received but failed to process via transformation function", - format: "org.matrix.custom.html", - formatted_body: "

Webhook received but failed to process via transformation function

", - msgtype: "m.notice", - "uk.half-shot.hookshot.webhook_data": webhookData, - }, - type: 'm.room.message', - }); + }); + + it("will handle a hook event with mentions", async () => { + const webhookData = {}; + const [connection, mq] = createGenericHook( + { + name: "test", + transformationFunction: `result = {plain: "Test test", mentions: { room: true, user_ids: ["@foo:bar"], ignored_data: true}, version: 'v2'}`, + }, + { + allowJsTransformationFunctions: true, + }, + ); + const messagePromise = handleMessage(mq); + await connection.onGenericHook(webhookData); + expect(await messagePromise).to.deep.equal({ + roomId: ROOM_ID, + sender: connection.getUserId(), + content: { + body: "Test test", + formatted_body: "

Test test

", + format: "org.matrix.custom.html", + msgtype: "m.notice", + "m.mentions": { + room: true, + user_ids: ["@foo:bar"], + }, + "uk.half-shot.hookshot.webhook_data": {}, + }, + type: "m.room.message", }); - - it("will handle a message containing floats", async () => { - const [connection, mq] = createGenericHook(); - let messagePromise = handleMessage(mq); - await connection.onGenericHook({ simple: 1.2345 }); - let message = await messagePromise; - expect(message.roomId).to.equal(ROOM_ID); - expect(message.sender).to.equal(connection.getUserId()); - expect(message.content["uk.half-shot.hookshot.webhook_data"]).to.deep.equal({ simple: "1.2345" }); - - messagePromise = handleMessage(mq); - await connection.onGenericHook({ - a: { - deep: { - object: { - containing: 1.2345 - } - } - } - }); - message = await messagePromise; - expect(message.roomId).to.equal(ROOM_ID); - expect(message.sender).to.equal(connection.getUserId()); - expect(message.content["uk.half-shot.hookshot.webhook_data"]).to.deep.equal({ a: { deep: { object: { containing: "1.2345" }}} }); - - messagePromise = handleMessage(mq); - await connection.onGenericHook({ - an_array_of: [1.2345, 6.789], - floats: true, - }); - message = await messagePromise; - expect(message.roomId).to.equal(ROOM_ID); - expect(message.sender).to.equal(connection.getUserId()); - expect(message.content["uk.half-shot.hookshot.webhook_data"]).to.deep.equal({ - an_array_of: ["1.2345", "6.789"], - floats: true, - }); + }); + + it("will handle a hook event with a top-level return", async () => { + const webhookData = { + question: "What is the meaning of life?", + answer: 42, + }; + const [connection, mq] = createGenericHook( + { name: "test", transformationFunction: V2TFFunctionWithReturn }, + { + allowJsTransformationFunctions: true, + }, + ); + const messagePromise = handleMessage(mq); + await connection.onGenericHook(webhookData); + expect(await messagePromise).to.deep.equal({ + roomId: ROOM_ID, + sender: connection.getUserId(), + content: { + body: "The answer to 'What is the meaning of life?' is 42", + format: "org.matrix.custom.html", + formatted_body: + "

The answer to 'What is the meaning of life?' is 42

", + msgtype: "m.notice", + "uk.half-shot.hookshot.webhook_data": webhookData, + }, + type: "m.room.message", }); - - it("should handle simple hook events with user Id prefix", async () => { - const [connection, mq] = createGenericHook(undefined, { userIdPrefix: "_webhooks_"}); - await testSimpleWebhook(connection, mq, "data1"); - // regression test covering https://github.com/matrix-org/matrix-hookshot/issues/625 - await testSimpleWebhook(connection, mq, "data2"); + }); + + it("will fail to handle a webhook with an invalid script", async () => { + const webhookData = { + question: "What is the meaning of life?", + answer: 42, + }; + const [connection, mq] = createGenericHook( + { name: "test", transformationFunction: "bibble bobble" }, + { + allowJsTransformationFunctions: true, + }, + ); + const messagePromise = handleMessage(mq); + await connection.onGenericHook(webhookData); + expect(await messagePromise).to.deep.equal({ + roomId: ROOM_ID, + sender: connection.getUserId(), + content: { + body: "Webhook received but failed to process via transformation function", + format: "org.matrix.custom.html", + formatted_body: + "

Webhook received but failed to process via transformation function

", + msgtype: "m.notice", + "uk.half-shot.hookshot.webhook_data": webhookData, + }, + type: "m.room.message", }); - - it("should invite a configured puppet to the room if it's unable to join", async () => { - const senderUserId = "@_webhooks_some-name:example.test"; - const [connection, mq, as, botIntent] = createGenericHook(undefined, { userIdPrefix: "_webhooks_"}); - const intent = as.getIntentForUserId(senderUserId); - let hasInvited = false; - - // This should fail the first time, then pass once we've tried to invite the user - intent.ensureJoined = async (roomId: string) => { - if (hasInvited) { - return roomId; - } - expect(roomId).to.equal(ROOM_ID); - throw new MatrixError({ errcode: "M_FORBIDDEN", error: "Test forced error"}, 401, { }) - }; - - // This should invite the puppet user. - botIntent.underlyingClient.inviteUser = async (userId: string, roomId: string) => { - expect(userId).to.equal(senderUserId); - expect(roomId).to.equal(ROOM_ID); - hasInvited = true; - } - - // regression test covering https://github.com/matrix-org/matrix-hookshot/issues/625 - await testSimpleWebhook(connection, mq, "data1"); - // Only pass if we've actually bothered to invite the bot. - expect(hasInvited).to.be.true; + }); + + it("will handle a message containing floats", async () => { + const [connection, mq] = createGenericHook(); + let messagePromise = handleMessage(mq); + await connection.onGenericHook({ simple: 1.2345 }); + let message = await messagePromise; + expect(message.roomId).to.equal(ROOM_ID); + expect(message.sender).to.equal(connection.getUserId()); + expect(message.content["uk.half-shot.hookshot.webhook_data"]).to.deep.equal( + { simple: "1.2345" }, + ); + + messagePromise = handleMessage(mq); + await connection.onGenericHook({ + a: { + deep: { + object: { + containing: 1.2345, + }, + }, + }, }); - - it("should fail a message if a bot cannot join a room", async () => { - const senderUserId = "@_webhooks_some-name:example.test"; - const [connection, mq, as] = createGenericHook(undefined, { userIdPrefix: "_webhooks_"}); - const intent = as.getIntentForUserId(senderUserId); - - // This should fail the first time, then pass once we've tried to invite the user - intent.ensureJoined = () => { - throw new MatrixError({ errcode: "FORCED_FAILURE", error: "Test forced error"}, 500, { }) - }; - try { - // regression test covering https://github.com/matrix-org/matrix-hookshot/issues/625 - await testSimpleWebhook(connection, mq, "data1"); - } catch (ex) { - expect(ex.message).to.contain(`Could not ensure that ${senderUserId} is in ${ROOM_ID}`) - } + message = await messagePromise; + expect(message.roomId).to.equal(ROOM_ID); + expect(message.sender).to.equal(connection.getUserId()); + expect(message.content["uk.half-shot.hookshot.webhook_data"]).to.deep.equal( + { a: { deep: { object: { containing: "1.2345" } } } }, + ); + + messagePromise = handleMessage(mq); + await connection.onGenericHook({ + an_array_of: [1.2345, 6.789], + floats: true, }); - - it('should fail to create a hook with an invalid expiry time', () => { - for (const expirationDate of [0, 1, -1, false, true, {}, [], new Date(), ""]) { - expect(() => GenericHookConnection.validateState({ - name: "beep", - expirationDate, - })).to.throw("'expirationDate' must be a non-empty string"); - } - for (const expirationDate of ["no", "\0", "true", " 2024", "2024-01-01", "15:56", "2024-01-01 15:16"]) { - expect(() => GenericHookConnection.validateState({ - name: "beep", - expirationDate, - })).to.throw("'expirationDate' must be a valid date"); - } + message = await messagePromise; + expect(message.roomId).to.equal(ROOM_ID); + expect(message.sender).to.equal(connection.getUserId()); + expect(message.content["uk.half-shot.hookshot.webhook_data"]).to.deep.equal( + { + an_array_of: ["1.2345", "6.789"], + floats: true, + }, + ); + }); + + it("should handle simple hook events with user Id prefix", async () => { + const [connection, mq] = createGenericHook(undefined, { + userIdPrefix: "_webhooks_", }); - - it('should fail to create a hook with a too short expiry time', async () => { - const as = AppserviceMock.create(); - try { - await GenericHookConnection.provisionConnection(ROOM_ID, "@some:user", { - name: "foo", - expirationDate: new Date().toISOString(), - }, { - as: as, - intent: as.botIntent, - config: { generic: new BridgeConfigGenericWebhooks(ConfigDefaults) } as unknown as BridgeConfig, - messageClient: new MessageSenderClient(new LocalMQ()), - storage: new MemoryStorageProvider(), - } as unknown as ProvisionConnectionOpts); - assert.fail('Expected function to throw'); - } catch (ex) { - expect(ex.message).to.contain('Expiration date must at least be a hour in the future'); - } + await testSimpleWebhook(connection, mq, "data1"); + // regression test covering https://github.com/matrix-org/matrix-hookshot/issues/625 + await testSimpleWebhook(connection, mq, "data2"); + }); + + it("should invite a configured puppet to the room if it's unable to join", async () => { + const senderUserId = "@_webhooks_some-name:example.test"; + const [connection, mq, as, botIntent] = createGenericHook(undefined, { + userIdPrefix: "_webhooks_", }); - - it('should fail to create a hook with a too long expiry time', async () => { - const as = AppserviceMock.create(); - try { - await GenericHookConnection.provisionConnection(ROOM_ID, "@some:user", { - name: "foo", - expirationDate: add(new Date(), { days: 1, seconds: 1}).toISOString(), - }, { - as: as, - intent: as.botIntent, - config: { generic: new BridgeConfigGenericWebhooks({ - ...ConfigDefaults, - maxExpiryTime: '1d' - }) } as unknown as BridgeConfig, - messageClient: new MessageSenderClient(new LocalMQ()), - storage: new MemoryStorageProvider(), - } as unknown as ProvisionConnectionOpts); - assert.fail('Expected function to throw'); - } catch (ex) { - expect(ex.message).to.contain('Expiration date cannot exceed the configured max expiry time'); - } + const intent = as.getIntentForUserId(senderUserId); + let hasInvited = false; + + // This should fail the first time, then pass once we've tried to invite the user + intent.ensureJoined = async (roomId: string) => { + if (hasInvited) { + return roomId; + } + expect(roomId).to.equal(ROOM_ID); + throw new MatrixError( + { errcode: "M_FORBIDDEN", error: "Test forced error" }, + 401, + {}, + ); + }; + + // This should invite the puppet user. + botIntent.underlyingClient.inviteUser = async ( + userId: string, + roomId: string, + ) => { + expect(userId).to.equal(senderUserId); + expect(roomId).to.equal(ROOM_ID); + hasInvited = true; + }; + + // regression test covering https://github.com/matrix-org/matrix-hookshot/issues/625 + await testSimpleWebhook(connection, mq, "data1"); + // Only pass if we've actually bothered to invite the bot. + expect(hasInvited).to.be.true; + }); + + it("should fail a message if a bot cannot join a room", async () => { + const senderUserId = "@_webhooks_some-name:example.test"; + const [connection, mq, as] = createGenericHook(undefined, { + userIdPrefix: "_webhooks_", }); - - it('should fail to create a hook without an expiry time when required by config', async () => { - const as = AppserviceMock.create(); - try { - await GenericHookConnection.provisionConnection(ROOM_ID, "@some:user", { - name: "foo", - }, { - as: as, - intent: as.botIntent, - config: { generic: new BridgeConfigGenericWebhooks({ - ...ConfigDefaults, - maxExpiryTime: '1d', - requireExpiryTime: true, - }) } as unknown as BridgeConfig, - messageClient: new MessageSenderClient(new LocalMQ()), - storage: new MemoryStorageProvider(), - } as unknown as ProvisionConnectionOpts); - assert.fail('Expected function to throw'); - } catch (ex) { - expect(ex.message).to.contain('Expiration date must be set'); - } + const intent = as.getIntentForUserId(senderUserId); + + // This should fail the first time, then pass once we've tried to invite the user + intent.ensureJoined = () => { + throw new MatrixError( + { errcode: "FORCED_FAILURE", error: "Test forced error" }, + 500, + {}, + ); + }; + try { + // regression test covering https://github.com/matrix-org/matrix-hookshot/issues/625 + await testSimpleWebhook(connection, mq, "data1"); + } catch (ex) { + expect(ex.message).to.contain( + `Could not ensure that ${senderUserId} is in ${ROOM_ID}`, + ); + } + }); + + it("should fail to create a hook with an invalid expiry time", () => { + for (const expirationDate of [ + 0, + 1, + -1, + false, + true, + {}, + [], + new Date(), + "", + ]) { + expect(() => + GenericHookConnection.validateState({ + name: "beep", + expirationDate, + }), + ).to.throw("'expirationDate' must be a non-empty string"); + } + for (const expirationDate of [ + "no", + "\0", + "true", + " 2024", + "2024-01-01", + "15:56", + "2024-01-01 15:16", + ]) { + expect(() => + GenericHookConnection.validateState({ + name: "beep", + expirationDate, + }), + ).to.throw("'expirationDate' must be a valid date"); + } + }); + + it("should fail to create a hook with a too short expiry time", async () => { + const as = AppserviceMock.create(); + try { + await GenericHookConnection.provisionConnection( + ROOM_ID, + "@some:user", + { + name: "foo", + expirationDate: new Date().toISOString(), + }, + { + as: as, + intent: as.botIntent, + config: { + generic: new BridgeConfigGenericWebhooks(ConfigDefaults), + } as unknown as BridgeConfig, + messageClient: new MessageSenderClient(new LocalMQ()), + storage: new MemoryStorageProvider(), + } as unknown as ProvisionConnectionOpts, + ); + assert.fail("Expected function to throw"); + } catch (ex) { + expect(ex.message).to.contain( + "Expiration date must at least be a hour in the future", + ); + } + }); + + it("should fail to create a hook with a too long expiry time", async () => { + const as = AppserviceMock.create(); + try { + await GenericHookConnection.provisionConnection( + ROOM_ID, + "@some:user", + { + name: "foo", + expirationDate: add(new Date(), { + days: 1, + seconds: 1, + }).toISOString(), + }, + { + as: as, + intent: as.botIntent, + config: { + generic: new BridgeConfigGenericWebhooks({ + ...ConfigDefaults, + maxExpiryTime: "1d", + }), + } as unknown as BridgeConfig, + messageClient: new MessageSenderClient(new LocalMQ()), + storage: new MemoryStorageProvider(), + } as unknown as ProvisionConnectionOpts, + ); + assert.fail("Expected function to throw"); + } catch (ex) { + expect(ex.message).to.contain( + "Expiration date cannot exceed the configured max expiry time", + ); + } + }); + + it("should fail to create a hook without an expiry time when required by config", async () => { + const as = AppserviceMock.create(); + try { + await GenericHookConnection.provisionConnection( + ROOM_ID, + "@some:user", + { + name: "foo", + }, + { + as: as, + intent: as.botIntent, + config: { + generic: new BridgeConfigGenericWebhooks({ + ...ConfigDefaults, + maxExpiryTime: "1d", + requireExpiryTime: true, + }), + } as unknown as BridgeConfig, + messageClient: new MessageSenderClient(new LocalMQ()), + storage: new MemoryStorageProvider(), + } as unknown as ProvisionConnectionOpts, + ); + assert.fail("Expected function to throw"); + } catch (ex) { + expect(ex.message).to.contain("Expiration date must be set"); + } + }); + + it("should create a hook and handle a request within the expiry time", async () => { + const [connection, mq] = createGenericHook({ + expirationDate: add(new Date(), { seconds: 30 }).toISOString(), }); + await testSimpleWebhook(connection, mq, "test"); + }); - it('should create a hook and handle a request within the expiry time', async () => { - const [connection, mq] = createGenericHook({ - expirationDate: add(new Date(), { seconds: 30 }).toISOString(), - }); - await testSimpleWebhook(connection, mq, "test"); + it("should reject requests to an expired hook", async () => { + const [connection] = createGenericHook({ + expirationDate: new Date().toISOString(), }); - - it('should reject requests to an expired hook', async () => { - const [connection] = createGenericHook({ - expirationDate: new Date().toISOString(), - }); - expect(await connection.onGenericHook({test: "value"})).to.deep.equal({ - error: "This hook has expired", - statusCode: 404, - successful: false, - }); + expect(await connection.onGenericHook({ test: "value" })).to.deep.equal({ + error: "This hook has expired", + statusCode: 404, + successful: false, }); -}) + }); +}); diff --git a/tests/connections/GithubRepoTest.ts b/tests/connections/GithubRepoTest.ts index 8c8a0e0dd..050efa407 100644 --- a/tests/connections/GithubRepoTest.ts +++ b/tests/connections/GithubRepoTest.ts @@ -1,4 +1,7 @@ -import { GitHubRepoConnection, GitHubRepoConnectionState } from "../../src/Connections/GithubRepo" +import { + GitHubRepoConnection, + GitHubRepoConnectionState, +} from "../../src/Connections/GithubRepo"; import { GithubInstance } from "../../src/github/GithubInstance"; import { createMessageQueue } from "../../src/MessageQueue"; import { UserTokenStore } from "../../src/tokens/UserTokenStore"; @@ -11,200 +14,243 @@ import { IntentMock } from "../utils/IntentMock"; const ROOM_ID = "!foo:bar"; const GITHUB_ORG_REPO = { - org: "a-fake-org", - repo: "a-fake-repo", + org: "a-fake-org", + repo: "a-fake-repo", }; const GITHUB_ISSUE = { - id: 1234, - number: 1234, - user: { - login: "alice" - }, - html_url: `https://github.com/${GITHUB_ORG_REPO.org}/${GITHUB_ORG_REPO.repo}/issues/1234`, - title: "My issue", - assignees: [] + id: 1234, + number: 1234, + user: { + login: "alice", + }, + html_url: `https://github.com/${GITHUB_ORG_REPO.org}/${GITHUB_ORG_REPO.repo}/issues/1234`, + title: "My issue", + assignees: [], }; const GITHUB_ISSUE_CREATED_PAYLOAD = { - action: "opened", - issue: GITHUB_ISSUE, - repository: { - full_name: `${GITHUB_ORG_REPO.org}/${GITHUB_ORG_REPO.repo}`, - id: 1234, - html_url: `https://github.com/${GITHUB_ORG_REPO.org}/${GITHUB_ORG_REPO.repo}`, - } + action: "opened", + issue: GITHUB_ISSUE, + repository: { + full_name: `${GITHUB_ORG_REPO.org}/${GITHUB_ORG_REPO.repo}`, + id: 1234, + html_url: `https://github.com/${GITHUB_ORG_REPO.org}/${GITHUB_ORG_REPO.repo}`, + }, }; -function createConnection(state: Record = {}, isExistingState=false) { - const mq = createMessageQueue(); - mq.subscribe('*'); - const as = AppserviceMock.create(); - const intent = as.getIntentForUserId('@github:example.test'); - const githubInstance = new GithubInstance("foo", "bar", new URL("https://github.com")); - const connection = new GitHubRepoConnection( - ROOM_ID, - as, - intent, - GitHubRepoConnection.validateState({ - org: "a-fake-org", - repo: "a-fake-repo", - ...state, - }, isExistingState), - {} as UserTokenStore, - "state_key", - githubInstance, - // Default config always contains GitHub - DefaultConfig.github! - ); - return {connection, intent: intent as IntentMock}; +function createConnection( + state: Record = {}, + isExistingState = false, +) { + const mq = createMessageQueue(); + mq.subscribe("*"); + const as = AppserviceMock.create(); + const intent = as.getIntentForUserId("@github:example.test"); + const githubInstance = new GithubInstance( + "foo", + "bar", + new URL("https://github.com"), + ); + const connection = new GitHubRepoConnection( + ROOM_ID, + as, + intent, + GitHubRepoConnection.validateState( + { + org: "a-fake-org", + repo: "a-fake-repo", + ...state, + }, + isExistingState, + ), + {} as UserTokenStore, + "state_key", + githubInstance, + // Default config always contains GitHub + DefaultConfig.github!, + ); + return { connection, intent: intent as IntentMock }; } describe("GitHubRepoConnection", () => { - describe("validateState", () => { - it("can validate a completes state config", () => { - GitHubRepoConnection.validateState({ - org: "foo", - repo: "bar", - enableHooks: ["issue", "pull_request", "release"], - commandPrefix: "!foo", - showIssueRoomLink: true, - prDiff: { - enabled: true, - maxLines: 55 - }, - includingLabels: ["this", "and", "that"], - excludingLabels: ["not", "those"], - hotlinkIssues: { - prefix: "foo" - }, - newIssue: { - labels: ["this", "and", "that"] - } - } as GitHubRepoConnectionState as unknown as Record); - }); + describe("validateState", () => { + it("can validate a completes state config", () => { + GitHubRepoConnection.validateState({ + org: "foo", + repo: "bar", + enableHooks: ["issue", "pull_request", "release"], + commandPrefix: "!foo", + showIssueRoomLink: true, + prDiff: { + enabled: true, + maxLines: 55, + }, + includingLabels: ["this", "and", "that"], + excludingLabels: ["not", "those"], + hotlinkIssues: { + prefix: "foo", + }, + newIssue: { + labels: ["this", "and", "that"], + }, + } as GitHubRepoConnectionState as unknown as Record); + }); - it("will convert ignoredHooks for existing state", () => { - const state = GitHubRepoConnection.validateState({ - org: "foo", - repo: "bar", - ignoreHooks: ["issue"], - enableHooks: ["issue", "pull_request", "release"], - commandPrefix: "!foo", - } as GitHubRepoConnectionState as unknown as Record, true); - expect(state.enableHooks).to.not.contain('issue'); - }); + it("will convert ignoredHooks for existing state", () => { + const state = GitHubRepoConnection.validateState( + { + org: "foo", + repo: "bar", + ignoreHooks: ["issue"], + enableHooks: ["issue", "pull_request", "release"], + commandPrefix: "!foo", + } as GitHubRepoConnectionState as unknown as Record, + true, + ); + expect(state.enableHooks).to.not.contain("issue"); + }); - it("will disallow invalid state", () => { - try { - GitHubRepoConnection.validateState({ - org: "foo", - repo: false, - }); - } catch (ex) { - if (ex instanceof ValidatorApiError === false || ex.errcode !== ErrCode.BadValue) { - throw ex; - } - } - }); + it("will disallow invalid state", () => { + try { + GitHubRepoConnection.validateState({ + org: "foo", + repo: false, + }); + } catch (ex) { + if ( + ex instanceof ValidatorApiError === false || + ex.errcode !== ErrCode.BadValue + ) { + throw ex; + } + } + }); - it("will disallow enabledHooks to contains invalid enums if this is new state", () => { - try { - GitHubRepoConnection.validateState({ - org: "foo", - repo: "bar", - enabledHooks: ["not-real"], - }, false); - } catch (ex) { - if (ex instanceof ApiError === false || ex.errcode !== ErrCode.BadValue) { - throw ex; - } - } - }); + it("will disallow enabledHooks to contains invalid enums if this is new state", () => { + try { + GitHubRepoConnection.validateState( + { + org: "foo", + repo: "bar", + enabledHooks: ["not-real"], + }, + false, + ); + } catch (ex) { + if ( + ex instanceof ApiError === false || + ex.errcode !== ErrCode.BadValue + ) { + throw ex; + } + } + }); - it("will allow enabledHooks to contains invalid enums if this is old state", () => { - GitHubRepoConnection.validateState({ - org: "foo", - repo: "bar", - enabledHooks: ["not-real"], - }, true); - }); - }); + it("will allow enabledHooks to contains invalid enums if this is old state", () => { + GitHubRepoConnection.validateState( + { + org: "foo", + repo: "bar", + enabledHooks: ["not-real"], + }, + true, + ); + }); + }); - describe("onIssueCreated", () => { - it("will handle a simple issue", async () => { - const { connection, intent } = createConnection(); - await connection.onIssueCreated(GITHUB_ISSUE_CREATED_PAYLOAD as never); - // Statement text. - intent.expectEventBodyContains('**alice** created new issue', 0); - intent.expectEventBodyContains(GITHUB_ISSUE_CREATED_PAYLOAD.issue.html_url, 0); - intent.expectEventBodyContains(GITHUB_ISSUE_CREATED_PAYLOAD.issue.title, 0); - }); + describe("onIssueCreated", () => { + it("will handle a simple issue", async () => { + const { connection, intent } = createConnection(); + await connection.onIssueCreated(GITHUB_ISSUE_CREATED_PAYLOAD as never); + // Statement text. + intent.expectEventBodyContains("**alice** created new issue", 0); + intent.expectEventBodyContains( + GITHUB_ISSUE_CREATED_PAYLOAD.issue.html_url, + 0, + ); + intent.expectEventBodyContains( + GITHUB_ISSUE_CREATED_PAYLOAD.issue.title, + 0, + ); + }); - it("will handle assignees on issue creation", async () => { - const { connection, intent } = createConnection(); - await connection.onIssueCreated({ - ...GITHUB_ISSUE_CREATED_PAYLOAD, - issue: { - ...GITHUB_ISSUE, - assignees: [{ login: 'alice'}, { login: 'bob'}] - } - } as never); - // Statement text. - intent.expectEventBodyContains('**alice** created new issue', 0); - intent.expectEventBodyContains('"My issue" assigned to alice, bob', 0); - intent.expectEventBodyContains(GITHUB_ISSUE_CREATED_PAYLOAD.issue.html_url, 0); - intent.expectEventBodyContains(GITHUB_ISSUE_CREATED_PAYLOAD.issue.title, 0); - }); + it("will handle assignees on issue creation", async () => { + const { connection, intent } = createConnection(); + await connection.onIssueCreated({ + ...GITHUB_ISSUE_CREATED_PAYLOAD, + issue: { + ...GITHUB_ISSUE, + assignees: [{ login: "alice" }, { login: "bob" }], + }, + } as never); + // Statement text. + intent.expectEventBodyContains("**alice** created new issue", 0); + intent.expectEventBodyContains('"My issue" assigned to alice, bob', 0); + intent.expectEventBodyContains( + GITHUB_ISSUE_CREATED_PAYLOAD.issue.html_url, + 0, + ); + intent.expectEventBodyContains( + GITHUB_ISSUE_CREATED_PAYLOAD.issue.title, + 0, + ); + }); - it("will filter out issues not matching includingLabels.", async () => { - const { connection, intent } = createConnection({ - includingLabels: ["include-me"] - }); - await connection.onIssueCreated({ - ...GITHUB_ISSUE_CREATED_PAYLOAD, - issue: { - ...GITHUB_ISSUE, - labels: [{ - name: "foo", - }], - } - } as never); - // ..or issues with no labels - await connection.onIssueCreated(GITHUB_ISSUE_CREATED_PAYLOAD as never); - intent.expectNoEvent(); - }); + it("will filter out issues not matching includingLabels.", async () => { + const { connection, intent } = createConnection({ + includingLabels: ["include-me"], + }); + await connection.onIssueCreated({ + ...GITHUB_ISSUE_CREATED_PAYLOAD, + issue: { + ...GITHUB_ISSUE, + labels: [ + { + name: "foo", + }, + ], + }, + } as never); + // ..or issues with no labels + await connection.onIssueCreated(GITHUB_ISSUE_CREATED_PAYLOAD as never); + intent.expectNoEvent(); + }); - it("will filter out issues matching excludingLabels.", async () => { - const { connection, intent } = createConnection({ - excludingLabels: ["exclude-me"] - }); - await connection.onIssueCreated({ - ...GITHUB_ISSUE_CREATED_PAYLOAD, - issue: { - ...GITHUB_ISSUE, - labels: [{ - name: "exclude-me", - }], - } - } as never); - intent.expectNoEvent(); - }); + it("will filter out issues matching excludingLabels.", async () => { + const { connection, intent } = createConnection({ + excludingLabels: ["exclude-me"], + }); + await connection.onIssueCreated({ + ...GITHUB_ISSUE_CREATED_PAYLOAD, + issue: { + ...GITHUB_ISSUE, + labels: [ + { + name: "exclude-me", + }, + ], + }, + } as never); + intent.expectNoEvent(); + }); - it("will include issues matching includingLabels.", async () => { - const { connection, intent } = createConnection({ - includingIssues: ["include-me"] - }); - await connection.onIssueCreated({ - ...GITHUB_ISSUE_CREATED_PAYLOAD, - issue: { - ...GITHUB_ISSUE, - labels: [{ - name: "include-me", - }], - } - } as never); - intent.expectEventBodyContains('**alice** created new issue', 0); - }); - }); + it("will include issues matching includingLabels.", async () => { + const { connection, intent } = createConnection({ + includingIssues: ["include-me"], + }); + await connection.onIssueCreated({ + ...GITHUB_ISSUE_CREATED_PAYLOAD, + issue: { + ...GITHUB_ISSUE, + labels: [ + { + name: "include-me", + }, + ], + }, + } as never); + intent.expectEventBodyContains("**alice** created new issue", 0); + }); + }); }); diff --git a/tests/connections/GitlabRepoTest.ts b/tests/connections/GitlabRepoTest.ts index fa8cfc20d..5f4870111 100644 --- a/tests/connections/GitlabRepoTest.ts +++ b/tests/connections/GitlabRepoTest.ts @@ -2,359 +2,402 @@ import { createMessageQueue } from "../../src/MessageQueue"; import { UserTokenStore } from "../../src/tokens/UserTokenStore"; import { AppserviceMock } from "../utils/AppserviceMock"; import { ApiError, ErrCode, ValidatorApiError } from "../../src/api"; -import { GitLabRepoConnection, GitLabRepoConnectionState } from "../../src/Connections"; +import { + GitLabRepoConnection, + GitLabRepoConnectionState, +} from "../../src/Connections"; import { expect } from "chai"; import { BridgeConfigGitLab } from "../../src/config/Config"; import { IBridgeStorageProvider } from "../../src/Stores/StorageProvider"; import { IntentMock } from "../utils/IntentMock"; -import { IGitlabMergeRequest, IGitlabProject, IGitlabUser, IGitLabWebhookNoteEvent } from "../../src/Gitlab/WebhookTypes"; +import { + IGitlabMergeRequest, + IGitlabProject, + IGitlabUser, + IGitLabWebhookNoteEvent, +} from "../../src/Gitlab/WebhookTypes"; const ROOM_ID = "!foo:bar"; const GITLAB_ORG_REPO = { - org: "a-fake-org", - repo: "a-fake-repo", + org: "a-fake-org", + repo: "a-fake-repo", }; const GITLAB_MR: IGitlabMergeRequest = { - author_id: 0, - labels: [], - state: "opened", - iid: 1234, - url: `https://gitlab.example.com/${GITLAB_ORG_REPO.org}/${GITLAB_ORG_REPO.repo}/issues/1234`, - title: "My MR", + author_id: 0, + labels: [], + state: "opened", + iid: 1234, + url: `https://gitlab.example.com/${GITLAB_ORG_REPO.org}/${GITLAB_ORG_REPO.repo}/issues/1234`, + title: "My MR", }; const GITLAB_USER: IGitlabUser = { - name: "Alice", - username: "alice", - avatar_url: "", - email: "alice@example.org" + name: "Alice", + username: "alice", + avatar_url: "", + email: "alice@example.org", }; const GITLAB_PROJECT: IGitlabProject = { - path_with_namespace: `${GITLAB_ORG_REPO.org}/${GITLAB_ORG_REPO.repo}`, - web_url: `https://gitlab.example.com/${GITLAB_ORG_REPO.org}/${GITLAB_ORG_REPO.repo}`, - homepage: "", + path_with_namespace: `${GITLAB_ORG_REPO.org}/${GITLAB_ORG_REPO.repo}`, + web_url: `https://gitlab.example.com/${GITLAB_ORG_REPO.org}/${GITLAB_ORG_REPO.repo}`, + homepage: "", }; const GITLAB_ISSUE_CREATED_PAYLOAD = { - object_kind: "merge_request", - user: GITLAB_USER, - object_attributes: GITLAB_MR, - project: GITLAB_PROJECT, + object_kind: "merge_request", + user: GITLAB_USER, + object_attributes: GITLAB_MR, + project: GITLAB_PROJECT, }; const GITLAB_MR_COMMENT: IGitLabWebhookNoteEvent = { - 'object_kind': 'note', - 'event_type': 'note', - 'merge_request': GITLAB_MR, - 'object_attributes': { - 'discussion_id': '6babfc4ad3be2355db286ed50be111a5220d5751', - 'note': 'I am starting a new thread', - 'noteable_type': 'MergeRequest', - 'url': 'https://gitlab.com/tadeuszs/my-awesome-project/-/merge_requests/2#note_1455087141', - 'id': 1455087141, - 'author_id': 12345, - 'noteable_id': 1, - }, - 'project': GITLAB_PROJECT, - 'user': GITLAB_USER, - repository: { - 'description': 'A repo', - 'homepage': 'https://gitlab.com/tadeuszs/my-awesome-project', - 'name': 'a-repo', - 'url': 'https://gitlab.com/tadeuszs/my-awesome-project' - }, + object_kind: "note", + event_type: "note", + merge_request: GITLAB_MR, + object_attributes: { + discussion_id: "6babfc4ad3be2355db286ed50be111a5220d5751", + note: "I am starting a new thread", + noteable_type: "MergeRequest", + url: "https://gitlab.com/tadeuszs/my-awesome-project/-/merge_requests/2#note_1455087141", + id: 1455087141, + author_id: 12345, + noteable_id: 1, + }, + project: GITLAB_PROJECT, + user: GITLAB_USER, + repository: { + description: "A repo", + homepage: "https://gitlab.com/tadeuszs/my-awesome-project", + name: "a-repo", + url: "https://gitlab.com/tadeuszs/my-awesome-project", + }, }; const COMMENT_DEBOUNCE_MS = 25; -function createConnection(state: Partial = {}, isExistingState=false): { connection: GitLabRepoConnection, intent: IntentMock } { - const mq = createMessageQueue(); - mq.subscribe('*'); - const as = AppserviceMock.create(); - const intent = as.getIntentForUserId('@gitlab:example.test'); - const connection = new GitLabRepoConnection( - ROOM_ID, - "state_key", - as, - { - commentDebounceMs: COMMENT_DEBOUNCE_MS, - } as BridgeConfigGitLab, - intent, - GitLabRepoConnection.validateState({ - instance: "bar", - path: "foo", - ...state, - }, isExistingState), - {} as UserTokenStore, - { - url: "https://gitlab.example.com" - }, - { - setGitlabDiscussionThreads: () => Promise.resolve(), - getGitlabDiscussionThreads: () => Promise.resolve([]), - } as unknown as IBridgeStorageProvider, - ); - return {connection, intent}; +function createConnection( + state: Partial = {}, + isExistingState = false, +): { connection: GitLabRepoConnection; intent: IntentMock } { + const mq = createMessageQueue(); + mq.subscribe("*"); + const as = AppserviceMock.create(); + const intent = as.getIntentForUserId("@gitlab:example.test"); + const connection = new GitLabRepoConnection( + ROOM_ID, + "state_key", + as, + { + commentDebounceMs: COMMENT_DEBOUNCE_MS, + } as BridgeConfigGitLab, + intent, + GitLabRepoConnection.validateState( + { + instance: "bar", + path: "foo", + ...state, + }, + isExistingState, + ), + {} as UserTokenStore, + { + url: "https://gitlab.example.com", + }, + { + setGitlabDiscussionThreads: () => Promise.resolve(), + getGitlabDiscussionThreads: () => Promise.resolve([]), + } as unknown as IBridgeStorageProvider, + ); + return { connection, intent }; } async function waitForDebouncing(): Promise { - return new Promise(resolve => setTimeout(resolve, COMMENT_DEBOUNCE_MS * 2)); + return new Promise((resolve) => setTimeout(resolve, COMMENT_DEBOUNCE_MS * 2)); } describe("GitLabRepoConnection", () => { - describe("validateState", () => { - it("can validate a completes state config", () => { - GitLabRepoConnection.validateState({ - instance: "foo", - path: "bar/baz", - enableHooks: [ - "merge_request.open", - "merge_request.reopen", - "merge_request.close", - "merge_request.merge", - "merge_request.review", - "merge_request.review.comments", - "merge_request", - "tag_push", - "push", - "wiki", - "release", - "release.created", - ], - commandPrefix: "!gl", - pushTagsRegex: ".*", - includingLabels: ["me"], - excludingLabels: ["but-not-me"], - } as GitLabRepoConnectionState as unknown as Record); - }); + describe("validateState", () => { + it("can validate a completes state config", () => { + GitLabRepoConnection.validateState({ + instance: "foo", + path: "bar/baz", + enableHooks: [ + "merge_request.open", + "merge_request.reopen", + "merge_request.close", + "merge_request.merge", + "merge_request.review", + "merge_request.review.comments", + "merge_request", + "tag_push", + "push", + "wiki", + "release", + "release.created", + ], + commandPrefix: "!gl", + pushTagsRegex: ".*", + includingLabels: ["me"], + excludingLabels: ["but-not-me"], + } as GitLabRepoConnectionState as unknown as Record); + }); - it("will convert ignoredHooks for existing state", () => { - const state = GitLabRepoConnection.validateState({ - instance: "foo", - path: "bar/baz", - ignoreHooks: [ - "merge_request", - ], - commandPrefix: "!gl", - } as GitLabRepoConnectionState as unknown as Record, true); - expect(state.enableHooks).to.not.contain('merge_request'); - }); + it("will convert ignoredHooks for existing state", () => { + const state = GitLabRepoConnection.validateState( + { + instance: "foo", + path: "bar/baz", + ignoreHooks: ["merge_request"], + commandPrefix: "!gl", + } as GitLabRepoConnectionState as unknown as Record, + true, + ); + expect(state.enableHooks).to.not.contain("merge_request"); + }); - it("will disallow invalid state", () => { - try { - GitLabRepoConnection.validateState({ - instance: "foo", - path: 123, - }); - } catch (ex) { - if (ex instanceof ValidatorApiError === false || ex.errcode !== ErrCode.BadValue) { - throw ex; - } - } - }); + it("will disallow invalid state", () => { + try { + GitLabRepoConnection.validateState({ + instance: "foo", + path: 123, + }); + } catch (ex) { + if ( + ex instanceof ValidatorApiError === false || + ex.errcode !== ErrCode.BadValue + ) { + throw ex; + } + } + }); - it("will disallow enabledHooks to contains invalid enums if this is new state", () => { - try { - GitLabRepoConnection.validateState({ - instance: "bar", - path: "foo", - enabledHooks: ["not-real"], - }, false); - } catch (ex) { - if (ex instanceof ApiError === false || ex.errcode !== ErrCode.BadValue) { - throw ex; - } - } - }); + it("will disallow enabledHooks to contains invalid enums if this is new state", () => { + try { + GitLabRepoConnection.validateState( + { + instance: "bar", + path: "foo", + enabledHooks: ["not-real"], + }, + false, + ); + } catch (ex) { + if ( + ex instanceof ApiError === false || + ex.errcode !== ErrCode.BadValue + ) { + throw ex; + } + } + }); - it("will allow enabledHooks to contains invalid enums if this is old state", () => { - GitLabRepoConnection.validateState({ - instance: "bar", - path: "foo", - enabledHooks: ["not-real"], - }, true); - }); - }); + it("will allow enabledHooks to contains invalid enums if this is old state", () => { + GitLabRepoConnection.validateState( + { + instance: "bar", + path: "foo", + enabledHooks: ["not-real"], + }, + true, + ); + }); + }); - describe("onMergeRequestCommentCreated", () => { - it("will handle an MR comment", async () => { - const { connection, intent } = createConnection(); - await connection.onMergeRequestCommentCreated(GITLAB_MR_COMMENT); - await waitForDebouncing(); - intent.expectEventMatches( - (ev: any) => ev.content.body.includes('**Alice** commented on MR'), - 'event body indicates MR comment' - ); - }); + describe("onMergeRequestCommentCreated", () => { + it("will handle an MR comment", async () => { + const { connection, intent } = createConnection(); + await connection.onMergeRequestCommentCreated(GITLAB_MR_COMMENT); + await waitForDebouncing(); + intent.expectEventMatches( + (ev: any) => ev.content.body.includes("**Alice** commented on MR"), + "event body indicates MR comment", + ); + }); - it("will filter out issues not matching includingLabels.", async () => { - const { connection, intent } = createConnection({ - includingLabels: ["include-me"] - }); - // ..or issues with no labels - await connection.onMergeRequestCommentCreated(GITLAB_MR_COMMENT); - await waitForDebouncing(); - intent.expectNoEvent(); - }); + it("will filter out issues not matching includingLabels.", async () => { + const { connection, intent } = createConnection({ + includingLabels: ["include-me"], + }); + // ..or issues with no labels + await connection.onMergeRequestCommentCreated(GITLAB_MR_COMMENT); + await waitForDebouncing(); + intent.expectNoEvent(); + }); - it("will filter out issues matching excludingLabels.", async () => { - const { connection, intent } = createConnection({ - excludingLabels: ["exclude-me"] - }); - // ..or issues with no labels - await connection.onMergeRequestCommentCreated({ - ...GITLAB_MR_COMMENT, - merge_request: { - ...GITLAB_MR, - labels: [{ - id: 0, - title: 'exclude-me' - } as any] - } - }); - await waitForDebouncing(); - intent.expectNoEvent(); - }); + it("will filter out issues matching excludingLabels.", async () => { + const { connection, intent } = createConnection({ + excludingLabels: ["exclude-me"], + }); + // ..or issues with no labels + await connection.onMergeRequestCommentCreated({ + ...GITLAB_MR_COMMENT, + merge_request: { + ...GITLAB_MR, + labels: [ + { + id: 0, + title: "exclude-me", + } as any, + ], + }, + }); + await waitForDebouncing(); + intent.expectNoEvent(); + }); + it("will debounce MR comments", async () => { + const { connection, intent } = createConnection(); + await connection.onMergeRequestCommentCreated(GITLAB_MR_COMMENT); + await connection.onMergeRequestCommentCreated({ + ...GITLAB_MR_COMMENT, + object_attributes: { + ...GITLAB_MR_COMMENT.object_attributes, + discussion_id: "fa5d", + note: "different comment", + }, + } as never); + await waitForDebouncing(); + expect(intent.sentEvents.length).to.equal(1); + intent.expectEventMatches( + (ev: any) => ev.content.body.includes("with 2 comments"), + "one event sent for both comments", + 0, + ); + }); + it("will add new comments in a Matrix thread", async () => { + const { connection, intent } = createConnection(); + await connection.onMergeRequestCommentCreated(GITLAB_MR_COMMENT); + await waitForDebouncing(); + await connection.onMergeRequestCommentCreated(GITLAB_MR_COMMENT); + await waitForDebouncing(); + expect(intent.sentEvents.length).to.equal(2); + intent.expectEventMatches( + (ev: any) => ev.content["m.relates_to"].event_id === "event_0", + "one event sent for both comments", + 1, + ); + }); - it("will debounce MR comments", async () => { - const { connection, intent } = createConnection(); - await connection.onMergeRequestCommentCreated(GITLAB_MR_COMMENT); - await connection.onMergeRequestCommentCreated({ - ...GITLAB_MR_COMMENT, - 'object_attributes': { - ...GITLAB_MR_COMMENT.object_attributes, - 'discussion_id': 'fa5d', - 'note': 'different comment', - }, - } as never); - await waitForDebouncing(); - expect(intent.sentEvents.length).to.equal(1); - intent.expectEventMatches( - (ev: any) => ev.content.body.includes('with 2 comments'), - 'one event sent for both comments', - 0, - ); - }); + it("will correctly map new comments to aggregated discussions", async () => { + const { connection, intent } = createConnection(); + await connection.onMergeRequestCommentCreated({ + ...GITLAB_MR_COMMENT, + object_attributes: { + ...GITLAB_MR_COMMENT.object_attributes, + discussion_id: "disc1", + }, + } as never); + await connection.onMergeRequestCommentCreated({ + ...GITLAB_MR_COMMENT, + object_attributes: { + ...GITLAB_MR_COMMENT.object_attributes, + discussion_id: "disc2", + }, + } as never); + await waitForDebouncing(); + expect(intent.sentEvents.length).to.equal(1); - it("will add new comments in a Matrix thread", async () => { - const { connection, intent } = createConnection(); - await connection.onMergeRequestCommentCreated(GITLAB_MR_COMMENT); - await waitForDebouncing(); - await connection.onMergeRequestCommentCreated(GITLAB_MR_COMMENT); - await waitForDebouncing(); - expect(intent.sentEvents.length).to.equal(2); - intent.expectEventMatches( - (ev: any) => ev.content['m.relates_to'].event_id === 'event_0', - 'one event sent for both comments', - 1, - ); - }); + await connection.onMergeRequestCommentCreated({ + ...GITLAB_MR_COMMENT, + object_attributes: { + ...GITLAB_MR_COMMENT.object_attributes, + discussion_id: "disc1", + }, + } as never); + await waitForDebouncing(); + expect(intent.sentEvents.length).to.equal(2); + intent.expectEventMatches( + (ev: any) => ev.content["m.relates_to"].event_id === "event_0", + "disc1 reply goes to existing thread", + 1, + ); - it("will correctly map new comments to aggregated discussions", async () => { - const { connection, intent } = createConnection(); - await connection.onMergeRequestCommentCreated({ - ...GITLAB_MR_COMMENT, - 'object_attributes': { - ...GITLAB_MR_COMMENT.object_attributes, - 'discussion_id': 'disc1', - }, - } as never); - await connection.onMergeRequestCommentCreated({ - ...GITLAB_MR_COMMENT, - 'object_attributes': { - ...GITLAB_MR_COMMENT.object_attributes, - 'discussion_id': 'disc2', - }, - } as never); - await waitForDebouncing(); - expect(intent.sentEvents.length).to.equal(1); + await connection.onMergeRequestCommentCreated({ + ...GITLAB_MR_COMMENT, + object_attributes: { + ...GITLAB_MR_COMMENT.object_attributes, + discussion_id: "disc2", + }, + } as never); + await waitForDebouncing(); + expect(intent.sentEvents.length).to.equal(3); + intent.expectEventMatches( + (ev: any) => ev.content["m.relates_to"].event_id === "event_0", + "disc2 reply also goes to existing thread", + 2, + ); + }); + }); - await connection.onMergeRequestCommentCreated({ - ...GITLAB_MR_COMMENT, - 'object_attributes': { - ...GITLAB_MR_COMMENT.object_attributes, - 'discussion_id': 'disc1', - }, - } as never); - await waitForDebouncing(); - expect(intent.sentEvents.length).to.equal(2); - intent.expectEventMatches( - (ev: any) => ev.content['m.relates_to'].event_id === 'event_0', - 'disc1 reply goes to existing thread', - 1 - ); + describe("onIssueCreated", () => { + it("will handle a simple issue", async () => { + const { connection, intent } = createConnection(); + await connection.onMergeRequestOpened( + GITLAB_ISSUE_CREATED_PAYLOAD as never, + ); + // Statement text. + intent.expectEventBodyContains("**alice** opened a new MR", 0); + intent.expectEventBodyContains( + GITLAB_ISSUE_CREATED_PAYLOAD.object_attributes.url, + 0, + ); + intent.expectEventBodyContains( + GITLAB_ISSUE_CREATED_PAYLOAD.object_attributes.title, + 0, + ); + }); - await connection.onMergeRequestCommentCreated({ - ...GITLAB_MR_COMMENT, - 'object_attributes': { - ...GITLAB_MR_COMMENT.object_attributes, - 'discussion_id': 'disc2', - }, - } as never); - await waitForDebouncing(); - expect(intent.sentEvents.length).to.equal(3); - intent.expectEventMatches( - (ev: any) => ev.content['m.relates_to'].event_id === 'event_0', - 'disc2 reply also goes to existing thread', - 2 - ); - }); - }); + it("will filter out issues not matching includingLabels.", async () => { + const { connection, intent } = createConnection({ + includingLabels: ["include-me"], + }); + await connection.onMergeRequestOpened({ + ...GITLAB_ISSUE_CREATED_PAYLOAD, + labels: [ + { + title: "foo", + }, + ], + } as never); + // ..or issues with no labels + await connection.onMergeRequestOpened( + GITLAB_ISSUE_CREATED_PAYLOAD as never, + ); + intent.expectNoEvent(); + }); - describe("onIssueCreated", () => { - it("will handle a simple issue", async () => { - const { connection, intent } = createConnection(); - await connection.onMergeRequestOpened(GITLAB_ISSUE_CREATED_PAYLOAD as never); - // Statement text. - intent.expectEventBodyContains('**alice** opened a new MR', 0); - intent.expectEventBodyContains(GITLAB_ISSUE_CREATED_PAYLOAD.object_attributes.url, 0); - intent.expectEventBodyContains(GITLAB_ISSUE_CREATED_PAYLOAD.object_attributes.title, 0); - }); + it("will filter out issues matching excludingLabels.", async () => { + const { connection, intent } = createConnection({ + excludingLabels: ["exclude-me"], + }); + await connection.onMergeRequestOpened({ + ...GITLAB_ISSUE_CREATED_PAYLOAD, + labels: [ + { + title: "exclude-me", + }, + ], + } as never); + intent.expectNoEvent(); + }); - it("will filter out issues not matching includingLabels.", async () => { - const { connection, intent } = createConnection({ - includingLabels: ["include-me"] - }); - await connection.onMergeRequestOpened({ - ...GITLAB_ISSUE_CREATED_PAYLOAD, - labels: [{ - title: "foo", - }], - } as never); - // ..or issues with no labels - await connection.onMergeRequestOpened(GITLAB_ISSUE_CREATED_PAYLOAD as never); - intent.expectNoEvent(); - }); - - it("will filter out issues matching excludingLabels.", async () => { - const { connection, intent } = createConnection({ - excludingLabels: ["exclude-me"] - }); - await connection.onMergeRequestOpened({ - ...GITLAB_ISSUE_CREATED_PAYLOAD, - labels: [{ - title: "exclude-me", - }], - } as never); - intent.expectNoEvent(); - }); - - it("will include issues matching includingLabels.", async () => { - const { connection, intent } = createConnection({ - includingLabels: ["include-me"] - }); - await connection.onMergeRequestOpened({ - ...GITLAB_ISSUE_CREATED_PAYLOAD, - labels: [{ - title: "include-me", - }], - } as never); - intent.expectEventBodyContains('**alice** opened a new MR', 0); - }); - }); + it("will include issues matching includingLabels.", async () => { + const { connection, intent } = createConnection({ + includingLabels: ["include-me"], + }); + await connection.onMergeRequestOpened({ + ...GITLAB_ISSUE_CREATED_PAYLOAD, + labels: [ + { + title: "include-me", + }, + ], + } as never); + intent.expectEventBodyContains("**alice** opened a new MR", 0); + }); + }); }); diff --git a/tests/github/AdminCommands.ts b/tests/github/AdminCommands.ts index f614b82e3..6c62bbc4f 100644 --- a/tests/github/AdminCommands.ts +++ b/tests/github/AdminCommands.ts @@ -1,51 +1,67 @@ import { expect } from "chai"; -import { GithubInstance } from "../../src/github/GithubInstance"; +import { GithubInstance } from "../../src/github/GithubInstance"; import { GITHUB_CLOUD_URL } from "../../src/github/GithubInstance"; describe("GitHub", () => { - describe("AdminCommands", () => { - it("can generate an authorize URL for the cloud URL", () => { - expect( - GithubInstance.generateOAuthUrl(GITHUB_CLOUD_URL, "authorize", { - state: "my_state", - client_id: "123", - redirect_uri: "456", - }) - ).equals('https://github.com/login/oauth/authorize?state=my_state&client_id=123&redirect_uri=456'); - }); + describe("AdminCommands", () => { + it("can generate an authorize URL for the cloud URL", () => { + expect( + GithubInstance.generateOAuthUrl(GITHUB_CLOUD_URL, "authorize", { + state: "my_state", + client_id: "123", + redirect_uri: "456", + }), + ).equals( + "https://github.com/login/oauth/authorize?state=my_state&client_id=123&redirect_uri=456", + ); + }); - it("can generate an authorize URL for enterprise URLs", () => { - expect( - GithubInstance.generateOAuthUrl(new URL("https://mygithuburl.com/foo/bar"), "authorize", { - state: "my_state", - client_id: "123", - redirect_uri: "456", - }) - ).equals('https://mygithuburl.com/foo/bar/login/oauth/authorize?state=my_state&client_id=123&redirect_uri=456'); - }); + it("can generate an authorize URL for enterprise URLs", () => { + expect( + GithubInstance.generateOAuthUrl( + new URL("https://mygithuburl.com/foo/bar"), + "authorize", + { + state: "my_state", + client_id: "123", + redirect_uri: "456", + }, + ), + ).equals( + "https://mygithuburl.com/foo/bar/login/oauth/authorize?state=my_state&client_id=123&redirect_uri=456", + ); + }); - it("can generate an access_token URL for the cloud URL", () => { - expect( - GithubInstance.generateOAuthUrl(GITHUB_CLOUD_URL, "access_token", { - client_id: "123", - client_secret: "the-secret", - code: "the-code", - redirect_uri: "456", - state: "my_state", - }) - ).equals('https://github.com/login/oauth/access_token?client_id=123&client_secret=the-secret&code=the-code&redirect_uri=456&state=my_state'); - }); + it("can generate an access_token URL for the cloud URL", () => { + expect( + GithubInstance.generateOAuthUrl(GITHUB_CLOUD_URL, "access_token", { + client_id: "123", + client_secret: "the-secret", + code: "the-code", + redirect_uri: "456", + state: "my_state", + }), + ).equals( + "https://github.com/login/oauth/access_token?client_id=123&client_secret=the-secret&code=the-code&redirect_uri=456&state=my_state", + ); + }); - it("can generate an access_token URL for enterprise URLs", () => { - expect( - GithubInstance.generateOAuthUrl(new URL("https://mygithuburl.com/foo/bar"), "access_token", { - client_id: "123", - client_secret: "the-secret", - code: "the-code", - redirect_uri: "456", - state: "my_state", - }) - ).equals('https://mygithuburl.com/foo/bar/login/oauth/access_token?client_id=123&client_secret=the-secret&code=the-code&redirect_uri=456&state=my_state'); - }); + it("can generate an access_token URL for enterprise URLs", () => { + expect( + GithubInstance.generateOAuthUrl( + new URL("https://mygithuburl.com/foo/bar"), + "access_token", + { + client_id: "123", + client_secret: "the-secret", + code: "the-code", + redirect_uri: "456", + state: "my_state", + }, + ), + ).equals( + "https://mygithuburl.com/foo/bar/login/oauth/access_token?client_id=123&client_secret=the-secret&code=the-code&redirect_uri=456&state=my_state", + ); }); -}); \ No newline at end of file + }); +}); diff --git a/tests/grants/GrantChecker.spec.ts b/tests/grants/GrantChecker.spec.ts index 5e81e625a..723197b3d 100644 --- a/tests/grants/GrantChecker.spec.ts +++ b/tests/grants/GrantChecker.spec.ts @@ -2,179 +2,203 @@ import { expect } from "chai"; import { BridgeConfig } from "../../src/config/Config"; import { DefaultConfigRoot } from "../../src/config/Defaults"; import { FormatUtil } from "../../src/FormatUtil"; -import { ConfigGrantChecker, GrantChecker, GrantRejectedError } from '../../src/grants/GrantCheck'; +import { + ConfigGrantChecker, + GrantChecker, + GrantRejectedError, +} from "../../src/grants/GrantCheck"; import { AppserviceMock } from "../utils/AppserviceMock"; import { IntentMock } from "../utils/IntentMock"; -const ROOM_ID = '!a-room:bar'; -const CONNECTION_ID = '!a-room:bar'; -const ALWAYS_GRANT_USER = '@grant_me:bar'; -const GRANT_SERVICE_USER = '@grant_service_user:bar'; -const GRANT_SERVCE_LOW_PERMS = '@grant_service_user_without_perms:bar'; -const GRANT_WRONG_SERVCE_USER = '@grant_wrong_service_user:bar'; -const ALICE_USERID = '@alice:bar'; -const GRANT_SERVICE = 'example-grant'; - -async function doesAssert(checker: GrantChecker, roomId: string, connectionId: string, sender?: string) { - try { - await checker.assertConnectionGranted(roomId, connectionId, sender); - throw Error(`Expected ${roomId}/${connectionId} to have thrown an error`) - } catch (ex) { - expect(ex).instanceOf(GrantRejectedError, 'Error thrown, but was not a grant rejected error'); - expect(ex.roomId).to.equal(roomId, "Grant rejected, but roomId didn't match"); - // connectionIds are always hashed - expect(ex.connectionId).to.equal(FormatUtil.hashId(connectionId), "Grant rejected, but connectionId didn't match"); - return true; - } +const ROOM_ID = "!a-room:bar"; +const CONNECTION_ID = "!a-room:bar"; +const ALWAYS_GRANT_USER = "@grant_me:bar"; +const GRANT_SERVICE_USER = "@grant_service_user:bar"; +const GRANT_SERVCE_LOW_PERMS = "@grant_service_user_without_perms:bar"; +const GRANT_WRONG_SERVCE_USER = "@grant_wrong_service_user:bar"; +const ALICE_USERID = "@alice:bar"; +const GRANT_SERVICE = "example-grant"; + +async function doesAssert( + checker: GrantChecker, + roomId: string, + connectionId: string, + sender?: string, +) { + try { + await checker.assertConnectionGranted(roomId, connectionId, sender); + throw Error(`Expected ${roomId}/${connectionId} to have thrown an error`); + } catch (ex) { + expect(ex).instanceOf( + GrantRejectedError, + "Error thrown, but was not a grant rejected error", + ); + expect(ex.roomId).to.equal( + roomId, + "Grant rejected, but roomId didn't match", + ); + // connectionIds are always hashed + expect(ex.connectionId).to.equal( + FormatUtil.hashId(connectionId), + "Grant rejected, but connectionId didn't match", + ); + return true; + } } class TestGrantChecker extends GrantChecker { - protected checkFallback(roomId: string, connectionId: string | object, sender?: string | undefined) { - return sender === ALWAYS_GRANT_USER; - } + protected checkFallback( + roomId: string, + connectionId: string | object, + sender?: string | undefined, + ) { + return sender === ALWAYS_GRANT_USER; + } } describe("GrantChecker", () => { - describe('base grant system', () => { - let check: GrantChecker; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let intent: any; - - beforeEach(() => { - intent = IntentMock.create('@foo:bar'); - check = new TestGrantChecker(intent, GRANT_SERVICE); - }); - - it('will grant a connection', async () => { - await check.grantConnection(ROOM_ID, CONNECTION_ID); - // And then to check that the grant has now been allowed. - await check.assertConnectionGranted(ROOM_ID, CONNECTION_ID); - }); - - it('will assert on a missing grant', async () => { - await doesAssert( - check, - ROOM_ID, - CONNECTION_ID - ); - }); - - it('will allow a missing grant if sender matches', async () => { - // Use the special user to grant the connection - await check.assertConnectionGranted(ROOM_ID, CONNECTION_ID, ALWAYS_GRANT_USER); - - // And then to check that the grant has now been allowed. - await check.assertConnectionGranted(ROOM_ID, CONNECTION_ID); - }); - - it('will not conflict with another connection id', async () => { - await check.grantConnection(ROOM_ID, CONNECTION_ID); - await doesAssert( - check, - ROOM_ID, - CONNECTION_ID + "2", - ); - }); - - it('will not conflict with another room', async () => { - await check.grantConnection(ROOM_ID, CONNECTION_ID); - await doesAssert( - check, - ROOM_ID + "2", - CONNECTION_ID - ); - }); - - it('will not conflict with another grant service', async () => { - const anotherchecker = new TestGrantChecker(intent, GRANT_SERVICE + "-two"); - await check.grantConnection(ROOM_ID, CONNECTION_ID); - - await doesAssert( - anotherchecker, - ROOM_ID, - CONNECTION_ID - ); - }); + describe("base grant system", () => { + let check: GrantChecker; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let intent: any; + + beforeEach(() => { + intent = IntentMock.create("@foo:bar"); + check = new TestGrantChecker(intent, GRANT_SERVICE); }); - describe('config fallback', () => { - let check: GrantChecker; - let as: AppserviceMock; - - beforeEach(() => { - const mockAs = AppserviceMock.create(); - as = mockAs; - const config = new BridgeConfig( - { - ...DefaultConfigRoot, - permissions: [{ - actor: ALWAYS_GRANT_USER, - services: [{ - service: '*', - level: "admin", - }], - }, - { - actor: GRANT_SERVICE_USER, - services: [{ - service: GRANT_SERVICE, - level: "admin", - }] - }, - { - actor: GRANT_SERVCE_LOW_PERMS, - services: [{ - service: GRANT_SERVICE, - level: 'notifications', - }] - }, - { - actor: GRANT_WRONG_SERVCE_USER, - services: [{ - service: 'another-service', - level: "admin", - }] - }], - } - ); - check = new ConfigGrantChecker(GRANT_SERVICE, mockAs, config); - }); - - it('will deny a missing grant if the sender is not provided', async () => { - await doesAssert( - check, - ROOM_ID, - CONNECTION_ID - ); - }); - - it('will deny a missing grant if the sender is not in the appservice whitelist', async () => { - await doesAssert( - check, - ROOM_ID, - CONNECTION_ID, - ALICE_USERID, - ); - }); - - it('will grant if the user is part of the appservice', async () => { - await check.assertConnectionGranted(ROOM_ID, CONNECTION_ID, as.namespace + "bot"); - }); - - it('will grant if the user has access to all services', async () => { - await check.assertConnectionGranted(ROOM_ID, CONNECTION_ID, ALWAYS_GRANT_USER); - }); - - it('will grant if the user has access to this service', async () => { - await check.assertConnectionGranted(ROOM_ID, CONNECTION_ID, GRANT_SERVICE_USER); - }); - - it('will not grant if the user has low access to this service', async () => { - await doesAssert(check, ROOM_ID, CONNECTION_ID, GRANT_SERVCE_LOW_PERMS); - }); - - it('will not grant if the user has access to a different service', async () => { - await doesAssert(check, ROOM_ID, CONNECTION_ID, GRANT_WRONG_SERVCE_USER); - }); + it("will grant a connection", async () => { + await check.grantConnection(ROOM_ID, CONNECTION_ID); + // And then to check that the grant has now been allowed. + await check.assertConnectionGranted(ROOM_ID, CONNECTION_ID); }); + + it("will assert on a missing grant", async () => { + await doesAssert(check, ROOM_ID, CONNECTION_ID); + }); + + it("will allow a missing grant if sender matches", async () => { + // Use the special user to grant the connection + await check.assertConnectionGranted( + ROOM_ID, + CONNECTION_ID, + ALWAYS_GRANT_USER, + ); + + // And then to check that the grant has now been allowed. + await check.assertConnectionGranted(ROOM_ID, CONNECTION_ID); + }); + + it("will not conflict with another connection id", async () => { + await check.grantConnection(ROOM_ID, CONNECTION_ID); + await doesAssert(check, ROOM_ID, CONNECTION_ID + "2"); + }); + + it("will not conflict with another room", async () => { + await check.grantConnection(ROOM_ID, CONNECTION_ID); + await doesAssert(check, ROOM_ID + "2", CONNECTION_ID); + }); + + it("will not conflict with another grant service", async () => { + const anotherchecker = new TestGrantChecker( + intent, + GRANT_SERVICE + "-two", + ); + await check.grantConnection(ROOM_ID, CONNECTION_ID); + + await doesAssert(anotherchecker, ROOM_ID, CONNECTION_ID); + }); + }); + + describe("config fallback", () => { + let check: GrantChecker; + let as: AppserviceMock; + + beforeEach(() => { + const mockAs = AppserviceMock.create(); + as = mockAs; + const config = new BridgeConfig({ + ...DefaultConfigRoot, + permissions: [ + { + actor: ALWAYS_GRANT_USER, + services: [ + { + service: "*", + level: "admin", + }, + ], + }, + { + actor: GRANT_SERVICE_USER, + services: [ + { + service: GRANT_SERVICE, + level: "admin", + }, + ], + }, + { + actor: GRANT_SERVCE_LOW_PERMS, + services: [ + { + service: GRANT_SERVICE, + level: "notifications", + }, + ], + }, + { + actor: GRANT_WRONG_SERVCE_USER, + services: [ + { + service: "another-service", + level: "admin", + }, + ], + }, + ], + }); + check = new ConfigGrantChecker(GRANT_SERVICE, mockAs, config); + }); + + it("will deny a missing grant if the sender is not provided", async () => { + await doesAssert(check, ROOM_ID, CONNECTION_ID); + }); + + it("will deny a missing grant if the sender is not in the appservice whitelist", async () => { + await doesAssert(check, ROOM_ID, CONNECTION_ID, ALICE_USERID); + }); + + it("will grant if the user is part of the appservice", async () => { + await check.assertConnectionGranted( + ROOM_ID, + CONNECTION_ID, + as.namespace + "bot", + ); + }); + + it("will grant if the user has access to all services", async () => { + await check.assertConnectionGranted( + ROOM_ID, + CONNECTION_ID, + ALWAYS_GRANT_USER, + ); + }); + + it("will grant if the user has access to this service", async () => { + await check.assertConnectionGranted( + ROOM_ID, + CONNECTION_ID, + GRANT_SERVICE_USER, + ); + }); + + it("will not grant if the user has low access to this service", async () => { + await doesAssert(check, ROOM_ID, CONNECTION_ID, GRANT_SERVCE_LOW_PERMS); + }); + + it("will not grant if the user has access to a different service", async () => { + await doesAssert(check, ROOM_ID, CONNECTION_ID, GRANT_WRONG_SERVCE_USER); + }); + }); }); diff --git a/tests/init.ts b/tests/init.ts index b08b3350b..d1d9ee532 100644 --- a/tests/init.ts +++ b/tests/init.ts @@ -1,2 +1,2 @@ import { Logger } from "matrix-appservice-bridge"; -Logger.configure({console: "info"}); +Logger.configure({ console: "info" }); diff --git a/tests/jira/Utils.ts b/tests/jira/Utils.ts index e9e3fb2f4..18e6d70ef 100644 --- a/tests/jira/Utils.ts +++ b/tests/jira/Utils.ts @@ -1,30 +1,39 @@ import { expect } from "chai"; -import { generateJiraWebLinkFromIssue, generateJiraWebLinkFromVersion } from "../../src/jira"; +import { + generateJiraWebLinkFromIssue, + generateJiraWebLinkFromVersion, +} from "../../src/jira"; describe("Jira", () => { - describe("Utils", () => { - it("processes a jira issue into a URL", () => { - expect(generateJiraWebLinkFromIssue({ - self: "https://my-test-jira/", - key: "TEST-111", - })).to.equal("https://my-test-jira/browse/TEST-111"); - }); + describe("Utils", () => { + it("processes a jira issue into a URL", () => { + expect( + generateJiraWebLinkFromIssue({ + self: "https://my-test-jira/", + key: "TEST-111", + }), + ).to.equal("https://my-test-jira/browse/TEST-111"); + }); - it("processes a jira issue into a URL with a port", () => { - expect(generateJiraWebLinkFromIssue({ - self: "https://my-test-jira:9995/", - key: "TEST-111", - })).to.equal("https://my-test-jira:9995/browse/TEST-111"); - }); + it("processes a jira issue into a URL with a port", () => { + expect( + generateJiraWebLinkFromIssue({ + self: "https://my-test-jira:9995/", + key: "TEST-111", + }), + ).to.equal("https://my-test-jira:9995/browse/TEST-111"); + }); - it("processes a jira issue into a URL with a port and a version", () => { - expect(generateJiraWebLinkFromVersion({ - self: "https://my-test-jira:9995/", - description: "foo", - name: "bar", - projectId: "TEST-111", - id: "v1.0.0", - })).to.equal("https://my-test-jira:9995/projects/TEST-111/versions/v1.0.0"); - }); + it("processes a jira issue into a URL with a port and a version", () => { + expect( + generateJiraWebLinkFromVersion({ + self: "https://my-test-jira:9995/", + description: "foo", + name: "bar", + projectId: "TEST-111", + id: "v1.0.0", + }), + ).to.equal("https://my-test-jira:9995/projects/TEST-111/versions/v1.0.0"); }); -}); \ No newline at end of file + }); +}); diff --git a/tests/tokens/tokenencryption.spec.ts b/tests/tokens/tokenencryption.spec.ts index d79ccea85..780111e70 100644 --- a/tests/tokens/tokenencryption.spec.ts +++ b/tests/tokens/tokenencryption.spec.ts @@ -3,89 +3,113 @@ import { RSAKeyPairOptions, generateKeyPair, publicEncrypt } from "node:crypto"; import { expect } from "chai"; describe("TokenEncryption", () => { - let keyPromise: Promise; - let keyPromisePKCS1: Promise; - async function createTokenEncryption() { - return new TokenEncryption(await keyPromise); - } + let keyPromise: Promise; + let keyPromisePKCS1: Promise; + async function createTokenEncryption() { + return new TokenEncryption(await keyPromise); + } - async function legacyEncryptFunction(token: string) { - const MAX_TOKEN_PART_SIZE = 128; - const tokenParts: string[] = []; - let tokenSource = token; - while (tokenSource && tokenSource.length > 0) { - const part = tokenSource.slice(0, MAX_TOKEN_PART_SIZE); - tokenSource = tokenSource.substring(MAX_TOKEN_PART_SIZE); - tokenParts.push(publicEncrypt(await keyPromise, Buffer.from(part)).toString("base64")); - } - return tokenParts; + async function legacyEncryptFunction(token: string) { + const MAX_TOKEN_PART_SIZE = 128; + const tokenParts: string[] = []; + let tokenSource = token; + while (tokenSource && tokenSource.length > 0) { + const part = tokenSource.slice(0, MAX_TOKEN_PART_SIZE); + tokenSource = tokenSource.substring(MAX_TOKEN_PART_SIZE); + tokenParts.push( + publicEncrypt(await keyPromise, Buffer.from(part)).toString("base64"), + ); } - - before('generate RSA key', () => { - // Generate this once since it will take an age. - keyPromise = new Promise((resolve, reject) => generateKeyPair("rsa", { - // Deliberately shorter length to speed up test - modulusLength: 2048, - privateKeyEncoding: { - type: "pkcs8", - format: "pem", - }, - publicKeyEncoding: { - format: "pem", - type: "pkcs1", - } - } satisfies RSAKeyPairOptions<"pem", "pem">, (err, _, privateKey) => { - if (err) { reject(err) } else { resolve(Buffer.from(privateKey)) } - })); - keyPromisePKCS1 = new Promise((resolve, reject) => generateKeyPair("rsa", { - // Deliberately shorter length to speed up test - modulusLength: 2048, - privateKeyEncoding: { - type: "pkcs1", - format: "pem", - }, - publicKeyEncoding: { - format: "pem", - type: "pkcs1", - } - } satisfies RSAKeyPairOptions<"pem", "pem">, (err, _, privateKey) => { - if (err) { reject(err) } else { resolve(Buffer.from(privateKey)) } - })); + return tokenParts; + } - }, ); + before("generate RSA key", () => { + // Generate this once since it will take an age. + keyPromise = new Promise((resolve, reject) => + generateKeyPair( + "rsa", + { + // Deliberately shorter length to speed up test + modulusLength: 2048, + privateKeyEncoding: { + type: "pkcs8", + format: "pem", + }, + publicKeyEncoding: { + format: "pem", + type: "pkcs1", + }, + } satisfies RSAKeyPairOptions<"pem", "pem">, + (err, _, privateKey) => { + if (err) { + reject(err); + } else { + resolve(Buffer.from(privateKey)); + } + }, + ), + ); + keyPromisePKCS1 = new Promise((resolve, reject) => + generateKeyPair( + "rsa", + { + // Deliberately shorter length to speed up test + modulusLength: 2048, + privateKeyEncoding: { + type: "pkcs1", + format: "pem", + }, + publicKeyEncoding: { + format: "pem", + type: "pkcs1", + }, + } satisfies RSAKeyPairOptions<"pem", "pem">, + (err, _, privateKey) => { + if (err) { + reject(err); + } else { + resolve(Buffer.from(privateKey)); + } + }, + ), + ); + }); - it('should be able to encrypt a string into a single part', async() => { - const tokenEncryption = await createTokenEncryption(); - const result = tokenEncryption.encrypt('hello world'); - expect(result).to.have.lengthOf(1); - }); + it("should be able to encrypt a string into a single part", async () => { + const tokenEncryption = await createTokenEncryption(); + const result = tokenEncryption.encrypt("hello world"); + expect(result).to.have.lengthOf(1); + }); - it('should be able to decrypt from a single part into a string', async() => { - const tokenEncryption = await createTokenEncryption(); - const value = tokenEncryption.encrypt('hello world'); - const result = tokenEncryption.decrypt(value, Algo.RSAPKCS1v15); - expect(result).to.equal('hello world'); - }); + it("should be able to decrypt from a single part into a string", async () => { + const tokenEncryption = await createTokenEncryption(); + const value = tokenEncryption.encrypt("hello world"); + const result = tokenEncryption.decrypt(value, Algo.RSAPKCS1v15); + expect(result).to.equal("hello world"); + }); - it('should be able to decrypt from many parts into string', async() => { - const plaintext = 'This is a very long string that needs to be encoded into multiple parts in order for us to store it properly. This ' + - ' should end up as multiple encrypted values in base64.'; - const tokenEncryption = await createTokenEncryption(); - const value = tokenEncryption.encrypt(plaintext); - expect(value).to.have.lengthOf(2); - const result = tokenEncryption.decrypt(value, Algo.RSAPKCS1v15); - expect(result).to.equal(plaintext); - }); + it("should be able to decrypt from many parts into string", async () => { + const plaintext = + "This is a very long string that needs to be encoded into multiple parts in order for us to store it properly. This " + + " should end up as multiple encrypted values in base64."; + const tokenEncryption = await createTokenEncryption(); + const value = tokenEncryption.encrypt(plaintext); + expect(value).to.have.lengthOf(2); + const result = tokenEncryption.decrypt(value, Algo.RSAPKCS1v15); + expect(result).to.equal(plaintext); + }); - it('should support pkcs1 format keys', async() => { - const tokenEncryption = new TokenEncryption(await keyPromisePKCS1); - const result = tokenEncryption.encrypt('hello world'); - expect(result).to.have.lengthOf(1); - }); + it("should support pkcs1 format keys", async () => { + const tokenEncryption = new TokenEncryption(await keyPromisePKCS1); + const result = tokenEncryption.encrypt("hello world"); + expect(result).to.have.lengthOf(1); + }); - it('should be to decrypt a string from the old crypto implementation', async() => { - const legacyString = await legacyEncryptFunction('hello world'); - const tokenEncryption = await createTokenEncryption(); - expect(tokenEncryption.decrypt(legacyString, Algo.RSAOAEP)).to.equal('hello world'); - }); + it("should be to decrypt a string from the old crypto implementation", async () => { + const legacyString = await legacyEncryptFunction("hello world"); + const tokenEncryption = await createTokenEncryption(); + expect(tokenEncryption.decrypt(legacyString, Algo.RSAOAEP)).to.equal( + "hello world", + ); + }); }); diff --git a/tests/utils/AppserviceMock.ts b/tests/utils/AppserviceMock.ts index c32dc3637..3cc739fba 100644 --- a/tests/utils/AppserviceMock.ts +++ b/tests/utils/AppserviceMock.ts @@ -1,35 +1,35 @@ import { IntentMock } from "./IntentMock"; export class AppserviceMock { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + public readonly intentMap = new Map(); + public readonly botIntent = IntentMock.create(`@bot:example.com`); + public namespace = "@hookshot_"; + + static create() { // eslint-disable-next-line @typescript-eslint/no-explicit-any - public readonly intentMap = new Map(); - public readonly botIntent = IntentMock.create(`@bot:example.com`); - public namespace = "@hookshot_"; - - static create(){ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return new this() as any; - } + return new this() as any; + } - get botUserId() { - return this.botIntent.userId; - } + get botUserId() { + return this.botIntent.userId; + } - get botClient() { - return this.botIntent.underlyingClient; - } + get botClient() { + return this.botIntent.underlyingClient; + } - public getIntentForUserId(userId: string) { - let intent = this.intentMap.get(userId); - if (intent) { - return intent; - } - intent = IntentMock.create(userId); - this.intentMap.set(userId, intent); - return intent; + public getIntentForUserId(userId: string) { + let intent = this.intentMap.get(userId); + if (intent) { + return intent; } + intent = IntentMock.create(userId); + this.intentMap.set(userId, intent); + return intent; + } - public isNamespacedUser(userId: string) { - return userId.startsWith(this.namespace); - } + public isNamespacedUser(userId: string) { + return userId.startsWith(this.namespace); + } } diff --git a/tests/utils/IntentMock.ts b/tests/utils/IntentMock.ts index d93fc68ad..c6a810d61 100644 --- a/tests/utils/IntentMock.ts +++ b/tests/utils/IntentMock.ts @@ -1,124 +1,146 @@ - import { expect } from "chai"; import { MatrixError } from "matrix-bot-sdk"; import { MatrixCapabilities } from "matrix-bot-sdk/lib/models/Capabilities"; export class MatrixClientMock { - - static create(){ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return new this() as any; - } - - // map room Id → user Ids - private joinedMembers: Map = new Map(); - public readonly roomAccountData: Map = new Map(); - - async setDisplayName() { - return; + static create() { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return new this() as any; + } + + // map room Id → user Ids + private joinedMembers: Map = new Map(); + public readonly roomAccountData: Map = new Map(); + + async setDisplayName() { + return; + } + + async getCapabilities(): Promise { + return { + "m.set_displayname": { + enabled: true, + }, + "m.set_avatar_url": { + enabled: true, + }, + }; + } + + async getJoinedRoomMembers(roomId: string): Promise { + return this.joinedMembers.get(roomId) || []; + } + + async inviteUser(userId: string, roomId: string): Promise { + const roomMembers = this.joinedMembers.get(roomId) || []; + + if (roomMembers.includes(userId)) { + throw new Error("User already in room"); } - async getCapabilities(): Promise { - return { - "m.set_displayname": { - enabled: true - }, - "m.set_avatar_url": { - enabled: true - }, - } - } + roomMembers.push(userId); + this.joinedMembers.set(roomId, roomMembers); + } - async getJoinedRoomMembers(roomId: string): Promise { - return this.joinedMembers.get(roomId) || []; - } - - async inviteUser(userId: string, roomId: string): Promise { - const roomMembers = this.joinedMembers.get(roomId) || []; - - if (roomMembers.includes(userId)) { - throw new Error("User already in room"); - } - - roomMembers.push(userId); - this.joinedMembers.set(roomId, roomMembers); - } - - async getRoomAccountData(key: string, roomId: string): Promise { - const data = this.roomAccountData.get(roomId+key); - if (data) { - return data; - } - throw new MatrixError({ - errcode: 'M_NOT_FOUND', - error: 'Test error: No account data', - }, 404, { }); - } - - async setRoomAccountData(key: string, roomId: string, value: string): Promise { - this.roomAccountData.set(roomId+key, value); + async getRoomAccountData(key: string, roomId: string): Promise { + const data = this.roomAccountData.get(roomId + key); + if (data) { + return data; } + throw new MatrixError( + { + errcode: "M_NOT_FOUND", + error: "Test error: No account data", + }, + 404, + {}, + ); + } + + async setRoomAccountData( + key: string, + roomId: string, + value: string, + ): Promise { + this.roomAccountData.set(roomId + key, value); + } } export class IntentMock { - public readonly underlyingClient = new MatrixClientMock(); - public sentEvents: {roomId: string, content: any}[] = []; - - constructor(readonly userId: string) {} - - static create(userId: string){ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return new this(userId) as any; - } - - sendText(roomId: string, noticeText: string, msgtype: string) { - this.sentEvents.push({ - roomId, - content: { - msgtype, - body: noticeText, - } - }); - } - - sendEvent(roomId: string, content: any): Promise { - this.sentEvents.push({ - roomId, - content, - }); - return Promise.resolve(`event_${this.sentEvents.length - 1}`); - } - - expectNoEvent() { - expect(this.sentEvents, 'Expected no events to be sent.').to.be.empty; + public readonly underlyingClient = new MatrixClientMock(); + public sentEvents: { roomId: string; content: any }[] = []; + + constructor(readonly userId: string) {} + + static create(userId: string) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return new this(userId) as any; + } + + sendText(roomId: string, noticeText: string, msgtype: string) { + this.sentEvents.push({ + roomId, + content: { + msgtype, + body: noticeText, + }, + }); + } + + sendEvent(roomId: string, content: any): Promise { + this.sentEvents.push({ + roomId, + content, + }); + return Promise.resolve(`event_${this.sentEvents.length - 1}`); + } + + expectNoEvent() { + expect(this.sentEvents, "Expected no events to be sent.").to.be.empty; + } + + expectEventBodyContains(matcher: string | RegExp, eventIndex?: number) { + if (eventIndex !== undefined) { + expect( + this.sentEvents[eventIndex], + `Expected event ${eventIndex} to exist`, + ).to.not.be.undefined; + const body = this.sentEvents[eventIndex].content.body; + expect( + body.includes(matcher), + `Expected event body ${eventIndex} to match '${matcher}'.\nMessage was: '${body}'`, + ).to.be.true; + return; } - - expectEventBodyContains(matcher: string|RegExp, eventIndex?: number) { - if (eventIndex !== undefined) { - expect(this.sentEvents[eventIndex], `Expected event ${eventIndex} to exist`).to.not.be.undefined; - const body = this.sentEvents[eventIndex].content.body; - expect( - body.includes(matcher), - `Expected event body ${eventIndex} to match '${matcher}'.\nMessage was: '${body}'` - ).to.be.true; - return; - } - expect(!!this.sentEvents.find(ev => ev.content.body.includes(matcher)), `Expected any event body to match '${matcher}'`).to.be.true; - } - - expectEventMatches(matcher: (content: any) => boolean, description: string, eventIndex?: number) { - if (eventIndex !== undefined) { - expect(this.sentEvents[eventIndex], `Expected event ${eventIndex} to exist`).to.not.be.undefined; - expect(matcher(this.sentEvents[eventIndex]), description).to.be.true; - return; - } - expect(this.sentEvents.some(ev => matcher(ev)), description).to.be.true; - } - - async ensureJoined() { - return true; - } - - async ensureRegistered() { - return true; + expect( + !!this.sentEvents.find((ev) => ev.content.body.includes(matcher)), + `Expected any event body to match '${matcher}'`, + ).to.be.true; + } + + expectEventMatches( + matcher: (content: any) => boolean, + description: string, + eventIndex?: number, + ) { + if (eventIndex !== undefined) { + expect( + this.sentEvents[eventIndex], + `Expected event ${eventIndex} to exist`, + ).to.not.be.undefined; + expect(matcher(this.sentEvents[eventIndex]), description).to.be.true; + return; } + expect( + this.sentEvents.some((ev) => matcher(ev)), + description, + ).to.be.true; + } + + async ensureJoined() { + return true; + } + + async ensureRegistered() { + return true; + } } diff --git a/web/App.tsx b/web/App.tsx index e19581c5b..9638d707a 100644 --- a/web/App.tsx +++ b/web/App.tsx @@ -1,150 +1,207 @@ -import { Component } from 'preact'; -import WA, { MatrixCapabilities } from 'matrix-widget-api'; -import { BridgeAPI, BridgeAPIError, EmbedType, embedTypeParameter } from './BridgeAPI'; -import { BridgeRoomState } from '../src/Widgets/BridgeWidgetInterface'; -import { LoadingSpinner } from './components/elements/LoadingSpinner'; -import AdminSettings from './components/AdminSettings'; -import RoomConfigView from './components/RoomConfigView'; -import { Alert } from '@vector-im/compound-web'; -import { BridgeContext } from './context'; +import { Component } from "preact"; +import WA, { MatrixCapabilities } from "matrix-widget-api"; +import { + BridgeAPI, + BridgeAPIError, + EmbedType, + embedTypeParameter, +} from "./BridgeAPI"; +import { BridgeRoomState } from "../src/Widgets/BridgeWidgetInterface"; +import { LoadingSpinner } from "./components/elements/LoadingSpinner"; +import AdminSettings from "./components/AdminSettings"; +import RoomConfigView from "./components/RoomConfigView"; +import { Alert } from "@vector-im/compound-web"; +import { BridgeContext } from "./context"; interface IMinimalState { - error: string|null, - busy: boolean, + error: string | null; + busy: boolean; } interface ICompleteState extends IMinimalState { - roomId: string, - userId: string, - roomState: BridgeRoomState, - supportedServices: { - [sectionName: string]: boolean; - }, - serviceScope?: string, - embedType: EmbedType, - kind: "invite"|"admin"|"roomConfig", - widgetApi: WA.WidgetApi, - bridgeApi: BridgeAPI, + roomId: string; + userId: string; + roomState: BridgeRoomState; + supportedServices: { + [sectionName: string]: boolean; + }; + serviceScope?: string; + embedType: EmbedType; + kind: "invite" | "admin" | "roomConfig"; + widgetApi: WA.WidgetApi; + bridgeApi: BridgeAPI; } -type IState = IMinimalState|ICompleteState; +type IState = IMinimalState | ICompleteState; function parseFragment() { - const fragmentString = (window.location.hash || "?"); - return new URLSearchParams(fragmentString.substring(Math.max(fragmentString.indexOf('?'), 0))); + const fragmentString = window.location.hash || "?"; + return new URLSearchParams( + fragmentString.substring(Math.max(fragmentString.indexOf("?"), 0)), + ); } function assertParam(fragment: URLSearchParams, name: string) { - const val = fragment.get(name); - if (!val) throw new Error(`${name} is not present in URL - cannot load widget`); - return val; + const val = fragment.get(name); + if (!val) + throw new Error(`${name} is not present in URL - cannot load widget`); + return val; } export default class App extends Component { constructor() { super(); this.state = { - error: null, - busy: true, + error: null, + busy: true, }; } async componentDidMount() { try { - // Start widgeting - const qs = parseFragment(); - const widgetId = assertParam(qs, 'widgetId'); - const roomId = assertParam(qs, 'roomId'); - const widgetKind = qs.get('kind') as "invite"|"admin"|"roomConfig"; - const serviceScope = qs.get('serviceScope'); - const embedType = qs.get(embedTypeParameter); - // Fetch via config. - const widgetApi = new WA.WidgetApi(widgetId, '*'); - widgetApi.requestCapability(MatrixCapabilities.RequiresClient); - const widgetReady = new Promise((resolve, reject) => { - const timeout = setTimeout(() => reject(new Error('Communication timed out to your Matrix client. Your browser may be blocking access.')), 5000); - widgetApi.on("ready", () => { - clearTimeout(timeout); - resolve(); - }); + // Start widgeting + const qs = parseFragment(); + const widgetId = assertParam(qs, "widgetId"); + const roomId = assertParam(qs, "roomId"); + const widgetKind = qs.get("kind") as "invite" | "admin" | "roomConfig"; + const serviceScope = qs.get("serviceScope"); + const embedType = qs.get(embedTypeParameter); + // Fetch via config. + const widgetApi = new WA.WidgetApi(widgetId, "*"); + widgetApi.requestCapability(MatrixCapabilities.RequiresClient); + const widgetReady = new Promise((resolve, reject) => { + const timeout = setTimeout( + () => + reject( + new Error( + "Communication timed out to your Matrix client. Your browser may be blocking access.", + ), + ), + 5000, + ); + widgetApi.on("ready", () => { + clearTimeout(timeout); + resolve(); }); - widgetApi.on(`action:${WA.WidgetApiToWidgetAction.NotifyCapabilities}`, (ev) => { - console.log(`${WA.WidgetApiToWidgetAction.NotifyCapabilities}`, ev); - }) - widgetApi.on(`action:${WA.WidgetApiToWidgetAction.SendEvent}`, (ev) => { - console.log(`${WA.WidgetApiToWidgetAction.SendEvent}`, ev); - }) - // Start the widget as soon as possible too, otherwise the client might time us out. - widgetApi.start(); + }); + widgetApi.on( + `action:${WA.WidgetApiToWidgetAction.NotifyCapabilities}`, + (ev) => { + console.log(`${WA.WidgetApiToWidgetAction.NotifyCapabilities}`, ev); + }, + ); + widgetApi.on(`action:${WA.WidgetApiToWidgetAction.SendEvent}`, (ev) => { + console.log(`${WA.WidgetApiToWidgetAction.SendEvent}`, ev); + }); + // Start the widget as soon as possible too, otherwise the client might time us out. + widgetApi.start(); - // Assuming the hosted widget is on the same API path. - const widgetApiUrl = new URL(`${window.location.origin}${window.location.pathname.replace("/widgetapi/v1/static", "")}`); - const bridgeApi = await BridgeAPI.getBridgeAPI(widgetApiUrl.toString(), widgetApi); - const { userId } = await bridgeApi.verify(); - const roomState = widgetKind === "admin" ? await bridgeApi.state() : undefined; - const supportedServices = await bridgeApi.getEnabledConfigSections(); - await widgetReady; - this.setState({ - userId, - roomState, - roomId, - supportedServices, - serviceScope: serviceScope || undefined, - embedType: embedType === EmbedType.IntegrationManager ? EmbedType.IntegrationManager : EmbedType.Default, - kind: widgetKind, - busy: false, - widgetApi, - bridgeApi, - }); + // Assuming the hosted widget is on the same API path. + const widgetApiUrl = new URL( + `${window.location.origin}${window.location.pathname.replace("/widgetapi/v1/static", "")}`, + ); + const bridgeApi = await BridgeAPI.getBridgeAPI( + widgetApiUrl.toString(), + widgetApi, + ); + const { userId } = await bridgeApi.verify(); + const roomState = + widgetKind === "admin" ? await bridgeApi.state() : undefined; + const supportedServices = await bridgeApi.getEnabledConfigSections(); + await widgetReady; + this.setState({ + userId, + roomState, + roomId, + supportedServices, + serviceScope: serviceScope || undefined, + embedType: + embedType === EmbedType.IntegrationManager + ? EmbedType.IntegrationManager + : EmbedType.Default, + kind: widgetKind, + busy: false, + widgetApi, + bridgeApi, + }); } catch (ex) { - console.error(`Failed to set up widget:`, ex); - let error: string = ex.message; - if (ex instanceof BridgeAPIError) { - if (ex.errcode === "M_AS_BAD_OPENID") { - error = "Could not contact your homeserver. Your instance may be misconfigured."; - } + console.error(`Failed to set up widget:`, ex); + let error: string = ex.message; + if (ex instanceof BridgeAPIError) { + if (ex.errcode === "M_AS_BAD_OPENID") { + error = + "Could not contact your homeserver. Your instance may be misconfigured."; } - this.setState({ - error, - busy: false, - }); + } + this.setState({ + error, + busy: false, + }); } } - render() { - const style = { - padding: 'embedType' in this.state && this.state.embedType === EmbedType.IntegrationManager ? "0" : "16px", - }; - if (this.state.error) { - return
{this.state.error}
; - } else if (this.state.busy) { - return
; - } else if ("kind" in this.state === false) { - console.warn("invalid state", this.state); - return
Widget got into an invalid state.
; - } - - // Return the App component. - let content; + render() { + const style = { + padding: + "embedType" in this.state && + this.state.embedType === EmbedType.IntegrationManager + ? "0" + : "16px", + }; + if (this.state.error) { + return ( +
+ + {this.state.error} + +
+ ); + } else if (this.state.busy) { + return ( +
+ +
+ ); + } else if ("kind" in this.state === false) { + console.warn("invalid state", this.state); + return ( +
+ + Widget got into an invalid state. + +
+ ); + } - if (this.state.kind === "admin") { - content = ; - }else if (this.state.kind === "roomConfig") { - content = ; - } else { - return
Unknown widget kind.
; - } + // Return the App component. + let content; - return ( -
- - {content} - -
- ); + if (this.state.kind === "admin") { + content = ; + } else if (this.state.kind === "roomConfig") { + content = ( + + ); + } else { + return ( +
+ + Unknown widget kind. + +
+ ); } + + return ( +
+ + {content} + +
+ ); + } } diff --git a/web/BridgeAPI.ts b/web/BridgeAPI.ts index 80fbcd81c..f205d17d6 100644 --- a/web/BridgeAPI.ts +++ b/web/BridgeAPI.ts @@ -1,173 +1,246 @@ -import { BridgeRoomState, GetAuthPollResponse, GetAuthResponse, GetConnectionsForServiceResponse } from '../src/Widgets/BridgeWidgetInterface'; +import { + BridgeRoomState, + GetAuthPollResponse, + GetAuthResponse, + GetConnectionsForServiceResponse, +} from "../src/Widgets/BridgeWidgetInterface"; import { GetConnectionsResponseItem } from "../src/provisioning/api"; -import { ExchangeOpenAPIRequestBody, ExchangeOpenAPIResponseBody } from "matrix-appservice-bridge"; -import { WidgetApi } from 'matrix-widget-api'; -import { ApiError } from '../src/api'; -import { FunctionComponent } from 'preact'; -import { IConnectionState } from '../src/Connections'; +import { + ExchangeOpenAPIRequestBody, + ExchangeOpenAPIResponseBody, +} from "matrix-appservice-bridge"; +import { WidgetApi } from "matrix-widget-api"; +import { ApiError } from "../src/api"; +import { FunctionComponent } from "preact"; +import { IConnectionState } from "../src/Connections"; export class BridgeAPIError extends Error { - constructor(msg: string, public readonly body: ApiError) { - super(msg); - } - - public get errcode() { - return this.body.errcode as string; - } - public get error() { - return this.body.error as string; - } + constructor( + msg: string, + public readonly body: ApiError, + ) { + super(msg); + } + + public get errcode() { + return this.body.errcode as string; + } + public get error() { + return this.body.error as string; + } } interface RequestOpts { - abortController?: AbortController; + abortController?: AbortController; } export class BridgeAPI { - - static async getBridgeAPI(baseUrl: string, widgetApi: WidgetApi, storage = localStorage): Promise { + static async getBridgeAPI( + baseUrl: string, + widgetApi: WidgetApi, + storage = localStorage, + ): Promise { + try { + const sessionToken = storage.getItem("hookshot-sessionToken"); + baseUrl = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl; + if (sessionToken) { + const client = new BridgeAPI(baseUrl, sessionToken); try { - const sessionToken = storage.getItem('hookshot-sessionToken'); - baseUrl = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl; - if (sessionToken) { - const client = new BridgeAPI(baseUrl, sessionToken); - try { - await client.verify(); - return client; - } catch (ex) { - // TODO: Check that the token is actually invalid, rather than just assuming we need to refresh. - console.warn(`Failed to verify token, fetching new token`, ex); - storage.removeItem(sessionToken); - } - } + await client.verify(); + return client; } catch (ex) { - // E.g. Browser prevents storage access. - console.debug(`Failed to fetch session token, requesting new token`, ex); - } - - const creds = await widgetApi.requestOpenIDConnectToken(); - const { matrix_server_name, access_token } = creds; - if (!matrix_server_name || !access_token) { - throw Error('Server OpenID response missing values'); - } - - const res = await fetch(`${baseUrl}/widgetapi/v1/exchange_openid`, { - cache: 'no-cache', - method: 'POST', - body: JSON.stringify({ - matrixServer: matrix_server_name, - openIdToken: access_token, - } as ExchangeOpenAPIRequestBody), - headers: { - 'Content-Type': 'application/json', - }, - }); - if (res.status !== 200) { - if (res.headers.get('Content-Type')?.includes("application/json")) { - const resultBody = await res.json(); - throw new BridgeAPIError(resultBody?.error || 'Request failed', resultBody); - } else { - throw new Error(`API request failed: ${await res.text()}`, ); - } - } - const response = await res.json() as ExchangeOpenAPIResponseBody; - try { - storage.setItem('hookshot-sessionToken', response.token); - } catch (ex) { - // E.g. Browser prevents storage access. - console.debug(`Failed to store session token, continuing`, ex); - } - return new BridgeAPI(baseUrl, response.token); - } - - private constructor(private readonly baseUrl: string, private readonly accessToken?: string) { - this.baseUrl = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl; - } - - async request(method: string, endpoint: string, body?: unknown, opts?: RequestOpts) { - const res = await fetch(`${this.baseUrl}${endpoint}`, { - cache: 'no-cache', - signal: opts?.abortController?.signal, - method, - body: body ? JSON.stringify(body) : undefined, - headers: { - Authorization: `Bearer ${this.accessToken}`, - // Only set Content-Type if we send a body - ...(!!body && { - 'Content-Type': 'application/json', - }), - }, - }); - if (res.status === 204) { - return; - } - if (res.status === 200) { - return res.json(); + // TODO: Check that the token is actually invalid, rather than just assuming we need to refresh. + console.warn(`Failed to verify token, fetching new token`, ex); + storage.removeItem(sessionToken); } + } + } catch (ex) { + // E.g. Browser prevents storage access. + console.debug(`Failed to fetch session token, requesting new token`, ex); + } + + const creds = await widgetApi.requestOpenIDConnectToken(); + const { matrix_server_name, access_token } = creds; + if (!matrix_server_name || !access_token) { + throw Error("Server OpenID response missing values"); + } + + const res = await fetch(`${baseUrl}/widgetapi/v1/exchange_openid`, { + cache: "no-cache", + method: "POST", + body: JSON.stringify({ + matrixServer: matrix_server_name, + openIdToken: access_token, + } as ExchangeOpenAPIRequestBody), + headers: { + "Content-Type": "application/json", + }, + }); + if (res.status !== 200) { + if (res.headers.get("Content-Type")?.includes("application/json")) { const resultBody = await res.json(); - throw new BridgeAPIError(resultBody?.error || 'Request failed', resultBody); - } - - async verify(): Promise<{ userId: string, type: "widget" }> { - return this.request('GET', `/widgetapi/v1/session`); - } - - async state(): Promise { - return this.request('GET', `/widgetapi/v1/state`); - } - - async getEnabledConfigSections(): Promise<{[sectionName: string]: boolean}> { - return this.request('GET', '/widgetapi/v1/config/sections'); - } - - async getServiceConfig(service: string): Promise { - return this.request('GET', `/widgetapi/v1/service/${service}/config`); - } - - async getConnectionsForRoom(roomId: string): Promise { - return this.request('GET', `/widgetapi/v1/${encodeURIComponent(roomId)}/connections`); - } - - async getConnectionsForService(roomId: string, service: string): Promise> { - return this.request('GET', `/widgetapi/v1/${encodeURIComponent(roomId)}/connections/${encodeURIComponent(service)}`); - } - - async createConnection(roomId: string, type: string, config: IConnectionState): Promise { - return this.request('POST', `/widgetapi/v1/${encodeURIComponent(roomId)}/connections/${encodeURIComponent(type)}`, config); - } - - async updateConnection(roomId: string, connectionId: string, config: IConnectionState) { - return this.request('PUT', `/widgetapi/v1/${encodeURIComponent(roomId)}/connections/${encodeURIComponent(connectionId)}`, config); - } - - removeConnection(roomId: string, connectionId: string) { - return this.request('DELETE', `/widgetapi/v1/${encodeURIComponent(roomId)}/connections/${encodeURIComponent(connectionId)}`); - } - - getConnectionTargets(type: string, filters?: Record|Record, abortController?: AbortController): Promise { - const searchParams = filters && !!Object.keys(filters).length && new URLSearchParams(filters); - return this.request('GET', `/widgetapi/v1/targets/${encodeURIComponent(type)}${searchParams ? `?${searchParams}` : ''}`, undefined, { abortController }); - } - - async getAuth(service: string): Promise { - return this.request('GET', `/widgetapi/v1/service/${service}/auth`); - } - - async getAuthPoll(service: string, state: string): Promise { - return this.request('GET', `/widgetapi/v1/service/${service}/auth/${state}`); - } - - async serviceLogout(service: string): Promise { - return this.request('POST', `/widgetapi/v1/service/${service}/auth/logout`); - } + throw new BridgeAPIError( + resultBody?.error || "Request failed", + resultBody, + ); + } else { + throw new Error(`API request failed: ${await res.text()}`); + } + } + const response = (await res.json()) as ExchangeOpenAPIResponseBody; + try { + storage.setItem("hookshot-sessionToken", response.token); + } catch (ex) { + // E.g. Browser prevents storage access. + console.debug(`Failed to store session token, continuing`, ex); + } + return new BridgeAPI(baseUrl, response.token); + } + + private constructor( + private readonly baseUrl: string, + private readonly accessToken?: string, + ) { + this.baseUrl = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl; + } + + async request( + method: string, + endpoint: string, + body?: unknown, + opts?: RequestOpts, + ) { + const res = await fetch(`${this.baseUrl}${endpoint}`, { + cache: "no-cache", + signal: opts?.abortController?.signal, + method, + body: body ? JSON.stringify(body) : undefined, + headers: { + Authorization: `Bearer ${this.accessToken}`, + // Only set Content-Type if we send a body + ...(!!body && { + "Content-Type": "application/json", + }), + }, + }); + if (res.status === 204) { + return; + } + if (res.status === 200) { + return res.json(); + } + const resultBody = await res.json(); + throw new BridgeAPIError(resultBody?.error || "Request failed", resultBody); + } + + async verify(): Promise<{ userId: string; type: "widget" }> { + return this.request("GET", `/widgetapi/v1/session`); + } + + async state(): Promise { + return this.request("GET", `/widgetapi/v1/state`); + } + + async getEnabledConfigSections(): Promise<{ + [sectionName: string]: boolean; + }> { + return this.request("GET", "/widgetapi/v1/config/sections"); + } + + async getServiceConfig(service: string): Promise { + return this.request("GET", `/widgetapi/v1/service/${service}/config`); + } + + async getConnectionsForRoom( + roomId: string, + ): Promise { + return this.request( + "GET", + `/widgetapi/v1/${encodeURIComponent(roomId)}/connections`, + ); + } + + async getConnectionsForService( + roomId: string, + service: string, + ): Promise> { + return this.request( + "GET", + `/widgetapi/v1/${encodeURIComponent(roomId)}/connections/${encodeURIComponent(service)}`, + ); + } + + async createConnection( + roomId: string, + type: string, + config: IConnectionState, + ): Promise { + return this.request( + "POST", + `/widgetapi/v1/${encodeURIComponent(roomId)}/connections/${encodeURIComponent(type)}`, + config, + ); + } + + async updateConnection( + roomId: string, + connectionId: string, + config: IConnectionState, + ) { + return this.request( + "PUT", + `/widgetapi/v1/${encodeURIComponent(roomId)}/connections/${encodeURIComponent(connectionId)}`, + config, + ); + } + + removeConnection(roomId: string, connectionId: string) { + return this.request( + "DELETE", + `/widgetapi/v1/${encodeURIComponent(roomId)}/connections/${encodeURIComponent(connectionId)}`, + ); + } + + getConnectionTargets( + type: string, + filters?: Record | Record, + abortController?: AbortController, + ): Promise { + const searchParams = + filters && !!Object.keys(filters).length && new URLSearchParams(filters); + return this.request( + "GET", + `/widgetapi/v1/targets/${encodeURIComponent(type)}${searchParams ? `?${searchParams}` : ""}`, + undefined, + { abortController }, + ); + } + + async getAuth(service: string): Promise { + return this.request("GET", `/widgetapi/v1/service/${service}/auth`); + } + + async getAuthPoll( + service: string, + state: string, + ): Promise { + return this.request( + "GET", + `/widgetapi/v1/service/${service}/auth/${state}`, + ); + } + + async serviceLogout(service: string): Promise { + return this.request("POST", `/widgetapi/v1/service/${service}/auth/logout`); + } } -export const embedTypeParameter = 'io_element_embed_type'; +export const embedTypeParameter = "io_element_embed_type"; export enum EmbedType { - IntegrationManager = 'integration-manager', - Default = 'default', + IntegrationManager = "integration-manager", + Default = "default", } export type BridgeConfig = FunctionComponent<{ - roomId: string, - showHeader: boolean, + roomId: string; + showHeader: boolean; }>; diff --git a/web/components/AdminSettings.module.scss b/web/components/AdminSettings.module.scss index b81f8ced4..9efc354a8 100644 --- a/web/components/AdminSettings.module.scss +++ b/web/components/AdminSettings.module.scss @@ -1,58 +1,56 @@ - .root { - margin-left: 30px; - margin-right: 30px; - padding-left: 10vw; - padding-right: 10vw; - padding-top: 10vh; + margin-left: 30px; + margin-right: 30px; + padding-left: 10vw; + padding-right: 10vw; + padding-top: 10vh; } .root > .card { - max-width: 95%; + max-width: 95%; } .header { - text-align: left; - font-size: 24pt; - margin-bottom: 30px; + text-align: left; + font-size: 24pt; + margin-bottom: 30px; } .sidebar { - min-height: 60vh; - max-width: 10vw; - flex: 200px; + min-height: 60vh; + max-width: 10vw; + flex: 200px; } .content { - padding-left: 20px; - h2 { - font-size: 16pt; - } + padding-left: 20px; + h2 { + font-size: 16pt; + } } .contents { - margin-top: 10px; - display: flex; - flex-direction: row; + margin-top: 10px; + display: flex; + flex-direction: row; } .sidebar > ul { - list-style: none; + list-style: none; } .sidebar > ul > a > li { - border-radius: 5px; - margin-bottom: 5px; - padding: 0.3em; - + border-radius: 5px; + margin-bottom: 5px; + padding: 0.3em; } .sidebar > ul > a:hover { - text-decoration: none; - cursor: pointer; + text-decoration: none; + cursor: pointer; } .sidebar > ul > a > li.active { - background-color: var(--foreground-color); - color: var(--background-color); -} \ No newline at end of file + background-color: var(--foreground-color); + color: var(--background-color); +} diff --git a/web/components/AdminSettings.tsx b/web/components/AdminSettings.tsx index f192c7e58..69d399d5a 100644 --- a/web/components/AdminSettings.tsx +++ b/web/components/AdminSettings.tsx @@ -1,61 +1,130 @@ -import { useEffect, useState, useCallback } from 'preact/hooks'; +import { useEffect, useState, useCallback } from "preact/hooks"; import { LoadingSpinner } from "./elements/LoadingSpinner"; import { BridgeRoomState } from "../../src/Widgets/BridgeWidgetInterface"; -import GeneralConfig from './configs/GeneralConfig'; +import GeneralConfig from "./configs/GeneralConfig"; import style from "./AdminSettings.module.scss"; import { BridgeAPI } from "../BridgeAPI"; import GitHubState from "../components/GitHubState"; interface IProps { - roomState: BridgeRoomState; - bridgeApi: BridgeAPI; + roomState: BridgeRoomState; + bridgeApi: BridgeAPI; } enum AdminSettingsTab { - General = 0, - GitHub = 1, - GitLab = 2, - Jira = 3, - Figma = 4, + General = 0, + GitHub = 1, + GitLab = 2, + Jira = 3, + Figma = 4, } export default function AdminSettings(props: IProps) { - const [currentTab, setCurrentTab] = useState(AdminSettingsTab.General); - const [busy, setBusy] = useState(true); - const [activeSections, setActiveSections] = useState<{[sectionName: string]: boolean}>({}); - useEffect(() => { - props.bridgeApi.getEnabledConfigSections().then(sections => { - setActiveSections(sections); - }) - setBusy(false); - }, [setBusy, setActiveSections, props.bridgeApi]); - const onSectionClick = useCallback( - (event: MouseEvent) => { - const key = parseInt((event.target as HTMLElement).parentElement.getAttribute('sectionkey'), 10); - setCurrentTab(key as AdminSettingsTab); - }, - [setCurrentTab] + const [currentTab, setCurrentTab] = useState( + AdminSettingsTab.General, + ); + const [busy, setBusy] = useState(true); + const [activeSections, setActiveSections] = useState<{ + [sectionName: string]: boolean; + }>({}); + useEffect(() => { + props.bridgeApi.getEnabledConfigSections().then((sections) => { + setActiveSections(sections); + }); + setBusy(false); + }, [setBusy, setActiveSections, props.bridgeApi]); + const onSectionClick = useCallback( + (event: MouseEvent) => { + const key = parseInt( + (event.target as HTMLElement).parentElement.getAttribute("sectionkey"), + 10, + ); + setCurrentTab(key as AdminSettingsTab); + }, + [setCurrentTab], + ); + if (busy) { + return ( +
+ +
); - if (busy) { - return
- -
; - } - return
-

Hookshot Bridge settings

-
- -
- {currentTab === AdminSettingsTab.General && } - {currentTab === AdminSettingsTab.GitHub && } -
+ } + return ( +
+

Hookshot Bridge settings

+
+ +
+ {currentTab === AdminSettingsTab.General && } + {currentTab === AdminSettingsTab.GitHub && ( + + )}
-
; +
+
+ ); } diff --git a/web/components/ConnectionCard.module.scss b/web/components/ConnectionCard.module.scss index 3369c3325..b2b8120a1 100644 --- a/web/components/ConnectionCard.module.scss +++ b/web/components/ConnectionCard.module.scss @@ -1,50 +1,47 @@ - .card { - background: var(--cpd-color-bg-subtle-secondary); - border: 1px solid var(--cpd-color-border-interactive-secondary); - box-sizing: border-box; - border-radius: 8px; - - display: flex; - flex-direction: row; - align-items: flex-start; - padding: 12px; - margin-top: 10px; - - cursor: pointer; - - - @media (prefers-color-scheme: dark) { - img.invert { - filter: invert(100%); - } + background: var(--cpd-color-bg-subtle-secondary); + border: 1px solid var(--cpd-color-border-interactive-secondary); + box-sizing: border-box; + border-radius: 8px; + + display: flex; + flex-direction: row; + align-items: flex-start; + padding: 12px; + margin-top: 10px; + + cursor: pointer; + + @media (prefers-color-scheme: dark) { + img.invert { + filter: invert(100%); } - - img { - width: 52px; - height: 52px; + } + + img { + width: 52px; + height: 52px; + } + + div { + margin-left: 12px; + + p { + font-style: normal; + font-weight: 400; + font-size: 14px; + line-height: 15px; + display: flex; + align-items: center; + color: #737d8c; + margin-top: 5px; } - div { - - margin-left: 12px; - - p { - font-style: normal; - font-weight: 400; - font-size: 14px; - line-height: 15px; - display: flex; - align-items: center; - color: #737D8C; - margin-top: 5px; - } - - span { - font-style: normal; - font-weight: 600; - font-size: 18px; - line-height: 24px; - } + span { + font-style: normal; + font-weight: 600; + font-size: 18px; + line-height: 24px; } -} \ No newline at end of file + } +} diff --git a/web/components/ConnectionCard.tsx b/web/components/ConnectionCard.tsx index 711d872be..97c032b9c 100644 --- a/web/components/ConnectionCard.tsx +++ b/web/components/ConnectionCard.tsx @@ -1,20 +1,26 @@ import style from "./ConnectionCard.module.scss"; interface IProps { - imageSrc: string; - darkImage?: boolean; - serviceName: string; - description: string; - key: string; - onClick: () => void; + imageSrc: string; + darkImage?: boolean; + serviceName: string; + description: string; + key: string; + onClick: () => void; } export function ConnectionCard(props: IProps) { - return
- -
- {props.serviceName} -

{props.description}

-
-
; -} \ No newline at end of file + return ( +
+ +
+ {props.serviceName} +

{props.description}

+
+
+ ); +} diff --git a/web/components/GitHubState.css b/web/components/GitHubState.css index b2340c677..8c584ef1b 100644 --- a/web/components/GitHubState.css +++ b/web/components/GitHubState.css @@ -1,7 +1,7 @@ .login-card img { - height: 48px; + height: 48px; } .login-card span { - font-size: 1.5rem; -} \ No newline at end of file + font-size: 1.5rem; +} diff --git a/web/components/GitHubState.tsx b/web/components/GitHubState.tsx index 220d56211..ca63cbc6a 100644 --- a/web/components/GitHubState.tsx +++ b/web/components/GitHubState.tsx @@ -1,19 +1,23 @@ -import { FunctionComponent } from 'preact'; -import { BridgeRoomStateGitHub } from '../../src/Widgets/BridgeWidgetInterface'; +import { FunctionComponent } from "preact"; +import { BridgeRoomStateGitHub } from "../../src/Widgets/BridgeWidgetInterface"; import "./GitHubState.css"; -const GitHubState: FunctionComponent<{config: BridgeRoomStateGitHub}> = ({ config }) => { - return
-
-
- GitHub avatar -
-
- Logged in as {config.identity.name} -

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

-
+const GitHubState: FunctionComponent<{ config: BridgeRoomStateGitHub }> = ({ + config, +}) => { + return ( +
+
+
+ GitHub avatar
-
-} +
+ Logged in as {config.identity.name} +

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

+
+
+
+ ); +}; -export default GitHubState; \ No newline at end of file +export default GitHubState; diff --git a/web/components/RoomConfigView.module.scss b/web/components/RoomConfigView.module.scss index fbc78d8d8..0be2c8725 100644 --- a/web/components/RoomConfigView.module.scss +++ b/web/components/RoomConfigView.module.scss @@ -1,29 +1,29 @@ .root { - h1 { - font-style: normal; - font-weight: 600; - font-size: 24px; - line-height: 29px; - } + h1 { + font-style: normal; + font-weight: 600; + font-size: 24px; + line-height: 29px; + } - h2 { - font-size: 18px; - font-style: normal; - font-weight: 600; - line-height: 22px; - } + h2 { + font-size: 18px; + font-style: normal; + font-weight: 600; + line-height: 22px; + } - section { - margin-bottom: 25px; - } + section { + margin-bottom: 25px; + } - header { - // min-height: 3rem; - margin-bottom: 8px; - } + header { + // min-height: 3rem; + margin-bottom: 8px; + } } .backButton { - font-weight: 500; - cursor: pointer; -} \ No newline at end of file + font-weight: 500; + cursor: pointer; +} diff --git a/web/components/RoomConfigView.tsx b/web/components/RoomConfigView.tsx index dfe6976d2..d986da53e 100644 --- a/web/components/RoomConfigView.tsx +++ b/web/components/RoomConfigView.tsx @@ -1,5 +1,5 @@ -import { lazy, Suspense } from "preact/compat" -import { useState } from "preact/hooks" +import { lazy, Suspense } from "preact/compat"; +import { useState } from "preact/hooks"; import { BridgeConfig, EmbedType } from "../BridgeAPI"; import style from "./RoomConfigView.module.scss"; import { ConnectionCard } from "./ConnectionCard"; @@ -11,119 +11,140 @@ import WebhookIcon from "../icons/webhook.png"; import OpenProjectIcon from "../icons/openproject.png"; import { ChevronLeftIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; - interface IProps { - supportedServices: {[service: string]: boolean}, - serviceScope?: string, - embedType: EmbedType, - roomId: string, + supportedServices: { [service: string]: boolean }; + serviceScope?: string; + embedType: EmbedType; + roomId: string; } enum ConnectionType { - Feeds = "feeds", - Generic = "generic", - GenericOutbound = "genericOutbound", - Github = "github", - Gitlab = "gitlab", - Jira = "jira", - OpenProject = "openproject" + Feeds = "feeds", + Generic = "generic", + GenericOutbound = "genericOutbound", + Github = "github", + Gitlab = "gitlab", + Jira = "jira", + OpenProject = "openproject", } interface IConnectionProps { - displayName: string, - description: string, - icon: string, - darkIcon?: true, - component: BridgeConfig, + displayName: string; + description: string; + icon: string; + darkIcon?: true; + component: BridgeConfig; } const connections: Record = { - [ConnectionType.Feeds]: { - displayName: "RSS/Atom Feeds", - description: "Subscribe to an RSS/Atom feed", - icon: FeedsIcon, - component: lazy(() => import("./roomConfig/FeedsConfig")), - }, - [ConnectionType.Github]: { - displayName: 'Github', - description: "Connect the room to a GitHub project", - icon: GitHubIcon, - darkIcon: true, - component: lazy(() => import("./roomConfig/GithubRepoConfig")), - }, - [ConnectionType.Gitlab]: { - displayName: 'Gitlab', - description: "Connect the room to a GitLab project", - icon: GitLabIcon, - component: lazy(() => import("./roomConfig/GitlabRepoConfig")), - }, - [ConnectionType.Jira]: { - displayName: 'JIRA', - description: "Connect the room to a JIRA project", - icon: JiraIcon, - component: lazy(() => import("./roomConfig/JiraProjectConfig")), - }, - [ConnectionType.Generic]: { - displayName: 'Inbound (Generic) Webhook', - description: "Create a webhook which can be used to connect any service to Matrix", - icon: WebhookIcon, - darkIcon: true, - component: lazy(() => import("./roomConfig/GenericWebhookConfig")), - }, - [ConnectionType.GenericOutbound]: { - displayName: 'Outbound Webhook', - description: "Create a webhook which can be used to connect any service to Matrix", - icon: WebhookIcon, - darkIcon: true, - component: lazy(() => import("./roomConfig/OutboundWebhookConfig")), - }, - [ConnectionType.OpenProject]: { - displayName: 'OpenProject', - description: "Connect the room to OpenProject", - icon: OpenProjectIcon, - darkIcon: true, - component: lazy(() => import("./roomConfig/OpenProjectConfig")), - }, + [ConnectionType.Feeds]: { + displayName: "RSS/Atom Feeds", + description: "Subscribe to an RSS/Atom feed", + icon: FeedsIcon, + component: lazy(() => import("./roomConfig/FeedsConfig")), + }, + [ConnectionType.Github]: { + displayName: "Github", + description: "Connect the room to a GitHub project", + icon: GitHubIcon, + darkIcon: true, + component: lazy(() => import("./roomConfig/GithubRepoConfig")), + }, + [ConnectionType.Gitlab]: { + displayName: "Gitlab", + description: "Connect the room to a GitLab project", + icon: GitLabIcon, + component: lazy(() => import("./roomConfig/GitlabRepoConfig")), + }, + [ConnectionType.Jira]: { + displayName: "JIRA", + description: "Connect the room to a JIRA project", + icon: JiraIcon, + component: lazy(() => import("./roomConfig/JiraProjectConfig")), + }, + [ConnectionType.Generic]: { + displayName: "Inbound (Generic) Webhook", + description: + "Create a webhook which can be used to connect any service to Matrix", + icon: WebhookIcon, + darkIcon: true, + component: lazy(() => import("./roomConfig/GenericWebhookConfig")), + }, + [ConnectionType.GenericOutbound]: { + displayName: "Outbound Webhook", + description: + "Create a webhook which can be used to connect any service to Matrix", + icon: WebhookIcon, + darkIcon: true, + component: lazy(() => import("./roomConfig/OutboundWebhookConfig")), + }, + [ConnectionType.OpenProject]: { + displayName: "OpenProject", + description: "Connect the room to OpenProject", + icon: OpenProjectIcon, + darkIcon: true, + component: lazy(() => import("./roomConfig/OpenProjectConfig")), + }, }; export default function RoomConfigView(props: IProps) { - const serviceScope = props.serviceScope && props.supportedServices[props.serviceScope] ? props.serviceScope as ConnectionType : null; - const [ activeConnectionType, setActiveConnectionType ] = useState(serviceScope); + const serviceScope = + props.serviceScope && props.supportedServices[props.serviceScope] + ? (props.serviceScope as ConnectionType) + : null; + const [activeConnectionType, setActiveConnectionType] = + useState(serviceScope); - let content; + let content; - if (activeConnectionType) { - const ConfigComponent = connections[activeConnectionType].component; - content = - - ; - } else { - content = <> -
-

Integrations

- {(Object.keys(connections) as Array).filter(service => props.supportedServices[service]).map((connectionType: ConnectionType) => { - const connection = connections[connectionType]; - return setActiveConnectionType(connectionType)} - /> - })} -
- ; - } + if (activeConnectionType) { + const ConfigComponent = connections[activeConnectionType].component; + content = ( + + + + ); + } else { + content = ( + <> +
+

Integrations

+ {(Object.keys(connections) as Array) + .filter((service) => props.supportedServices[service]) + .map((connectionType: ConnectionType) => { + const connection = connections[connectionType]; + return ( + setActiveConnectionType(connectionType)} + /> + ); + })} +
+ + ); + } - return
- {!serviceScope && activeConnectionType && -
- setActiveConnectionType(null)}> - Browse integrations - -
- } - {content} -
; + return ( +
+ {!serviceScope && activeConnectionType && ( +
+ setActiveConnectionType(null)} + > + Browse integrations + +
+ )} + {content} +
+ ); } diff --git a/web/components/ServiceCard.module.scss b/web/components/ServiceCard.module.scss index 5d6eedd83..b0d3e95d3 100644 --- a/web/components/ServiceCard.module.scss +++ b/web/components/ServiceCard.module.scss @@ -1,16 +1,14 @@ - .serviceCard { - display: grid !important; - grid-template-columns: 0.6fr 1fr 1fr; - padding: 0.5rem; + display: grid !important; + grid-template-columns: 0.6fr 1fr 1fr; + padding: 0.5rem; - img { - width: 48px; - @media (prefers-color-scheme: dark) { - &.invert { - filter: invert(100%); - } - } + img { + width: 48px; + @media (prefers-color-scheme: dark) { + &.invert { + filter: invert(100%); + } } - -} \ No newline at end of file + } +} diff --git a/web/components/ServiceCard.tsx b/web/components/ServiceCard.tsx index 14c72ed00..902534210 100644 --- a/web/components/ServiceCard.tsx +++ b/web/components/ServiceCard.tsx @@ -1,13 +1,18 @@ import { FunctionComponent } from "preact"; import style from "./ServiceCard.module.scss"; - -export const ServiceCard: FunctionComponent<{serviceName: string, iconUrl: string, onConfigure: () => void}> = ({ serviceName, iconUrl, onConfigure }) => { - return
- -
- {serviceName} - -
-
; -}; \ No newline at end of file +export const ServiceCard: FunctionComponent<{ + serviceName: string; + iconUrl: string; + onConfigure: () => void; +}> = ({ serviceName, iconUrl, onConfigure }) => { + return ( +
+ +
+ {serviceName} + +
+
+ ); +}; diff --git a/web/components/configs/GeneralConfig.tsx b/web/components/configs/GeneralConfig.tsx index 1c648dc56..6069f6dcc 100644 --- a/web/components/configs/GeneralConfig.tsx +++ b/web/components/configs/GeneralConfig.tsx @@ -1,13 +1,15 @@ import { Button } from "../elements"; export default function GeneralConfig() { - return
-

General Configuration

-
-
-

Filters

-

You have no configured filters.

- -
-
; -} \ No newline at end of file + return ( +
+

General Configuration

+
+
+

Filters

+

You have no configured filters.

+ +
+
+ ); +} diff --git a/web/components/elements/Button.module.scss b/web/components/elements/Button.module.scss index 26fadb5e8..7e9120c62 100644 --- a/web/components/elements/Button.module.scss +++ b/web/components/elements/Button.module.scss @@ -1,29 +1,29 @@ .button { - background-color: var(--primary-color); - color: var(--background-color); - padding: 2px 10px; - border-radius: 5px; - border: none; - font-family: 'Inter'; - font-style: normal; - font-weight: 600; - font-size: 15px; - line-height: 24px; - min-width: 150px; - display: block; - padding: 4px 15px; - cursor: pointer; + background-color: var(--primary-color); + color: var(--background-color); + padding: 2px 10px; + border-radius: 5px; + border: none; + font-family: "Inter"; + font-style: normal; + font-weight: 600; + font-size: 15px; + line-height: 24px; + min-width: 150px; + display: block; + padding: 4px 15px; + cursor: pointer; } .button:disabled { - background-color: var(--primary-color-disabled); + background-color: var(--primary-color-disabled); } .remove { - color: #FF5B55; - background-color: transparent; + color: #ff5b55; + background-color: transparent; - &:disabled { - background-color: transparent; - } -} \ No newline at end of file + &:disabled { + background-color: transparent; + } +} diff --git a/web/components/elements/Button.tsx b/web/components/elements/Button.tsx index 9c1622994..a0221f527 100644 --- a/web/components/elements/Button.tsx +++ b/web/components/elements/Button.tsx @@ -2,13 +2,13 @@ import { FunctionComponent, h } from "preact"; import style from "./Button.module.scss"; interface ButtonProps extends h.JSX.HTMLAttributes { - intent?: "remove"; + intent?: "remove"; } export const Button: FunctionComponent = (props) => { - let className = style.button; - if (props.intent === "remove") { - className += ` ${style.remove}`; - } - return ; + const logoutOfService = useCallback(() => { + if (!authState.authenticated) { + // No need to do anything + return; } - return

- Logged in as {authState.user?.name ?? ''}. Logout -

; + api + .serviceLogout(service) + .then(() => { + onAuthSucceeded(); + }) + .catch((ex) => { + console.warn(`Failed to poll for state check`, ex); + }); + }, [api, onAuthSucceeded, service, authState]); + + if ("authUrl" in authState) { + return ; + } + return ( +

+ Logged in as {authState.user?.name ?? ""}.{" "} + + Logout + +

+ ); }; diff --git a/web/components/roomConfig/FeedConnection.module.scss b/web/components/roomConfig/FeedConnection.module.scss index f42cb608e..f9f3c548b 100644 --- a/web/components/roomConfig/FeedConnection.module.scss +++ b/web/components/roomConfig/FeedConnection.module.scss @@ -1,4 +1,4 @@ .resultListItem { - list-style: none; - padding-bottom: 1rem; -} \ No newline at end of file + list-style: none; + padding-bottom: 1rem; +} diff --git a/web/components/roomConfig/FeedsConfig.tsx b/web/components/roomConfig/FeedsConfig.tsx index 3808b7452..dba7abe71 100644 --- a/web/components/roomConfig/FeedsConfig.tsx +++ b/web/components/roomConfig/FeedsConfig.tsx @@ -1,108 +1,202 @@ import { FunctionComponent, createRef } from "preact"; -import { useCallback, useState } from "preact/hooks" +import { useCallback, useState } from "preact/hooks"; import { BridgeConfig } from "../../BridgeAPI"; -import { FeedConnectionState, FeedResponseItem } from "../../../src/Connections/FeedConnection"; -import { ConnectionConfigurationProps, IRoomConfigText, RoomConfig } from "./RoomConfig"; +import { + FeedConnectionState, + FeedResponseItem, +} from "../../../src/Connections/FeedConnection"; +import { + ConnectionConfigurationProps, + IRoomConfigText, + RoomConfig, +} from "./RoomConfig"; import { Button, ButtonSet, InputField } from "../elements"; import styles from "./FeedConnection.module.scss"; import FeedsIcon from "../../icons/feeds.png"; -const DEFAULT_TEMPLATE = "New post in $FEEDNAME: $LINK" +const DEFAULT_TEMPLATE = "New post in $FEEDNAME: $LINK"; -const FeedRecentResults: FunctionComponent<{item: FeedResponseItem}> = ({ item }) => { - if (!item.secrets) { - return null; - } - return <> -

Recent feed results

- {!item.secrets.lastResults.length && There have been no recent updates for this feed.} -
    - {item.secrets.lastResults.map(item =>
  • - {new Date(item.timestamp).toLocaleString()}: - {item.ok && `✅ Successful fetch`} - {!item.ok && `⚠️ ${item.error}`} -
  • )} -
- ; -} -const DOCUMENTATION_LINK = "https://matrix-org.github.io/matrix-hookshot/latest/setup/feeds.html#feed-templates"; - -const ConnectionConfiguration: FunctionComponent> = ({existingConnection, onSave, onRemove, isMigrationCandidate, isUpdating}) => { - const urlRef = createRef(); - const labelRef = createRef(); - const templateRef = createRef(); - const canSave = !existingConnection?.id || (existingConnection?.canEdit ?? false); - const canEdit = canSave && !isMigrationCandidate; - const [notifyOnFailure, setNotifyOnFailure] = useState(existingConnection?.config.notifyOnFailure ?? false); +const FeedRecentResults: FunctionComponent<{ item: FeedResponseItem }> = ({ + item, +}) => { + if (!item.secrets) { + return null; + } + return ( + <> +

Recent feed results

+ {!item.secrets.lastResults.length && ( + There have been no recent updates for this feed. + )} +
    + {item.secrets.lastResults.map((item) => ( +
  • + {new Date(item.timestamp).toLocaleString()}: + {item.ok && `✅ Successful fetch`} + {!item.ok && `⚠️ ${item.error}`} +
  • + ))} +
+ + ); +}; +const DOCUMENTATION_LINK = + "https://matrix-org.github.io/matrix-hookshot/latest/setup/feeds.html#feed-templates"; - const handleSave = useCallback((evt: Event) => { - evt.preventDefault(); - if (!canSave) { - return; - } - const url = urlRef?.current?.value || existingConnection?.config.url; - if (url) { - onSave({ - url, - label: labelRef?.current?.value || existingConnection?.config.label, - template: templateRef.current?.value || existingConnection?.config.template, - notifyOnFailure, - }) - } - }, [canSave, onSave, urlRef, labelRef, templateRef, notifyOnFailure, existingConnection]); +const ConnectionConfiguration: FunctionComponent< + ConnectionConfigurationProps< + ServiceConfig, + FeedResponseItem, + FeedConnectionState + > +> = ({ + existingConnection, + onSave, + onRemove, + isMigrationCandidate, + isUpdating, +}) => { + const urlRef = createRef(); + const labelRef = createRef(); + const templateRef = createRef(); + const canSave = + !existingConnection?.id || (existingConnection?.canEdit ?? false); + const canEdit = canSave && !isMigrationCandidate; + const [notifyOnFailure, setNotifyOnFailure] = useState( + existingConnection?.config.notifyOnFailure ?? false, + ); - const onlyVisibleOnExistingConnection = !!existingConnection; - + const handleSave = useCallback( + (evt: Event) => { + evt.preventDefault(); + if (!canSave) { + return; + } + const url = urlRef?.current?.value || existingConnection?.config.url; + if (url) { + onSave({ + url, + label: labelRef?.current?.value || existingConnection?.config.label, + template: + templateRef.current?.value || existingConnection?.config.template, + notifyOnFailure, + }); + } + }, + [ + canSave, + onSave, + urlRef, + labelRef, + templateRef, + notifyOnFailure, + existingConnection, + ], + ); - return
- { existingConnection && } + const onlyVisibleOnExistingConnection = !!existingConnection; - - - - - - - - setNotifyOnFailure(v => !v), [])} /> - - - -

See the documentation for help writing templates.

-
- - { canSave && } - { canEdit && existingConnection?.id && } - + return ( + + {existingConnection && } - ; + + + + + + + + setNotifyOnFailure((v) => !v), [])} + /> + + + +

+ {" "} + See the{" "} + + documentation + {" "} + for help writing templates.{" "} +

+
+ + {canSave && ( + + )} + {canEdit && existingConnection?.id && ( + + )} + + + ); }; interface ServiceConfig { - pollIntervalSeconds: number, + pollIntervalSeconds: number; } const roomConfigText: IRoomConfigText = { - header: 'RSS/Atom feeds', - createNew: 'Subscribe to a feed', - listCanEdit: 'Feeds subscribed to', - listCantEdit: 'Feeds subscribed to', + header: "RSS/Atom feeds", + createNew: "Subscribe to a feed", + listCanEdit: "Feeds subscribed to", + listCantEdit: "Feeds subscribed to", }; -const RoomConfigListItemFunc = (c: FeedResponseItem) => c.config.label || c.config.url; +const RoomConfigListItemFunc = (c: FeedResponseItem) => + c.config.label || c.config.url; const FeedsConfig: BridgeConfig = ({ roomId, showHeader }) => { - return - headerImg={FeedsIcon} - showHeader={showHeader} - roomId={roomId} - type="feeds" - connectionEventType="uk.half-shot.matrix-hookshot.feed" - text={roomConfigText} - listItemName={RoomConfigListItemFunc} - connectionConfigComponent={ConnectionConfiguration} - />; + return ( + + headerImg={FeedsIcon} + showHeader={showHeader} + roomId={roomId} + type="feeds" + connectionEventType="uk.half-shot.matrix-hookshot.feed" + text={roomConfigText} + listItemName={RoomConfigListItemFunc} + connectionConfigComponent={ConnectionConfiguration} + /> + ); }; -export default FeedsConfig; \ No newline at end of file +export default FeedsConfig; diff --git a/web/components/roomConfig/GenericWebhookConfig.tsx b/web/components/roomConfig/GenericWebhookConfig.tsx index f1b054011..60cc5a9f2 100644 --- a/web/components/roomConfig/GenericWebhookConfig.tsx +++ b/web/components/roomConfig/GenericWebhookConfig.tsx @@ -1,18 +1,24 @@ import { FunctionComponent, createRef } from "preact"; -import { useCallback, useEffect, useState } from "preact/hooks" +import { useCallback, useEffect, useState } from "preact/hooks"; import { add, format } from "date-fns"; import { BridgeConfig } from "../../BridgeAPI"; -import type { GenericHookConnectionState, GenericHookResponseItem, GenericHookServiceConfig } from "../../../src/Connections/GenericHook"; +import type { + GenericHookConnectionState, + GenericHookResponseItem, + GenericHookServiceConfig, +} from "../../../src/Connections/GenericHook"; import { ConnectionConfigurationProps, RoomConfig } from "./RoomConfig"; import { InputField, ButtonSet, Button } from "../elements"; import WebhookIcon from "../../icons/webhook.png"; import { Alert, ToggleInput } from "@vector-im/compound-web"; -import { InfoIcon, WarningIcon } from "@vector-im/compound-design-tokens/assets/web/icons" +import { + InfoIcon, + WarningIcon, +} from "@vector-im/compound-design-tokens/assets/web/icons"; import { lazy, Suspense } from "preact/compat"; import { LoadingSpinner } from "../elements/LoadingSpinner"; import { Extension } from "@uiw/react-codemirror"; - const CodeMirror = lazy(() => import("@uiw/react-codemirror")); const EXAMPLE_SCRIPT = `if (data.counter === undefined) { @@ -32,162 +38,288 @@ const EXAMPLE_SCRIPT = `if (data.counter === undefined) { }; }`; -const DOCUMENTATION_LINK = "https://matrix-org.github.io/matrix-hookshot/latest/setup/webhooks.html#script-api"; +const DOCUMENTATION_LINK = + "https://matrix-org.github.io/matrix-hookshot/latest/setup/webhooks.html#script-api"; const EXPIRY_WARN_AT_MS = 3 * 24 * 60 * 60 * 1000; -const CodeEditor: FunctionComponent<{value: string, onChange: (value: string) => void}> = ({value, onChange}) => { - const [codeMirrorTheme, setCodeMirrorTheme] = useState<"light"|"dark">("light"); - const [extensions, setExtensions] = useState(); - useEffect(() => { - const mm = window.matchMedia('(prefers-color-scheme: dark)'); - const fn = (event: MediaQueryListEvent) => { - setCodeMirrorTheme(event.matches ? "dark" : "light"); - }; - mm.addEventListener('change', fn); - setCodeMirrorTheme(mm.matches ? "dark" : "light"); - return () => mm.removeEventListener('change', fn); - }, []); - - useEffect(() => { - async function loader() { - const { javascript } = await import("@codemirror/lang-javascript"); - setExtensions([javascript({ jsx: false, typescript: false})]); - console.log('Extensions loaded'); - } - void loader(); - }, []); - - if (!extensions) { - return ; +const CodeEditor: FunctionComponent<{ + value: string; + onChange: (value: string) => void; +}> = ({ value, onChange }) => { + const [codeMirrorTheme, setCodeMirrorTheme] = useState<"light" | "dark">( + "light", + ); + const [extensions, setExtensions] = useState(); + useEffect(() => { + const mm = window.matchMedia("(prefers-color-scheme: dark)"); + const fn = (event: MediaQueryListEvent) => { + setCodeMirrorTheme(event.matches ? "dark" : "light"); + }; + mm.addEventListener("change", fn); + setCodeMirrorTheme(mm.matches ? "dark" : "light"); + return () => mm.removeEventListener("change", fn); + }, []); + + useEffect(() => { + async function loader() { + const { javascript } = await import("@codemirror/lang-javascript"); + setExtensions([javascript({ jsx: false, typescript: false })]); + console.log("Extensions loaded"); } + void loader(); + }, []); - return }> - -

See the documentation for help writing transformation functions

-
; -}; + if (!extensions) { + return ; + } -const ConnectionConfiguration: FunctionComponent> = ({serviceConfig, existingConnection, onSave, onRemove, isUpdating}) => { - const [transFn, setTransFn] = useState(existingConnection?.config.transformationFunction as string || EXAMPLE_SCRIPT); - const [transFnEnabled, setTransFnEnabled] = useState(serviceConfig.allowJsTransformationFunctions && !!existingConnection?.config.transformationFunction); - const [waitForComplete, setWaitForComplete] = useState(existingConnection?.config.waitForComplete ?? false); - - const minExpiryTime = format(add(new Date(), { hours: 1 }), "yyyy-MM-dd'T'HH:mm"); - const maxExpiryTime = serviceConfig.maxExpiryTime ? format(Date.now() + serviceConfig.maxExpiryTime, "yyyy-MM-dd'T'HH:mm") : undefined; - - const nameRef = createRef(); - const expiryRef = createRef(); - - const canEdit = !existingConnection || existingConnection?.canEdit || false; - const handleSave = useCallback((evt: Event) => { - evt.preventDefault(); - if (!canEdit) { - return; - } - onSave({ - name: nameRef?.current?.value || existingConnection?.config.name || "Generic Webhook", - expirationDate: expiryRef?.current?.value ? new Date(expiryRef?.current?.value).toISOString() : undefined, - waitForComplete, - ...(transFnEnabled ? { transformationFunction: transFn } : undefined), - }); - }, [expiryRef, canEdit, onSave, nameRef, transFn, existingConnection, transFnEnabled, waitForComplete]); - - const hasExpired = existingConnection?.secrets?.timeRemainingMs ? existingConnection?.secrets?.timeRemainingMs <= 0 : false; - const willExpireSoon = !hasExpired && existingConnection?.secrets?.timeRemainingMs ? existingConnection?.secrets?.timeRemainingMs <= EXPIRY_WARN_AT_MS : false; - - useEffect(() => { - if (!expiryRef.current || !existingConnection?.config.expirationDate) { - return; - } - expiryRef.current.valueAsDate = new Date(existingConnection.config.expirationDate); - }, [existingConnection, expiryRef]); - - return
- {hasExpired && - This Webhook has expired and will no longer handle any incoming requests. Please set a new expiry date or remove the Webhook. - } - {willExpireSoon && - This Webhook will expired soon will no longer handle any incoming requests. To extend the Webhook lifetime, set a new expiry date below. - } - - - - - - - - - - - - - - - setTransFnEnabled(v => !v), [])} /> - - - - - setWaitForComplete(v => !v), [])} /> - - {transFnEnabled && } - - { canEdit && } - { canEdit && existingConnection && } - - ; + return ( + }> + +

+ {" "} + See the{" "} + + documentation + {" "} + for help writing transformation functions{" "} +

+
+ ); }; +const ConnectionConfiguration: FunctionComponent< + ConnectionConfigurationProps< + GenericHookServiceConfig, + GenericHookResponseItem, + GenericHookConnectionState + > +> = ({ serviceConfig, existingConnection, onSave, onRemove, isUpdating }) => { + const [transFn, setTransFn] = useState( + (existingConnection?.config.transformationFunction as string) || + EXAMPLE_SCRIPT, + ); + const [transFnEnabled, setTransFnEnabled] = useState( + serviceConfig.allowJsTransformationFunctions && + !!existingConnection?.config.transformationFunction, + ); + const [waitForComplete, setWaitForComplete] = useState( + existingConnection?.config.waitForComplete ?? false, + ); + + const minExpiryTime = format( + add(new Date(), { hours: 1 }), + "yyyy-MM-dd'T'HH:mm", + ); + const maxExpiryTime = serviceConfig.maxExpiryTime + ? format(Date.now() + serviceConfig.maxExpiryTime, "yyyy-MM-dd'T'HH:mm") + : undefined; + + const nameRef = createRef(); + const expiryRef = createRef(); + + const canEdit = !existingConnection || existingConnection?.canEdit || false; + const handleSave = useCallback( + (evt: Event) => { + evt.preventDefault(); + if (!canEdit) { + return; + } + onSave({ + name: + nameRef?.current?.value || + existingConnection?.config.name || + "Generic Webhook", + expirationDate: expiryRef?.current?.value + ? new Date(expiryRef?.current?.value).toISOString() + : undefined, + waitForComplete, + ...(transFnEnabled ? { transformationFunction: transFn } : undefined), + }); + }, + [ + expiryRef, + canEdit, + onSave, + nameRef, + transFn, + existingConnection, + transFnEnabled, + waitForComplete, + ], + ); + + const hasExpired = existingConnection?.secrets?.timeRemainingMs + ? existingConnection?.secrets?.timeRemainingMs <= 0 + : false; + const willExpireSoon = + !hasExpired && existingConnection?.secrets?.timeRemainingMs + ? existingConnection?.secrets?.timeRemainingMs <= EXPIRY_WARN_AT_MS + : false; + + useEffect(() => { + if (!expiryRef.current || !existingConnection?.config.expirationDate) { + return; + } + expiryRef.current.valueAsDate = new Date( + existingConnection.config.expirationDate, + ); + }, [existingConnection, expiryRef]); + + return ( +
+ {hasExpired && ( + + This Webhook has expired and will no longer handle any incoming + requests. Please set a new expiry date or remove the + Webhook. + + )} + {willExpireSoon && ( + + This Webhook will expired soon will no longer handle any incoming + requests. To extend the Webhook lifetime, set a new expiry date below. + + )} + + + + + + + + + + + + + + + setTransFnEnabled((v) => !v), [])} + /> + + + + setWaitForComplete((v) => !v), [])} + /> + + {transFnEnabled && } + + {canEdit && ( + + )} + {canEdit && existingConnection && ( + + )} + + + ); +}; const RoomConfigText = { - header: 'Inbound (Generic) Webhooks', - createNew: 'Create new Webhook', - listCanEdit: 'Your Webhooks', - listCantEdit: 'Configured Webhooks', + header: "Inbound (Generic) Webhooks", + createNew: "Create new Webhook", + listCanEdit: "Your Webhooks", + listCantEdit: "Configured Webhooks", }; const RoomConfigListItemFunc = (c: GenericHookResponseItem) => { - const hasExpired = c.secrets?.timeRemainingMs ? c.secrets.timeRemainingMs <= 0 : false; - const willExpireSoon = !hasExpired && c.secrets?.timeRemainingMs ? c.secrets?.timeRemainingMs <= EXPIRY_WARN_AT_MS : false; - - return <> - {c.config.name} - - {hasExpired && } - {willExpireSoon && } - + const hasExpired = c.secrets?.timeRemainingMs + ? c.secrets.timeRemainingMs <= 0 + : false; + const willExpireSoon = + !hasExpired && c.secrets?.timeRemainingMs + ? c.secrets?.timeRemainingMs <= EXPIRY_WARN_AT_MS + : false; + + return ( + <> + {c.config.name} + + {hasExpired && } + {willExpireSoon && } + + ); }; export const GenericWebhookConfig: BridgeConfig = ({ roomId, showHeader }) => { - return - headerImg={WebhookIcon} - darkHeaderImg={true} - showHeader={showHeader} - roomId={roomId} - type="generic" - connectionEventType="uk.half-shot.matrix-hookshot.generic.hook" - text={RoomConfigText} - listItemName={RoomConfigListItemFunc} - connectionConfigComponent={ConnectionConfiguration} - />; + return ( + + headerImg={WebhookIcon} + darkHeaderImg={true} + showHeader={showHeader} + roomId={roomId} + type="generic" + connectionEventType="uk.half-shot.matrix-hookshot.generic.hook" + text={RoomConfigText} + listItemName={RoomConfigListItemFunc} + connectionConfigComponent={ConnectionConfiguration} + /> + ); }; -export default GenericWebhookConfig; \ No newline at end of file +export default GenericWebhookConfig; diff --git a/web/components/roomConfig/GithubRepoConfig.tsx b/web/components/roomConfig/GithubRepoConfig.tsx index e6a2d76d7..683751f21 100644 --- a/web/components/roomConfig/GithubRepoConfig.tsx +++ b/web/components/roomConfig/GithubRepoConfig.tsx @@ -1,11 +1,26 @@ import GitHubIcon from "../../icons/github.png"; import { BridgeConfig } from "../../BridgeAPI"; -import { ConnectionConfigurationProps, IRoomConfigText, RoomConfig } from "./RoomConfig"; -import { EventHookCheckbox } from '../elements/EventHookCheckbox'; +import { + ConnectionConfigurationProps, + IRoomConfigText, + RoomConfig, +} from "./RoomConfig"; +import { EventHookCheckbox } from "../elements/EventHookCheckbox"; import { FunctionComponent, createRef } from "preact"; -import { GitHubRepoConnectionState, GitHubRepoResponseItem, GitHubRepoConnectionRepoTarget, GitHubRepoConnectionOrgTarget } from "../../../src/Connections/GithubRepo"; +import { + GitHubRepoConnectionState, + GitHubRepoResponseItem, + GitHubRepoConnectionRepoTarget, + GitHubRepoConnectionOrgTarget, +} from "../../../src/Connections/GithubRepo"; import { InputField, ButtonSet, Button } from "../elements"; -import { useState, useCallback, useMemo, useEffect, useContext } from "preact/hooks"; +import { + useState, + useCallback, + useMemo, + useEffect, + useContext, +} from "preact/hooks"; import { DropItem } from "../elements/DropdownSearch"; import { ConnectionSearch } from "../elements/ConnectionSearch"; import { ServiceAuth } from "./Auth"; @@ -15,181 +30,452 @@ import { BridgeContext } from "../../context"; const EventType = "uk.half-shot.matrix-hookshot.github.repository"; function getRepoFullName(state: GitHubRepoConnectionState) { - return `${state.org}/${state.repo}`; + return `${state.org}/${state.repo}`; } -const ConnectionConfiguration: FunctionComponent> = ({ - showAuthPrompt, loginLabel, serviceConfig, existingConnection, onSave, onRemove, isUpdating +const ConnectionConfiguration: FunctionComponent< + ConnectionConfigurationProps< + never, + GitHubRepoResponseItem, + GitHubRepoConnectionState + > +> = ({ + showAuthPrompt, + loginLabel, + serviceConfig, + existingConnection, + onSave, + onRemove, + isUpdating, }) => { - // Assume true if we have no auth prompt. - const [authedResponse, setAuthResponse] = useState(null); - const [enabledHooks, setEnabledHooks] = useState(existingConnection?.config.enableHooks || []); - const api = useContext(BridgeContext).bridgeApi; - - const checkAuth = useCallback(() => { - api.getAuth("github").then((res) => { - setAuthResponse(res); - }).catch(ex => { - console.warn("Could not check authed state, assuming yes", ex); - setAuthResponse({ - authenticated: true, - user: { - name: 'Unknown' - } - }); - }) - }, [api]); - - useEffect(() => { - if (!showAuthPrompt) { - return; - } - checkAuth(); - }, [showAuthPrompt, checkAuth]) - - const { newInstallationUrl } = serviceConfig; - - - const toggleEnabledHook = useCallback((evt: any) => { - const key = (evt.target as HTMLElement).getAttribute('data-event-name'); - if (key) { - setEnabledHooks(enabledHooks => ( - enabledHooks.includes(key) ? enabledHooks.filter(k => k !== key) : [...enabledHooks, key] - )); - } - }, []); - - const [connectionState, setConnectionState] = useState(null); - - const canEdit = !existingConnection?.id || (existingConnection?.canEdit ?? false); - const commandPrefixRef = createRef(); - const handleSave = useCallback((evt: Event) => { - evt.preventDefault(); - if (!canEdit || !existingConnection && !connectionState) { - return; - } - const state = existingConnection?.config || connectionState; - if (state) { - onSave({ - ...(state), - enableHooks: enabledHooks as any[], - commandPrefix: commandPrefixRef.current?.value || commandPrefixRef.current?.placeholder, - }); - } - }, [enabledHooks, canEdit, existingConnection, connectionState, commandPrefixRef, onSave]); - - const getInstances = useMemo(() => async () => { - const targets = await api.getConnectionTargets(EventType, { }); - return targets; - }, [api]); - - const getProjects = useMemo(() => async (instance: string, search: string, abortController: AbortController) => { - const targets = await api.getConnectionTargets(EventType, { - ...(search && { search }), - orgName: instance, - }, abortController); - return targets.map(repo => ({ - description: repo.description, - imageSrc: repo.avatar, - title: repo.name, - value: repo.state.repo, - } as DropItem)); - }, [api]); - - const setInstance = useCallback((org: string, repo: string) => { - setConnectionState({ - org, - repo, - }) - },[setConnectionState]); - - const clearInstance = useCallback(() => setConnectionState(null), [setConnectionState]); - - const consideredAuthenticated = (authedResponse?.authenticated || !showAuthPrompt); - - return
- {authedResponse && } - {!existingConnection && consideredAuthenticated && } - - - - -

Choose which event should send a notification to the room

-
    - Issues -
      - Created - Changed - Edited - Labeled -
    - Issue Comments -
      - Created -
    - Pull requests -
      - Opened - Closed - Merged - Ready for review - Reviewed -
    - Pushed commits - Workflow Runs -
      - Success - Failed - Neutral - Cancelled - Timed Out - Action Required - Stale -
    - Releases -
      - Published - Drafted -
    -
-
- - { canEdit && consideredAuthenticated && } - { canEdit && existingConnection?.id && } - - ; + // Assume true if we have no auth prompt. + const [authedResponse, setAuthResponse] = useState( + null, + ); + const [enabledHooks, setEnabledHooks] = useState( + existingConnection?.config.enableHooks || [], + ); + const api = useContext(BridgeContext).bridgeApi; + + const checkAuth = useCallback(() => { + api + .getAuth("github") + .then((res) => { + setAuthResponse(res); + }) + .catch((ex) => { + console.warn("Could not check authed state, assuming yes", ex); + setAuthResponse({ + authenticated: true, + user: { + name: "Unknown", + }, + }); + }); + }, [api]); + + useEffect(() => { + if (!showAuthPrompt) { + return; + } + checkAuth(); + }, [showAuthPrompt, checkAuth]); + + const { newInstallationUrl } = serviceConfig; + + const toggleEnabledHook = useCallback((evt: any) => { + const key = (evt.target as HTMLElement).getAttribute("data-event-name"); + if (key) { + setEnabledHooks((enabledHooks) => + enabledHooks.includes(key) + ? enabledHooks.filter((k) => k !== key) + : [...enabledHooks, key], + ); + } + }, []); + + const [connectionState, setConnectionState] = + useState(null); + + const canEdit = + !existingConnection?.id || (existingConnection?.canEdit ?? false); + const commandPrefixRef = createRef(); + const handleSave = useCallback( + (evt: Event) => { + evt.preventDefault(); + if (!canEdit || (!existingConnection && !connectionState)) { + return; + } + const state = existingConnection?.config || connectionState; + if (state) { + onSave({ + ...state, + enableHooks: enabledHooks as any[], + commandPrefix: + commandPrefixRef.current?.value || + commandPrefixRef.current?.placeholder, + }); + } + }, + [ + enabledHooks, + canEdit, + existingConnection, + connectionState, + commandPrefixRef, + onSave, + ], + ); + + const getInstances = useMemo( + () => async () => { + const targets = + await api.getConnectionTargets( + EventType, + {}, + ); + return targets; + }, + [api], + ); + + const getProjects = useMemo( + () => + async ( + instance: string, + search: string, + abortController: AbortController, + ) => { + const targets = + await api.getConnectionTargets( + EventType, + { + ...(search && { search }), + orgName: instance, + }, + abortController, + ); + return targets.map( + (repo) => + ({ + description: repo.description, + imageSrc: repo.avatar, + title: repo.name, + value: repo.state.repo, + }) as DropItem, + ); + }, + [api], + ); + + const setInstance = useCallback( + (org: string, repo: string) => { + setConnectionState({ + org, + repo, + }); + }, + [setConnectionState], + ); + + const clearInstance = useCallback( + () => setConnectionState(null), + [setConnectionState], + ); + + const consideredAuthenticated = + authedResponse?.authenticated || !showAuthPrompt; + + return ( +
+ {authedResponse && ( + + )} + {!existingConnection && consideredAuthenticated && ( + + )} + + + + +

Choose which event should send a notification to the room

+
    + + Issues + +
      + + Created + + + Changed + + + Edited + + + Labeled + +
    + + Issue Comments + +
      + + Created + +
    + + Pull requests + +
      + + Opened + + + Closed + + + Merged + + + Ready for review + + + Reviewed + +
    + + Pushed commits + + + Workflow Runs + +
      + + Success + + + Failed + + + Neutral + + + Cancelled + + + Timed Out + + + Action Required + + + Stale + +
    + + Releases + +
      + + Published + + + Drafted + +
    +
+
+ + {canEdit && consideredAuthenticated && ( + + )} + {canEdit && existingConnection?.id && ( + + )} + + + ); }; const roomConfigText: IRoomConfigText = { - header: 'GitHub Repositories', - login: 'Log in to GitHub', - createNew: 'Add new GitHub repository', - listCanEdit: 'Your connected repositories', - listCantEdit: 'Connected repositories', + header: "GitHub Repositories", + login: "Log in to GitHub", + createNew: "Add new GitHub repository", + listCanEdit: "Your connected repositories", + listCantEdit: "Connected repositories", }; -const RoomConfigListItemFunc = (c: GitHubRepoResponseItem) => getRepoFullName(c.config); +const RoomConfigListItemFunc = (c: GitHubRepoResponseItem) => + getRepoFullName(c.config); const GithubRepoConfig: BridgeConfig = ({ roomId, showHeader }) => { - return - headerImg={GitHubIcon} - darkHeaderImg={true} - showHeader={showHeader} - roomId={roomId} - type="github" - showAuthPrompt={true} - text={roomConfigText} - listItemName={RoomConfigListItemFunc} - connectionEventType={EventType} - connectionConfigComponent={ConnectionConfiguration} - />; + return ( + + headerImg={GitHubIcon} + darkHeaderImg={true} + showHeader={showHeader} + roomId={roomId} + type="github" + showAuthPrompt={true} + text={roomConfigText} + listItemName={RoomConfigListItemFunc} + connectionEventType={EventType} + connectionConfigComponent={ConnectionConfiguration} + /> + ); }; -export default GithubRepoConfig; \ No newline at end of file +export default GithubRepoConfig; diff --git a/web/components/roomConfig/GitlabRepoConfig.tsx b/web/components/roomConfig/GitlabRepoConfig.tsx index 6d516219d..371a6cccb 100644 --- a/web/components/roomConfig/GitlabRepoConfig.tsx +++ b/web/components/roomConfig/GitlabRepoConfig.tsx @@ -1,8 +1,13 @@ import GitLabIcon from "../../icons/gitlab.png"; import { BridgeConfig } from "../../BridgeAPI"; import { ConnectionConfigurationProps, RoomConfig } from "./RoomConfig"; -import { EventHookCheckbox } from '../elements/EventHookCheckbox'; -import { GitLabRepoConnectionState, GitLabRepoResponseItem, GitLabRepoConnectionProjectTarget, GitLabRepoConnectionInstanceTarget } from "../../../src/Connections/GitlabRepo"; +import { EventHookCheckbox } from "../elements/EventHookCheckbox"; +import { + GitLabRepoConnectionState, + GitLabRepoResponseItem, + GitLabRepoConnectionProjectTarget, + GitLabRepoConnectionInstanceTarget, +} from "../../../src/Connections/GitlabRepo"; import { InputField, ButtonSet, Button } from "../elements"; import { FunctionComponent, createRef } from "preact"; import { useState, useCallback, useMemo, useContext } from "preact/hooks"; @@ -11,132 +16,324 @@ import { ConnectionSearch } from "../elements/ConnectionSearch"; import { BridgeContext } from "../../context"; const EventType = "uk.half-shot.matrix-hookshot.gitlab.repository"; -const ConnectionConfiguration: FunctionComponent> = ({existingConnection, onSave, onRemove, isUpdating }) => { - const [enabledHooks, setEnabledHooks] = useState(existingConnection?.config.enableHooks || []); - const api = useContext(BridgeContext).bridgeApi; +const ConnectionConfiguration: FunctionComponent< + ConnectionConfigurationProps< + never, + GitLabRepoResponseItem, + GitLabRepoConnectionState + > +> = ({ existingConnection, onSave, onRemove, isUpdating }) => { + const [enabledHooks, setEnabledHooks] = useState( + existingConnection?.config.enableHooks || [], + ); + const api = useContext(BridgeContext).bridgeApi; - const toggleEnabledHook = useCallback((evt: any) => { - const key = (evt.target as HTMLElement).getAttribute('data-event-name'); - if (key) { - setEnabledHooks(enabledHooks => ( - enabledHooks.includes(key) ? enabledHooks.filter(k => k !== key) : [...enabledHooks, key] - )); - } - }, []); + const toggleEnabledHook = useCallback((evt: any) => { + const key = (evt.target as HTMLElement).getAttribute("data-event-name"); + if (key) { + setEnabledHooks((enabledHooks) => + enabledHooks.includes(key) + ? enabledHooks.filter((k) => k !== key) + : [...enabledHooks, key], + ); + } + }, []); - const [newConnectionState, setNewConnectionState] = useState(null); + const [newConnectionState, setNewConnectionState] = + useState(null); - const canEdit = useMemo(() =>!existingConnection || (existingConnection?.canEdit ?? false), [existingConnection]); - const commandPrefixRef = createRef(); - const includeBodyRef = createRef(); - const handleSave = useCallback((evt: Event) => { - evt.preventDefault(); - if (!canEdit || !existingConnection && !newConnectionState) { - return; - } - const state = existingConnection?.config || newConnectionState; - if (state) { - onSave({ - ...(state), - enableHooks: enabledHooks as any[], - includeCommentBody: includeBodyRef.current?.checked, - commandPrefix: commandPrefixRef.current?.value || commandPrefixRef.current?.placeholder, - }); - } - }, [includeBodyRef, canEdit, existingConnection, newConnectionState, enabledHooks, commandPrefixRef, onSave]); + const canEdit = useMemo( + () => !existingConnection || (existingConnection?.canEdit ?? false), + [existingConnection], + ); + const commandPrefixRef = createRef(); + const includeBodyRef = createRef(); + const handleSave = useCallback( + (evt: Event) => { + evt.preventDefault(); + if (!canEdit || (!existingConnection && !newConnectionState)) { + return; + } + const state = existingConnection?.config || newConnectionState; + if (state) { + onSave({ + ...state, + enableHooks: enabledHooks as any[], + includeCommentBody: includeBodyRef.current?.checked, + commandPrefix: + commandPrefixRef.current?.value || + commandPrefixRef.current?.placeholder, + }); + } + }, + [ + includeBodyRef, + canEdit, + existingConnection, + newConnectionState, + enabledHooks, + commandPrefixRef, + onSave, + ], + ); - const getInstances = useMemo(() => async () => { - const targets = await api.getConnectionTargets(EventType, { }); - return targets; - }, [api]); + const getInstances = useMemo( + () => async () => { + const targets = + await api.getConnectionTargets( + EventType, + {}, + ); + return targets; + }, + [api], + ); - const getProjects = useMemo(() => async (instance: string, search?: string, abortController?: AbortController) => { - const targets = await api.getConnectionTargets(EventType, { - instance, - ...(search && { search }) - }, abortController); - return targets.map(project => ({ - description: project.description, - imageSrc: project.avatar_url, - title: project.name, - value: project.state.path, - } as DropItem)); - }, [api]); + const getProjects = useMemo( + () => + async ( + instance: string, + search?: string, + abortController?: AbortController, + ) => { + const targets = + await api.getConnectionTargets( + EventType, + { + instance, + ...(search && { search }), + }, + abortController, + ); + return targets.map( + (project) => + ({ + description: project.description, + imageSrc: project.avatar_url, + title: project.name, + value: project.state.path, + }) as DropItem, + ); + }, + [api], + ); - const setInstance = useCallback((instance: string, path: string) => { - setNewConnectionState({ - instance, - path, - }) - },[setNewConnectionState]); - const clearInstance = useCallback(() => setNewConnectionState(null), [setNewConnectionState]); + const setInstance = useCallback( + (instance: string, path: string) => { + setNewConnectionState({ + instance, + path, + }); + }, + [setNewConnectionState], + ); + const clearInstance = useCallback( + () => setNewConnectionState(null), + [setNewConnectionState], + ); - return
- {!existingConnection && } - - - - - - - - - - - - - -

Choose which event should send a notification to the room

-
    - Merge requests -
      - Opened - Reopened - Closed - Merged - Completed review - Single review - Ready for review -
    - Pushes - Tag pushes - Wiki page updates - Releases -
-
- - { canEdit && } - { canEdit && existingConnection && } - - ; + return ( +
+ {!existingConnection && ( + + )} + + + + + + + + + + + + + +

Choose which event should send a notification to the room

+
    + + Merge requests + +
      + + Opened + + + Reopened + + + Closed + + + Merged + + + Completed review + + + Single review + + + Ready for review + +
    + + Pushes + + + Tag pushes + + + Wiki page updates + + + Releases + +
+
+ + {canEdit && ( + + )} + {canEdit && existingConnection && ( + + )} + + + ); }; const RoomConfigText = { - header: 'GitLab Projects', - createNew: 'Add new GitLab project', - listCanEdit: 'Your connected projects', - listCantEdit: 'Connected projects', + header: "GitLab Projects", + createNew: "Add new GitLab project", + listCanEdit: "Your connected projects", + listCantEdit: "Connected projects", }; const RoomConfigListItemFunc = (c: GitLabRepoResponseItem) => c.config.path; const GitlabRepoConfig: BridgeConfig = ({ roomId, showHeader }) => { - return - headerImg={GitLabIcon} - showHeader={showHeader} - roomId={roomId} - type="gitlab" - text={RoomConfigText} - listItemName={RoomConfigListItemFunc} - connectionEventType={EventType} - connectionConfigComponent={ConnectionConfiguration} - />; + return ( + + headerImg={GitLabIcon} + showHeader={showHeader} + roomId={roomId} + type="gitlab" + text={RoomConfigText} + listItemName={RoomConfigListItemFunc} + connectionEventType={EventType} + connectionConfigComponent={ConnectionConfiguration} + /> + ); }; -export default GitlabRepoConfig; \ No newline at end of file +export default GitlabRepoConfig; diff --git a/web/components/roomConfig/JiraProjectConfig.tsx b/web/components/roomConfig/JiraProjectConfig.tsx index dbd10229c..ef56247d7 100644 --- a/web/components/roomConfig/JiraProjectConfig.tsx +++ b/web/components/roomConfig/JiraProjectConfig.tsx @@ -2,9 +2,14 @@ import { FunctionComponent, createRef } from "preact"; import { useState, useCallback, useMemo, useContext } from "preact/hooks"; import { BridgeConfig } from "../../BridgeAPI"; import { ConnectionConfigurationProps, RoomConfig } from "./RoomConfig"; -import type { JiraProjectConnectionState, JiraProjectResponseItem, JiraProjectConnectionProjectTarget, JiraProjectConnectionInstanceTarget } from "../../../src/Connections/JiraProject"; +import type { + JiraProjectConnectionState, + JiraProjectResponseItem, + JiraProjectConnectionProjectTarget, + JiraProjectConnectionInstanceTarget, +} from "../../../src/Connections/JiraProject"; import { InputField, ButtonSet, Button } from "../elements"; -import { EventHookCheckbox } from '../elements/EventHookCheckbox'; +import { EventHookCheckbox } from "../elements/EventHookCheckbox"; import JiraIcon from "../../icons/jira.png"; import { ConnectionSearch } from "../elements/ConnectionSearch"; import { DropItem } from "../elements/DropdownSearch"; @@ -12,115 +17,229 @@ import { BridgeContext } from "../../context"; const EventType = "uk.half-shot.matrix-hookshot.jira.project"; -const ConnectionConfiguration: FunctionComponent> = ({existingConnection, onSave, onRemove, isUpdating }) => { - const [allowedEvents, setAllowedEvents] = useState(existingConnection?.config.events || ['issue_created']); - const api = useContext(BridgeContext).bridgeApi; +const ConnectionConfiguration: FunctionComponent< + ConnectionConfigurationProps< + never, + JiraProjectResponseItem, + JiraProjectConnectionState + > +> = ({ existingConnection, onSave, onRemove, isUpdating }) => { + const [allowedEvents, setAllowedEvents] = useState( + existingConnection?.config.events || ["issue_created"], + ); + const api = useContext(BridgeContext).bridgeApi; - const toggleEvent = useCallback((evt: Event) => { - const key = (evt.target as HTMLElement).getAttribute('data-event-name'); - if (key) { - setAllowedEvents(allowedEvents => ( - allowedEvents.includes(key) ? allowedEvents.filter(k => k !== key) : [...allowedEvents, key] - )); - } - }, []); - const [newConnectionState, setNewConnectionState] = useState(null); + const toggleEvent = useCallback((evt: Event) => { + const key = (evt.target as HTMLElement).getAttribute("data-event-name"); + if (key) { + setAllowedEvents((allowedEvents) => + allowedEvents.includes(key) + ? allowedEvents.filter((k) => k !== key) + : [...allowedEvents, key], + ); + } + }, []); + const [newConnectionState, setNewConnectionState] = + useState(null); - const canEdit = !existingConnection || (existingConnection?.canEdit ?? false); - const commandPrefixRef = createRef(); - const handleSave = useCallback((evt: Event) => { - evt.preventDefault(); - if (!canEdit || !existingConnection && !newConnectionState) { - return; - } - const state = existingConnection?.config || newConnectionState; - if (state) { - onSave({ - ...(state), - events: allowedEvents as any[], - commandPrefix: commandPrefixRef.current?.value || commandPrefixRef.current?.placeholder, - }); - } - }, [canEdit, existingConnection, newConnectionState, allowedEvents, commandPrefixRef, onSave]); + const canEdit = !existingConnection || (existingConnection?.canEdit ?? false); + const commandPrefixRef = createRef(); + const handleSave = useCallback( + (evt: Event) => { + evt.preventDefault(); + if (!canEdit || (!existingConnection && !newConnectionState)) { + return; + } + const state = existingConnection?.config || newConnectionState; + if (state) { + onSave({ + ...state, + events: allowedEvents as any[], + commandPrefix: + commandPrefixRef.current?.value || + commandPrefixRef.current?.placeholder, + }); + } + }, + [ + canEdit, + existingConnection, + newConnectionState, + allowedEvents, + commandPrefixRef, + onSave, + ], + ); - const getInstances = useMemo(() => async () => { - const targets = await api.getConnectionTargets(EventType, { }); - return targets; - }, [api]); + const getInstances = useMemo( + () => async () => { + const targets = + await api.getConnectionTargets( + EventType, + {}, + ); + return targets; + }, + [api], + ); - const getProjects = useMemo(() => async (instanceName: string, search?: string, abortController?: AbortController) => { - const targets = await api.getConnectionTargets(EventType, { - instanceName, - ...(search && { search }) - }, abortController); - return targets.map(project => ({ - title: project.key, - description: project.name, - value: project.state.url, - } as DropItem)); - }, [api]); + const getProjects = useMemo( + () => + async ( + instanceName: string, + search?: string, + abortController?: AbortController, + ) => { + const targets = + await api.getConnectionTargets( + EventType, + { + instanceName, + ...(search && { search }), + }, + abortController, + ); + return targets.map( + (project) => + ({ + title: project.key, + description: project.name, + value: project.state.url, + }) as DropItem, + ); + }, + [api], + ); - const setInstance = useCallback((instance: string, url: string) => { - setNewConnectionState({ - url, - }) - },[setNewConnectionState]); - const clearInstance = useCallback(() => setNewConnectionState(null), [setNewConnectionState]); + const setInstance = useCallback( + (instance: string, url: string) => { + setNewConnectionState({ + url, + }); + }, + [setNewConnectionState], + ); + const clearInstance = useCallback( + () => setNewConnectionState(null), + [setNewConnectionState], + ); - return
- {!existingConnection && } - - - - -

Choose which event should send a notification to the room

-
    - Issues -
      - Created - Updated -
    - Versions -
      - Created - Updated - Released -
    -
-
- - { canEdit && } - { canEdit && existingConnection && } - - ; + return ( +
+ {!existingConnection && ( + + )} + + + + +

Choose which event should send a notification to the room

+
    + Issues +
      + + Created + + + Updated + +
    + Versions +
      + + Created + + + Updated + + + Released + +
    +
+
+ + {canEdit && ( + + )} + {canEdit && existingConnection && ( + + )} + + + ); }; const RoomConfigText = { - header: 'JIRA Projects', - createNew: 'Add new JIRA Project', - listCanEdit: 'Your connected projects', - listCantEdit: 'Connected projects', + header: "JIRA Projects", + createNew: "Add new JIRA Project", + listCanEdit: "Your connected projects", + listCantEdit: "Connected projects", }; const RoomConfigListItemFunc = (c: JiraProjectResponseItem) => c.config.url; const JiraProjectConfig: BridgeConfig = ({ roomId, showHeader }) => { - return - headerImg={JiraIcon} - showHeader={showHeader} - roomId={roomId} - type="jira" - text={RoomConfigText} - listItemName={RoomConfigListItemFunc} - connectionEventType={EventType} - connectionConfigComponent={ConnectionConfiguration} - />; + return ( + + headerImg={JiraIcon} + showHeader={showHeader} + roomId={roomId} + type="jira" + text={RoomConfigText} + listItemName={RoomConfigListItemFunc} + connectionEventType={EventType} + connectionConfigComponent={ConnectionConfiguration} + /> + ); }; export default JiraProjectConfig; diff --git a/web/components/roomConfig/OutboundWebhookConfig.tsx b/web/components/roomConfig/OutboundWebhookConfig.tsx index 0bf24d900..bc694b442 100644 --- a/web/components/roomConfig/OutboundWebhookConfig.tsx +++ b/web/components/roomConfig/OutboundWebhookConfig.tsx @@ -1,87 +1,148 @@ import { FunctionComponent, createRef } from "preact"; -import { useCallback, useState } from "preact/hooks" +import { useCallback, useState } from "preact/hooks"; import { BridgeConfig } from "../../BridgeAPI"; -import type { OutboundHookConnectionState, OutboundHookResponseItem } from "../../../src/Connections/OutboundHook"; +import type { + OutboundHookConnectionState, + OutboundHookResponseItem, +} from "../../../src/Connections/OutboundHook"; import { ConnectionConfigurationProps, RoomConfig } from "./RoomConfig"; import { InputField, ButtonSet, Button } from "../elements"; import WebhookIcon from "../../icons/webhook.png"; -const ConnectionConfiguration: FunctionComponent> = ({existingConnection, onSave, onRemove, isUpdating}) => { - const [outboundUrl, setOutboundUrl] = useState(existingConnection?.config.url ?? ''); - - const nameRef = createRef(); - - const canEdit = !existingConnection || (existingConnection?.canEdit ?? false); - const handleSave = useCallback((evt: Event) => { - evt.preventDefault(); - if (!canEdit) { - return; - } - onSave({ - name: nameRef?.current?.value || existingConnection?.config.name || "Generic Webhook", - url: outboundUrl, - }); - }, [canEdit, onSave, nameRef, outboundUrl, existingConnection]); - - const onUrlChange = useCallback((evt: any) => { - setOutboundUrl(evt.target?.value); - }, [setOutboundUrl]); - - const [tokenRevealed, setTokenRevealed] = useState(false); - - const revealToken = useCallback((evt: any) => { - evt.preventDefault(); - setTokenRevealed(true); - }, [setTokenRevealed]); - - - - return
- - - - - - - - - - - - - - { canEdit && } - { canEdit && existingConnection && } - -
; +const ConnectionConfiguration: FunctionComponent< + ConnectionConfigurationProps< + ServiceConfig, + OutboundHookResponseItem, + OutboundHookConnectionState + > +> = ({ existingConnection, onSave, onRemove, isUpdating }) => { + const [outboundUrl, setOutboundUrl] = useState( + existingConnection?.config.url ?? "", + ); + + const nameRef = createRef(); + + const canEdit = !existingConnection || (existingConnection?.canEdit ?? false); + const handleSave = useCallback( + (evt: Event) => { + evt.preventDefault(); + if (!canEdit) { + return; + } + onSave({ + name: + nameRef?.current?.value || + existingConnection?.config.name || + "Generic Webhook", + url: outboundUrl, + }); + }, + [canEdit, onSave, nameRef, outboundUrl, existingConnection], + ); + + const onUrlChange = useCallback( + (evt: any) => { + setOutboundUrl(evt.target?.value); + }, + [setOutboundUrl], + ); + + const [tokenRevealed, setTokenRevealed] = useState(false); + + const revealToken = useCallback( + (evt: any) => { + evt.preventDefault(); + setTokenRevealed(true); + }, + [setTokenRevealed], + ); + + return ( +
+ + + + + + + + + + + + + + {canEdit && ( + + )} + {canEdit && existingConnection && ( + + )} + +
+ ); }; interface ServiceConfig { - allowJsTransformationFunctions: boolean, - waitForComplete: boolean, + allowJsTransformationFunctions: boolean; + waitForComplete: boolean; } const RoomConfigText = { - header: 'Outbound Webhooks', - createNew: 'Create new webhook', - listCanEdit: 'Your webhooks', - listCantEdit: 'Configured webhooks', + header: "Outbound Webhooks", + createNew: "Create new webhook", + listCanEdit: "Your webhooks", + listCantEdit: "Configured webhooks", }; const RoomConfigListItemFunc = (c: OutboundHookResponseItem) => c.config.name; const OutboundWebhookConfig: BridgeConfig = ({ roomId, showHeader }) => { - return - headerImg={WebhookIcon} - darkHeaderImg={true} - showHeader={showHeader} - roomId={roomId} - type="genericOutbound" - connectionEventType="uk.half-shot.matrix-hookshot.outbound-hook" - text={RoomConfigText} - listItemName={RoomConfigListItemFunc} - connectionConfigComponent={ConnectionConfiguration} - />; + return ( + + headerImg={WebhookIcon} + darkHeaderImg={true} + showHeader={showHeader} + roomId={roomId} + type="genericOutbound" + connectionEventType="uk.half-shot.matrix-hookshot.outbound-hook" + text={RoomConfigText} + listItemName={RoomConfigListItemFunc} + connectionConfigComponent={ConnectionConfiguration} + /> + ); }; -export default OutboundWebhookConfig; \ No newline at end of file +export default OutboundWebhookConfig; diff --git a/web/components/roomConfig/RoomConfig.module.scss b/web/components/roomConfig/RoomConfig.module.scss index 98012cee6..1cff866af 100644 --- a/web/components/roomConfig/RoomConfig.module.scss +++ b/web/components/roomConfig/RoomConfig.module.scss @@ -1,23 +1,23 @@ .header { - display: flex; - flex-direction: row; - align-items: flex-start; + display: flex; + flex-direction: row; + align-items: flex-start; - img { - width: 52px; - height: 52px; - } + img { + width: 52px; + height: 52px; + } - @media (prefers-color-scheme: dark) { - img.invert { - filter: invert(100%); - } + @media (prefers-color-scheme: dark) { + img.invert { + filter: invert(100%); } + } - h1 { - margin-left: 10px; - font-weight: 600; - font-size: 18px; - line-height: 24px; - } -} \ No newline at end of file + h1 { + margin-left: 10px; + font-weight: 600; + font-size: 18px; + line-height: 24px; + } +} diff --git a/web/components/roomConfig/RoomConfig.tsx b/web/components/roomConfig/RoomConfig.tsx index bbc76fba2..758e9aa3e 100644 --- a/web/components/roomConfig/RoomConfig.tsx +++ b/web/components/roomConfig/RoomConfig.tsx @@ -1,205 +1,282 @@ import { ComponentChild, FunctionComponent } from "preact"; -import { useCallback, useContext, useEffect, useReducer, useState } from "preact/hooks" +import { + useCallback, + useContext, + useEffect, + useReducer, + useState, +} from "preact/hooks"; import { BridgeAPIError } from "../../BridgeAPI"; import { ListItem, Card } from "../elements"; import style from "./RoomConfig.module.scss"; import { GetConnectionsResponseItem } from "../../../src/provisioning/api"; import { IConnectionState } from "../../../src/Connections"; -import { LoadingSpinner } from '../elements/LoadingSpinner'; +import { LoadingSpinner } from "../elements/LoadingSpinner"; import { ErrCode } from "../../../src/api"; import { retry } from "../../../src/PromiseUtil"; import { Alert } from "@vector-im/compound-web"; import { BridgeContext } from "../../context"; -export interface ConnectionConfigurationProps { - serviceConfig: SConfig; - loginLabel?: string; - showAuthPrompt?: boolean; - onSave: (newConfig: ConnectionState) => void, - isUpdating: boolean, - isMigrationCandidate?: boolean, - existingConnection?: ConnectionType; - onRemove?: () => void, +export interface ConnectionConfigurationProps< + SConfig, + ConnectionType extends GetConnectionsResponseItem, + ConnectionState extends IConnectionState, +> { + serviceConfig: SConfig; + loginLabel?: string; + showAuthPrompt?: boolean; + onSave: (newConfig: ConnectionState) => void; + isUpdating: boolean; + isMigrationCandidate?: boolean; + existingConnection?: ConnectionType; + onRemove?: () => void; } export interface IRoomConfigText { - header: string; - login?: string; - createNew: string; - listCanEdit: string; - listCantEdit: string; + header: string; + login?: string; + createNew: string; + listCanEdit: string; + listCantEdit: string; } -interface IRoomConfigProps { - roomId: string; - type: string; - showAuthPrompt?: boolean; - showHeader: boolean; - darkHeaderImg?: boolean; - headerImg: string; - text: IRoomConfigText; - connectionEventType: string; - listItemName: (c: ConnectionType) => ComponentChild, - connectionConfigComponent: FunctionComponent>; +interface IRoomConfigProps< + SConfig, + ConnectionType extends GetConnectionsResponseItem, + ConnectionState extends IConnectionState, +> { + roomId: string; + type: string; + showAuthPrompt?: boolean; + showHeader: boolean; + darkHeaderImg?: boolean; + headerImg: string; + text: IRoomConfigText; + connectionEventType: string; + listItemName: (c: ConnectionType) => ComponentChild; + connectionConfigComponent: FunctionComponent< + ConnectionConfigurationProps + >; } const MAX_CONNECTION_FETCH_ATTEMPTS = 10; -export const RoomConfig = function(props: IRoomConfigProps) { - const { - roomId, - type, - showAuthPrompt = false, - darkHeaderImg, - headerImg, - showHeader, - text, - listItemName, - connectionEventType, - } = props; - const api = useContext(BridgeContext).bridgeApi; - const ConnectionConfigComponent = props.connectionConfigComponent; - const [ error, setError ] = useState(null); - const [ connections, setConnections ] = useState(null); - const [ serviceConfig, setServiceConfig ] = useState(null); - const [ canEditRoom, setCanEditRoom ] = useState(false); - // We need to increment this every time we create a connection in order to properly reset the state. - const [ newConnectionKey, incrementConnectionKey ] = useReducer(n => n+1, 0); - const [ updatingConnection, isUpdatingConnection ] = useState(false); +export const RoomConfig = function < + SConfig, + ConnectionType extends GetConnectionsResponseItem, + ConnectionState extends IConnectionState, +>(props: IRoomConfigProps) { + const { + roomId, + type, + showAuthPrompt = false, + darkHeaderImg, + headerImg, + showHeader, + text, + listItemName, + connectionEventType, + } = props; + const api = useContext(BridgeContext).bridgeApi; + const ConnectionConfigComponent = props.connectionConfigComponent; + const [error, setError] = useState(null); + const [connections, setConnections] = useState(null); + const [serviceConfig, setServiceConfig] = useState(null); + const [canEditRoom, setCanEditRoom] = useState(false); + // We need to increment this every time we create a connection in order to properly reset the state. + const [newConnectionKey, incrementConnectionKey] = useReducer< + number, + undefined + >((n) => n + 1, 0); + const [updatingConnection, isUpdatingConnection] = useState(false); - const clearCurrentError = () => { - setError(error => error?.forPrevious ? error : null); - } + const clearCurrentError = () => { + setError((error) => (error?.forPrevious ? error : null)); + }; - useEffect(() => { - const fetchConnections = retry( - () => { - return api.getConnectionsForService(roomId, type); - }, - MAX_CONNECTION_FETCH_ATTEMPTS, - 1000, - (ex) => ex instanceof BridgeAPIError && ex.errcode === ErrCode.NotInRoom - ); + useEffect(() => { + const fetchConnections = retry( + () => { + return api.getConnectionsForService(roomId, type); + }, + MAX_CONNECTION_FETCH_ATTEMPTS, + 1000, + (ex) => ex instanceof BridgeAPIError && ex.errcode === ErrCode.NotInRoom, + ); - fetchConnections.then((res) => { - setCanEditRoom(res.canEdit); - setConnections(res.connections); - clearCurrentError(); - }).catch(ex => { - setError({ - header: "Failed to fetch existing connections", - message: ex instanceof BridgeAPIError ? ex.message : "Unknown error" - }); - }) - }, [api, roomId, type, newConnectionKey]); + fetchConnections + .then((res) => { + setCanEditRoom(res.canEdit); + setConnections(res.connections); + clearCurrentError(); + }) + .catch((ex) => { + setError({ + header: "Failed to fetch existing connections", + message: ex instanceof BridgeAPIError ? ex.message : "Unknown error", + }); + }); + }, [api, roomId, type, newConnectionKey]); - const canSendMessages = connections?.every(c => c.canSendMessages) ?? true; + const canSendMessages = connections?.every((c) => c.canSendMessages) ?? true; - useEffect(() => { - api.getServiceConfig(type) - .then(setServiceConfig) - .then(clearCurrentError) - .catch(ex => { - console.warn("Failed to fetch service config", ex); - setError({ - header: "Failed to fetch service config", - message: ex instanceof BridgeAPIError ? ex.message : "Unknown error" - }); - }) - }, [api, type]); + useEffect(() => { + api + .getServiceConfig(type) + .then(setServiceConfig) + .then(clearCurrentError) + .catch((ex) => { + console.warn("Failed to fetch service config", ex); + setError({ + header: "Failed to fetch service config", + message: ex instanceof BridgeAPIError ? ex.message : "Unknown error", + }); + }); + }, [api, type]); - const handleSaveOnCreation = useCallback((config: ConnectionState) => { - isUpdatingConnection(true); - api.createConnection(roomId, connectionEventType, config).then(result => { - // Force reload - incrementConnectionKey(undefined); - setError(!result.warning ? null : { - header: result.warning.header, - message: result.warning.message, - isWarning: true, - forPrevious: true, - }); - }).catch(ex => { - console.warn("Failed to create connection", ex); - setError({ - header: "Failed to create connection", - message: ex instanceof BridgeAPIError ? ex.message : "Unknown error" - }); - }).finally(() => { - isUpdatingConnection(false); + const handleSaveOnCreation = useCallback( + (config: ConnectionState) => { + isUpdatingConnection(true); + api + .createConnection(roomId, connectionEventType, config) + .then((result) => { + // Force reload + incrementConnectionKey(undefined); + setError( + !result.warning + ? null + : { + header: result.warning.header, + message: result.warning.message, + isWarning: true, + forPrevious: true, + }, + ); + }) + .catch((ex) => { + console.warn("Failed to create connection", ex); + setError({ + header: "Failed to create connection", + message: + ex instanceof BridgeAPIError ? ex.message : "Unknown error", + }); + }) + .finally(() => { + isUpdatingConnection(false); }); - }, [api, roomId, connectionEventType]); + }, + [api, roomId, connectionEventType], + ); - return -
- { showHeader && -
- -

{text.header}

-
- } - { - error && - {error.message} - } - { !canSendMessages && canEditRoom && - - This room does not permit the bot to send messages. - Please go to the room settings in your client and adjust permissions. - - } - { canEditRoom &&
-

{text.createNew}

- {serviceConfig && +
+ {showHeader && ( +
+ +

{text.header}

+
+ )} + {error && ( + + {error.message} + + )} + {!canSendMessages && canEditRoom && ( + + This room does not permit the bot to send messages. Please go to the + room settings in your client and adjust permissions. + + )} + {canEditRoom && ( +
+

{text.createNew}

+ {serviceConfig && ( + + )} +
+ )} + {!error && connections === null && } + {!!connections?.length && ( +
+

{canEditRoom ? text.listCanEdit : text.listCantEdit}

+ {serviceConfig && + connections?.map((c) => ( + + { + isUpdatingConnection(true); + api + .updateConnection(roomId, c.id, config) + .then(() => { + c.config = config; + // Force reload + incrementConnectionKey(undefined); + setError(null); + }) + .catch((ex) => { + console.warn("Failed to create connection", ex); + setError({ + header: "Failed to create connection", + message: + ex instanceof BridgeAPIError + ? ex.message + : "Unknown error", + }); + }) + .finally(() => { + isUpdatingConnection(false); + }); + }} + onRemove={() => { + api + .removeConnection(roomId, c.id) + .then(() => { + setConnections( + (conns) => + conns?.filter((conn) => c.id !== conn.id) || [], + ); + setError(null); + }) + .catch((ex) => { + console.warn("Failed to remove connection", ex); + setError({ + header: "Failed to remove connection", + message: + ex instanceof BridgeAPIError + ? ex.message + : "Unknown error", + }); + }); + }} isUpdating={updatingConnection} - />} -
} - { !error && connections === null && } - { !!connections?.length &&
-

{ canEditRoom ? text.listCanEdit : text.listCantEdit }

- { serviceConfig && connections?.map(c => - { - isUpdatingConnection(true); - api.updateConnection(roomId, c.id, config).then(() => { - c.config = config; - // Force reload - incrementConnectionKey(undefined); - setError(null); - }).catch(ex => { - console.warn("Failed to create connection", ex); - setError({ - header: "Failed to create connection", - message: ex instanceof BridgeAPIError ? ex.message : "Unknown error" - }); - }).finally(() => { - isUpdatingConnection(false); - }); - }} - onRemove={() => { - api.removeConnection(roomId, c.id).then(() => { - setConnections(conns => conns?.filter(conn => c.id !== conn.id) || []); - setError(null); - }).catch(ex => { - console.warn("Failed to remove connection", ex); - setError({ - header: "Failed to remove connection", - message: ex instanceof BridgeAPIError ? ex.message : "Unknown error" - }); - }); - }} - isUpdating={updatingConnection} - /> - ) - } -
} -
- ; + /> + + ))} +
+ )} +
+
+ ); }; diff --git a/web/context.ts b/web/context.ts index 8f66d701b..e0630dde0 100644 --- a/web/context.ts +++ b/web/context.ts @@ -2,14 +2,13 @@ import { createContext } from "preact"; import type { BridgeAPI } from "./BridgeAPI"; interface IBridgeContext { - bridgeApi: BridgeAPI; + bridgeApi: BridgeAPI; } const fakeBridgeContext = { - get bridgeApi(): BridgeAPI { - throw Error('No context provided'); - } -} + get bridgeApi(): BridgeAPI { + throw Error("No context provided"); + }, +}; export const BridgeContext = createContext(fakeBridgeContext); - diff --git a/web/fonts/fonts.scss b/web/fonts/fonts.scss index 370dc2671..52baf6f00 100644 --- a/web/fonts/fonts.scss +++ b/web/fonts/fonts.scss @@ -6,9 +6,11 @@ // Borrowed from https://github.com/matrix-org/matrix-react-sdk/blob/020cdc99a2db1b6c7ac2b1be28a5ccd336011f6a/res/themes/light/css/_fonts.scss -$inter-unicode-range: U+0000-20e2,U+20e4-23ce,U+23d0-24c1,U+24c3-259f,U+25c2-2664,U+2666-2763,U+2765-2b05,U+2b07-2b1b,U+2b1d-10FFFF; +$inter-unicode-range: + U+0000-20e2, U+20e4-23ce, U+23d0-24c1, U+24c3-259f, U+25c2-2664, U+2666-2763, + U+2765-2b05, U+2b07-2b1b, U+2b1d-10FFFF; @import url("@fontsource/inter/400.css"); @import url("@fontsource/inter/500.css"); @import url("@fontsource/inter/600.css"); -@import url("@fontsource/inter/700.css"); \ No newline at end of file +@import url("@fontsource/inter/700.css"); diff --git a/web/index.html b/web/index.html index 080c1558d..d2e965d0a 100644 --- a/web/index.html +++ b/web/index.html @@ -1,4 +1,4 @@ - + diff --git a/web/index.tsx b/web/index.tsx index ea07dbb90..967684231 100644 --- a/web/index.tsx +++ b/web/index.tsx @@ -1,13 +1,13 @@ -import { render } from 'preact'; -import 'preact/devtools'; -import App from './App'; -import "./fonts/fonts.scss" +import { render } from "preact"; +import "preact/devtools"; +import App from "./App"; +import "./fonts/fonts.scss"; import "./styling.scss"; import "@vector-im/compound-design-tokens/assets/web/css/compound-design-tokens.css"; -import '@vector-im/compound-web/dist/style.css'; +import "@vector-im/compound-web/dist/style.css"; -const [ root ] = document.getElementsByTagName('main'); +const [root] = document.getElementsByTagName("main"); if (root) { render(, root); -} \ No newline at end of file +} diff --git a/web/oauth.html b/web/oauth.html index 46258b1ee..884a613d8 100644 --- a/web/oauth.html +++ b/web/oauth.html @@ -1,4 +1,4 @@ - + @@ -7,11 +7,9 @@
- +
diff --git a/web/oauth.scss b/web/oauth.scss index 137faaad4..4f9b77c3f 100644 --- a/web/oauth.scss +++ b/web/oauth.scss @@ -20,4 +20,4 @@ h1 { p { font-size: 1.25em; -} \ No newline at end of file +} diff --git a/web/oauth.tsx b/web/oauth.tsx index b7acde500..269b2f304 100644 --- a/web/oauth.tsx +++ b/web/oauth.tsx @@ -1,58 +1,76 @@ -import "./fonts/fonts.scss" +import "./fonts/fonts.scss"; import "./styling.scss"; import "./oauth.scss"; -import { render } from 'preact'; -import 'preact/devtools'; -import type { OAuthPageParams } from '../src/Webhooks'; +import { render } from "preact"; +import "preact/devtools"; +import type { OAuthPageParams } from "../src/Webhooks"; -const root = document.getElementsByTagName('main')[0]; - -const ServiceToName: Record = { - github: 'GitHub', - gitlab: 'GitLab', - default: '' -} +const root = document.getElementsByTagName("main")[0]; +const ServiceToName: Record = { + github: "GitHub", + gitlab: "GitLab", + default: "", +}; function RenderOAuth() { - const params = new URLSearchParams(window.location.search); - const service = (params.get('service') as OAuthPageParams['service']) ?? 'default'; - const error = (params.get('error') as OAuthPageParams['error']); - const errcode = (params.get('errcode') as OAuthPageParams['errcode']); - const oauthKind = (params.get('oauth-kind') as OAuthPageParams['oauth-kind']) ?? 'account'; - const result = (params.get('result') as OAuthPageParams['result']); + const params = new URLSearchParams(window.location.search); + const service = + (params.get("service") as OAuthPageParams["service"]) ?? "default"; + const error = params.get("error") as OAuthPageParams["error"]; + const errcode = params.get("errcode") as OAuthPageParams["errcode"]; + const oauthKind = + (params.get("oauth-kind") as OAuthPageParams["oauth-kind"]) ?? "account"; + const result = params.get("result") as OAuthPageParams["result"]; - const serviceName = ServiceToName[service]; + const serviceName = ServiceToName[service]; - if (result === 'error') { - return <> -

Could not connect your { serviceName } { oauthKind } to Hookshot.

-

- {errcode} {error} -

- ; + if (result === "error") { + return ( + <> +

+ Could not connect your {serviceName} {oauthKind} to Hookshot. +

+

+ {errcode} {error} +

+ + ); // Pending / update are mostly the same thing. Pending means a new app install, update means updating the existing app install. - } else if (result === 'pending' || result === 'update') { - return <> -

The connection to your { serviceName } { oauthKind } is pending.

-

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

- ; - } else if (result === 'success') { - return <> -

Your { serviceName } {oauthKind} has been connected.

-

You may close this window.

- ; - } - return <> -

The connection to your { serviceName } { oauthKind } is { result }.

+ } else if (result === "pending" || result === "update") { + return ( + <> +

+ The connection to your {serviceName} {oauthKind} is pending. +

- This is an unknown state, you may need to contact your systems administrator. + You will need to wait for an administrator of the {serviceName}{" "} + {oauthKind} instance to approve the new installation. If you think + this is a mistake, contact the administrator of your organisation.

- ; - + + ); + } else if (result === "success") { + return ( + <> +

+ Your {serviceName} {oauthKind} has been connected. +

+

You may close this window.

+ + ); + } + return ( + <> +

+ The connection to your {serviceName} {oauthKind} is {result}. +

+

+ This is an unknown state, you may need to contact your systems + administrator. +

+ + ); } if (root) { diff --git a/web/styling.scss b/web/styling.scss index 51d1aebbc..ed64d9982 100644 --- a/web/styling.scss +++ b/web/styling.scss @@ -1,14 +1,16 @@ :root { - --background-color: #FFFFFF; - --foreground-color: #17191C; - --primary-color: #0DBD8B; + --background-color: #ffffff; + --foreground-color: #17191c; + --primary-color: #0dbd8b; --primary-color-disabled: #0dbd8baf; - background-color: #F4F6FA; + background-color: #f4f6fa; color: var(--foreground-color); min-height: 100%; width: 100%; - font-family: 'Inter', 'Twemoji', 'Apple Color Emoji', 'Segoe UI Emoji', 'Arial', 'Helvetica', sans-serif, 'Noto Color Emoji'; + font-family: + "Inter", "Twemoji", "Apple Color Emoji", "Segoe UI Emoji", "Arial", + "Helvetica", sans-serif, "Noto Color Emoji"; } // @media (prefers-color-scheme: dark) { @@ -32,7 +34,6 @@ body { block-size: fit-content !important; } - button { font-weight: 600; margin-right: 8px; diff --git a/web/tsconfig.json b/web/tsconfig.json index 14ff2f7bb..0702ba77a 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -17,8 +17,6 @@ "experimentalDecorators": true, "emitDecoratorMetadata": true, "esModuleInterop": true, - "typeRoots": [ - "./typings" - ] + "typeRoots": ["./typings"] } } diff --git a/web/typings/images.d.ts b/web/typings/images.d.ts index 476fa54d8..ecfa11d08 100644 --- a/web/typings/images.d.ts +++ b/web/typings/images.d.ts @@ -1,4 +1,4 @@ declare module "*.png" { - const content: string - export = content -} \ No newline at end of file + const content: string; + export = content; +} diff --git a/web/typings/sass.d.ts b/web/typings/sass.d.ts index 8f4ef2091..1b0774353 100644 --- a/web/typings/sass.d.ts +++ b/web/typings/sass.d.ts @@ -1,6 +1,6 @@ // As per https://lwebapp.com/en/post/cannot-find-module-scss -declare module'*.scss' { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const content: {[key: string]: any} - export = content +declare module "*.scss" { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const content: { [key: string]: any }; + export = content; } diff --git a/yarn.lock b/yarn.lock index a02325e28..759aa2705 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6694,6 +6694,11 @@ prelude-ls@^1.2.1: resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g== +prettier@^3.5.3: + version "3.5.3" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.5.3.tgz#4fc2ce0d657e7a02e602549f053b239cb7dfe1b5" + integrity sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw== + process-nextick-args@~2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2" From 0aeb68f3c7adaf321f2a691d15226f22406f70f3 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Wed, 14 May 2025 14:37:24 +0100 Subject: [PATCH 02/22] Consistent file naming scheme --- src/AdminRoom.ts | 6 ++--- src/App/BridgeApp.ts | 8 +++---- src/App/GithubWebhookApp.ts | 2 +- src/App/MatrixSenderApp.ts | 2 +- src/App/ResetCryptoStore.ts | 6 ++--- src/{appservice.ts => Appservice.ts} | 6 ++--- src/BotCommands.ts | 2 +- src/Bridge.ts | 23 ++++++++----------- src/CommentProcessor.ts | 2 +- src/ConnectionManager.ts | 8 +++---- src/Connections/FeedConnection.ts | 2 +- src/Connections/FigmaFileConnection.ts | 4 ++-- src/Connections/GenericHook.ts | 8 +++---- src/Connections/GithubRepo.ts | 4 ++-- src/Connections/GitlabIssue.ts | 4 ++-- src/Connections/GitlabRepo.ts | 14 +++++------ src/Connections/HoundConnection.ts | 4 ++-- src/Connections/IConnection.ts | 4 ++-- src/Connections/JiraProject.ts | 4 ++-- src/Connections/OpenProjectConnection.ts | 6 ++--- src/Connections/OutboundHook.ts | 2 +- src/Connections/SetupConnection.ts | 4 ++-- src/{errors.ts => Errors.ts} | 0 src/MatrixSender.ts | 2 +- src/NotificationsProcessor.ts | 4 ++-- src/Webhooks.ts | 6 ++--- src/api/{error.ts => Error.ts} | 0 src/api/index.ts | 2 +- src/config/Config.ts | 8 +++---- src/config/sections/{cache.ts => Cache.ts} | 0 .../sections/{encryption.ts => Encryption.ts} | 2 +- .../{generichooks.ts => GenericHooks.ts} | 2 +- .../{openproject.ts => OpenProject.ts} | 0 src/config/sections/{queue.ts => Queue.ts} | 0 src/config/sections/index.ts | 6 ++--- src/feeds/FeedReader.ts | 4 ++-- src/figma/{router.ts => Router.ts} | 4 ++-- src/figma/{types.ts => Types.ts} | 0 src/figma/index.ts | 8 +++---- src/generic/Router.ts | 4 ++-- src/generic/{types.ts => Types.ts} | 2 +- .../{transformer.ts => WebhookTransformer.ts} | 0 src/github/AdminCommands.ts | 2 +- src/{Gitlab => gitlab}/Client.ts | 0 src/{Gitlab => gitlab}/GrantChecker.ts | 0 src/{Gitlab => gitlab}/Types.ts | 0 src/{Gitlab => gitlab}/WebhookTypes.ts | 0 src/hound/{reader.ts => HoundReader.ts} | 4 ++-- src/jira/Router.ts | 2 +- src/{Managers => managers}/BotUsersManager.ts | 0 src/{MessageQueue => messageQueue}/LocalMQ.ts | 0 .../MessageQueue.ts | 0 .../RedisQueue.ts | 2 +- src/{MessageQueue => messageQueue}/Types.ts | 0 src/{MessageQueue => messageQueue}/index.ts | 0 .../GitHubWatcher.ts | 0 .../GitLabWatcher.ts | 2 +- .../NotificationWatcherTask.ts | 0 .../UserNotificationWatcher.ts | 2 +- src/openproject/Router.ts | 4 ++-- src/openproject/oauth.ts | 2 +- src/openproject/widgetApi.ts | 2 +- .../MemoryStorageProvider.ts | 2 +- .../RedisStorageProvider.ts | 2 +- src/{Stores => stores}/StorageProvider.ts | 2 +- src/tokens/UserTokenStore.ts | 4 ++-- src/{Widgets/api.ts => widgets/Api.ts} | 0 src/{Widgets => widgets}/BridgeWidgetApi.ts | 6 ++--- .../BridgeWidgetInterface.ts | 2 +- src/{Widgets => widgets}/SetupWidget.ts | 2 +- src/{Widgets => widgets}/WidgetKind.ts | 0 tests/FeedReader.spec.ts | 4 ++-- tests/MessageQueueTest.ts | 2 +- tests/connections/GenericHookTest.ts | 4 ++-- tests/connections/GithubRepoTest.ts | 2 +- tests/connections/GitlabRepoTest.ts | 6 ++--- web/App.tsx | 2 +- web/BridgeAPI.ts | 2 +- web/components/AdminSettings.tsx | 2 +- web/components/GitHubState.tsx | 2 +- web/components/roomConfig/Auth.tsx | 2 +- .../roomConfig/GithubRepoConfig.tsx | 2 +- .../roomConfig/OpenProjectConfig.tsx | 2 +- 83 files changed, 125 insertions(+), 128 deletions(-) rename src/{appservice.ts => Appservice.ts} (90%) rename src/{errors.ts => Errors.ts} (100%) rename src/api/{error.ts => Error.ts} (100%) rename src/config/sections/{cache.ts => Cache.ts} (100%) rename src/config/sections/{encryption.ts => Encryption.ts} (95%) rename src/config/sections/{generichooks.ts => GenericHooks.ts} (98%) rename src/config/sections/{openproject.ts => OpenProject.ts} (100%) rename src/config/sections/{queue.ts => Queue.ts} (100%) rename src/figma/{router.ts => Router.ts} (94%) rename src/figma/{types.ts => Types.ts} (100%) rename src/generic/{types.ts => Types.ts} (87%) rename src/generic/{transformer.ts => WebhookTransformer.ts} (100%) rename src/{Gitlab => gitlab}/Client.ts (100%) rename src/{Gitlab => gitlab}/GrantChecker.ts (100%) rename src/{Gitlab => gitlab}/Types.ts (100%) rename src/{Gitlab => gitlab}/WebhookTypes.ts (100%) rename src/hound/{reader.ts => HoundReader.ts} (97%) rename src/{Managers => managers}/BotUsersManager.ts (100%) rename src/{MessageQueue => messageQueue}/LocalMQ.ts (100%) rename src/{MessageQueue => messageQueue}/MessageQueue.ts (100%) rename src/{MessageQueue => messageQueue}/RedisQueue.ts (98%) rename src/{MessageQueue => messageQueue}/Types.ts (100%) rename src/{MessageQueue => messageQueue}/index.ts (100%) rename src/{Notifications => notifications}/GitHubWatcher.ts (100%) rename src/{Notifications => notifications}/GitLabWatcher.ts (95%) rename src/{Notifications => notifications}/NotificationWatcherTask.ts (100%) rename src/{Notifications => notifications}/UserNotificationWatcher.ts (99%) rename src/{Stores => stores}/MemoryStorageProvider.ts (99%) rename src/{Stores => stores}/RedisStorageProvider.ts (99%) rename src/{Stores => stores}/StorageProvider.ts (98%) rename src/{Widgets/api.ts => widgets/Api.ts} (100%) rename src/{Widgets => widgets}/BridgeWidgetApi.ts (99%) rename src/{Widgets => widgets}/BridgeWidgetInterface.ts (95%) rename src/{Widgets => widgets}/SetupWidget.ts (98%) rename src/{Widgets => widgets}/WidgetKind.ts (100%) diff --git a/src/AdminRoom.ts b/src/AdminRoom.ts index b43594625..e146baa15 100644 --- a/src/AdminRoom.ts +++ b/src/AdminRoom.ts @@ -15,7 +15,7 @@ import { BridgeConfig, BridgePermissionLevel } from "./config/Config"; import { BridgeRoomState, BridgeRoomStateGitHub, -} from "./Widgets/BridgeWidgetInterface"; +} from "./widgets/BridgeWidgetInterface"; import { Endpoints } from "@octokit/types"; import { GitHubDiscussionSpace, @@ -24,10 +24,10 @@ import { } from "./Connections"; import { ConnectionManager } from "./ConnectionManager"; import { FormatUtil } from "./FormatUtil"; -import { GetUserResponse } from "./Gitlab/Types"; +import { GetUserResponse } from "./gitlab/Types"; import { GitHubBotCommands } from "./github/AdminCommands"; import { GithubGraphQLClient } from "./github/GithubInstance"; -import { GitLabClient } from "./Gitlab/Client"; +import { GitLabClient } from "./gitlab/Client"; import { Intent } from "matrix-bot-sdk"; import { JiraBotCommands } from "./jira/AdminCommands"; import { diff --git a/src/App/BridgeApp.ts b/src/App/BridgeApp.ts index 76961b79f..1a45b9770 100644 --- a/src/App/BridgeApp.ts +++ b/src/App/BridgeApp.ts @@ -1,16 +1,16 @@ import { Bridge } from "../Bridge"; import { BridgeConfig, parseRegistrationFile } from "../config/Config"; import { MatrixSender } from "../MatrixSender"; -import { UserNotificationWatcher } from "../Notifications/UserNotificationWatcher"; +import { UserNotificationWatcher } from "../notifications/UserNotificationWatcher"; import { ListenerService } from "../ListenerService"; import { Logger, getBridgeVersion } from "matrix-appservice-bridge"; import { IAppserviceRegistration, LogService } from "matrix-bot-sdk"; -import { getAppservice } from "../appservice"; -import BotUsersManager from "../Managers/BotUsersManager"; +import { getAppservice } from "../Appservice"; +import BotUsersManager from "../managers/BotUsersManager"; import * as Sentry from "@sentry/node"; import { GenericHookConnection } from "../Connections"; import { UserTokenStore } from "../tokens/UserTokenStore"; -import { WebhookTransformer } from "../generic/transformer"; +import { WebhookTransformer } from "../generic/WebhookTransformer"; Logger.configure({ console: "info" }); const log = new Logger("App"); diff --git a/src/App/GithubWebhookApp.ts b/src/App/GithubWebhookApp.ts index 2eb518b10..e535b1179 100644 --- a/src/App/GithubWebhookApp.ts +++ b/src/App/GithubWebhookApp.ts @@ -1,7 +1,7 @@ import { BridgeConfig } from "../config/Config"; import { Webhooks } from "../Webhooks"; import { Logger } from "matrix-appservice-bridge"; -import { UserNotificationWatcher } from "../Notifications/UserNotificationWatcher"; +import { UserNotificationWatcher } from "../notifications/UserNotificationWatcher"; import Metrics from "../Metrics"; import { ListenerService } from "../ListenerService"; import { LogService } from "matrix-bot-sdk"; diff --git a/src/App/MatrixSenderApp.ts b/src/App/MatrixSenderApp.ts index b977ad7f1..c600f17f3 100644 --- a/src/App/MatrixSenderApp.ts +++ b/src/App/MatrixSenderApp.ts @@ -4,7 +4,7 @@ import { Logger } from "matrix-appservice-bridge"; import Metrics from "../Metrics"; import { ListenerService } from "../ListenerService"; import { LogService } from "matrix-bot-sdk"; -import { getAppservice } from "../appservice"; +import { getAppservice } from "../Appservice"; const log = new Logger("App"); diff --git a/src/App/ResetCryptoStore.ts b/src/App/ResetCryptoStore.ts index 1da1026c4..be55473f6 100644 --- a/src/App/ResetCryptoStore.ts +++ b/src/App/ResetCryptoStore.ts @@ -3,9 +3,9 @@ import { rm } from "fs/promises"; import { BridgeConfig, parseRegistrationFile } from "../config/Config"; import { Logger } from "matrix-appservice-bridge"; import { LogService, MatrixClient } from "matrix-bot-sdk"; -import { getAppservice } from "../appservice"; -import BotUsersManager from "../Managers/BotUsersManager"; -import { IBridgeStorageProvider } from "../Stores/StorageProvider"; +import { getAppservice } from "../Appservice"; +import BotUsersManager from "../managers/BotUsersManager"; +import { IBridgeStorageProvider } from "../stores/StorageProvider"; const log = new Logger("ResetCryptoStore"); diff --git a/src/appservice.ts b/src/Appservice.ts similarity index 90% rename from src/appservice.ts rename to src/Appservice.ts index 21688f62c..c6e7c5125 100644 --- a/src/appservice.ts +++ b/src/Appservice.ts @@ -8,9 +8,9 @@ import { } from "matrix-bot-sdk"; import { BridgeConfig } from "./config/Config"; import Metrics from "./Metrics"; -import { MemoryStorageProvider } from "./Stores/MemoryStorageProvider"; -import { RedisStorageProvider } from "./Stores/RedisStorageProvider"; -import { IBridgeStorageProvider } from "./Stores/StorageProvider"; +import { MemoryStorageProvider } from "./stores/MemoryStorageProvider"; +import { RedisStorageProvider } from "./stores/RedisStorageProvider"; +import { IBridgeStorageProvider } from "./stores/StorageProvider"; const log = new Logger("Appservice"); export function getAppservice( diff --git a/src/BotCommands.ts b/src/BotCommands.ts index 6f3023523..97140596a 100644 --- a/src/BotCommands.ts +++ b/src/BotCommands.ts @@ -1,6 +1,6 @@ import markdown from "markdown-it"; import { ApiError } from "./api"; -import { CommandError } from "./errors"; +import { CommandError } from "./Errors"; import { MatrixMessageContent } from "./MatrixEvent"; import { BridgePermissionLevel } from "./config/Config"; import { PermissionCheckFn } from "./Connections"; diff --git a/src/Bridge.ts b/src/Bridge.ts index d4dfa6254..a0c58c012 100644 --- a/src/Bridge.ts +++ b/src/Bridge.ts @@ -13,18 +13,18 @@ import { PowerLevelsEvent, Intent, } from "matrix-bot-sdk"; -import BotUsersManager from "./Managers/BotUsersManager"; +import BotUsersManager from "./managers/BotUsersManager"; import { BridgeConfig, BridgePermissionLevel, GitLabInstance, } from "./config/Config"; -import { BridgeWidgetApi } from "./Widgets/BridgeWidgetApi"; +import { BridgeWidgetApi } from "./widgets/BridgeWidgetApi"; import { CommentProcessor } from "./CommentProcessor"; import { ConnectionManager } from "./ConnectionManager"; -import { GetIssueResponse, GetIssueOpts } from "./Gitlab/Types"; +import { GetIssueResponse, GetIssueOpts } from "./gitlab/Types"; import { GithubInstance } from "./github/GithubInstance"; -import { IBridgeStorageProvider } from "./Stores/StorageProvider"; +import { IBridgeStorageProvider } from "./stores/StorageProvider"; import { IConnection, GitHubDiscussionSpace, @@ -38,7 +38,6 @@ import { GitLabIssueConnection, FigmaFileConnection, FeedConnection, - GenericHookConnection, } from "./Connections"; import { IGitLabWebhookIssueStateEvent, @@ -48,7 +47,7 @@ import { IGitLabWebhookReleaseEvent, IGitLabWebhookTagPushEvent, IGitLabWebhookWikiPageEvent, -} from "./Gitlab/WebhookTypes"; +} from "./gitlab/WebhookTypes"; import { JiraIssueEvent, JiraIssueUpdatedEvent, @@ -64,7 +63,7 @@ import { MessageQueue, MessageQueueMessageOut, createMessageQueue, -} from "./MessageQueue"; +} from "./messageQueue"; import { MessageSenderClient } from "./MatrixSender"; import { NotifFilter, @@ -82,12 +81,10 @@ import { ProjectsGetResponseData, } from "./github/Types"; import { retry } from "./PromiseUtil"; -import { UserNotificationsEvent } from "./Notifications/UserNotificationWatcher"; +import { UserNotificationsEvent } from "./notifications/UserNotificationWatcher"; import { UserTokenStore } from "./tokens/UserTokenStore"; import * as GitHubWebhookTypes from "@octokit/webhooks-types"; import { Logger } from "matrix-appservice-bridge"; -import { JiraProvisionerRouter } from "./jira/Router"; -import { GitHubProvisionerRouter } from "./github/Router"; import { promises as fs } from "fs"; import Metrics from "./Metrics"; import { FigmaEvent, ensureFigmaWebhooks } from "./figma"; @@ -97,8 +94,8 @@ import { JiraOAuthRequestOnPrem } from "./jira/OAuth"; import { GenericWebhookEvent, GenericWebhookEventResult, -} from "./generic/types"; -import { SetupWidget } from "./Widgets/SetupWidget"; +} from "./generic/Types"; +import { SetupWidget } from "./widgets/SetupWidget"; import { FeedEntry, FeedError, @@ -107,7 +104,7 @@ import { } from "./feeds/FeedReader"; import * as Sentry from "@sentry/node"; import { HoundConnection, HoundPayload } from "./Connections/HoundConnection"; -import { HoundReader } from "./hound/reader"; +import { HoundReader } from "./hound/HoundReader"; import { OpenProjectWebhookPayloadWorkPackage } from "./openproject/types"; import { OpenProjectConnection } from "./Connections/OpenProjectConnection"; import { OAuthRequest, OAuthRequestResult } from "./tokens/oauth"; diff --git a/src/CommentProcessor.ts b/src/CommentProcessor.ts index d252823c6..a5ef20ae5 100644 --- a/src/CommentProcessor.ts +++ b/src/CommentProcessor.ts @@ -10,7 +10,7 @@ import { ReposGetResponseData, IssuesGetResponseData, } from "./github/Types"; -import { IGitLabWebhookNoteEvent } from "./Gitlab/WebhookTypes"; +import { IGitLabWebhookNoteEvent } from "./gitlab/WebhookTypes"; const REGEX_MENTION = /(^|\s)(@[a-z\d](?:[a-z\d]|-(?=[a-z\d])){0,38})(\s|$)/gi; const REGEX_MATRIX_MENTION = diff --git a/src/ConnectionManager.ts b/src/ConnectionManager.ts index 132145126..c4bfa46d3 100644 --- a/src/ConnectionManager.ts +++ b/src/ConnectionManager.ts @@ -27,15 +27,15 @@ import { JiraProjectConnection, } from "./Connections"; import { FigmaFileConnection, FeedConnection } from "./Connections"; -import { GetConnectionTypeResponseItem } from "./Widgets/api"; -import { GitLabClient } from "./Gitlab/Client"; +import { GetConnectionTypeResponseItem } from "./widgets/Api"; +import { GitLabClient } from "./gitlab/Client"; import { GithubInstance } from "./github/GithubInstance"; -import { IBridgeStorageProvider } from "./Stores/StorageProvider"; +import { IBridgeStorageProvider } from "./stores/StorageProvider"; import { JiraProject, JiraVersion } from "./jira/Types"; import { Logger } from "matrix-appservice-bridge"; import { MessageSenderClient } from "./MatrixSender"; import { UserTokenStore } from "./tokens/UserTokenStore"; -import BotUsersManager from "./Managers/BotUsersManager"; +import BotUsersManager from "./managers/BotUsersManager"; import { retry, retryMatrixErrorFilter } from "./PromiseUtil"; import Metrics from "./Metrics"; import EventEmitter from "events"; diff --git a/src/Connections/FeedConnection.ts b/src/Connections/FeedConnection.ts index ebf2291e8..57a04f8eb 100644 --- a/src/Connections/FeedConnection.ts +++ b/src/Connections/FeedConnection.ts @@ -6,7 +6,7 @@ import { Logger } from "matrix-appservice-bridge"; import { BaseConnection } from "./BaseConnection"; import markdown from "markdown-it"; import { Connection, ProvisionConnectionOpts } from "./IConnection"; -import { GetConnectionsResponseItem } from "../Widgets/api"; +import { GetConnectionsResponseItem } from "../widgets/Api"; import { readFeed, sanitizeHtml } from "../libRs"; import UserAgent from "../UserAgent"; import { retry, retryMatrixErrorFilter } from "../PromiseUtil"; diff --git a/src/Connections/FigmaFileConnection.ts b/src/Connections/FigmaFileConnection.ts index fccb74226..f30034544 100644 --- a/src/Connections/FigmaFileConnection.ts +++ b/src/Connections/FigmaFileConnection.ts @@ -1,10 +1,10 @@ import { Appservice, Intent, StateEvent } from "matrix-bot-sdk"; import markdownit from "markdown-it"; -import { FigmaPayload } from "../figma/types"; +import { FigmaPayload } from "../figma/Types"; import { BaseConnection } from "./BaseConnection"; import { IConnection, IConnectionState } from "."; import { Logger } from "matrix-appservice-bridge"; -import { IBridgeStorageProvider } from "../Stores/StorageProvider"; +import { IBridgeStorageProvider } from "../stores/StorageProvider"; import { BridgeConfig } from "../config/Config"; import { Connection, diff --git a/src/Connections/GenericHook.ts b/src/Connections/GenericHook.ts index 81ac51851..5eb04b7cd 100644 --- a/src/Connections/GenericHook.ts +++ b/src/Connections/GenericHook.ts @@ -15,16 +15,16 @@ import { BaseConnection } from "./BaseConnection"; import { BridgeConfigGenericWebhooks } from "../config/sections"; import { ensureUserIsInRoom } from "../IntentUtils"; import { randomUUID } from "node:crypto"; -import { GenericWebhookEventResult } from "../generic/types"; +import { GenericWebhookEventResult } from "../generic/Types"; import { StatusCodes } from "http-status-codes"; -import { IBridgeStorageProvider } from "../Stores/StorageProvider"; +import { IBridgeStorageProvider } from "../stores/StorageProvider"; import { formatDuration, isMatch, millisecondsToHours } from "date-fns"; import { ExecuteResultContent, ExecuteResultWebhookResponse, WebhookTransformer, -} from "../generic/transformer"; -import { GetConnectionsResponseItem } from "../Widgets/api"; +} from "../generic/WebhookTransformer"; +import { GetConnectionsResponseItem } from "../widgets/Api"; export interface GenericHookConnectionState extends IConnectionState { /** diff --git a/src/Connections/GithubRepo.ts b/src/Connections/GithubRepo.ts index 1cdb07780..91944adc8 100644 --- a/src/Connections/GithubRepo.ts +++ b/src/Connections/GithubRepo.ts @@ -20,7 +20,7 @@ import { InstantiateConnectionOpts, ProvisionConnectionOpts, } from "./IConnection"; -import { GetConnectionsResponseItem } from "../Widgets/api"; +import { GetConnectionsResponseItem } from "../widgets/Api"; import { IssuesOpenedEvent, IssuesReopenedEvent, @@ -44,7 +44,7 @@ import { MatrixReactionContent, } from "../MatrixEvent"; import { MessageSenderClient } from "../MatrixSender"; -import { CommandError, NotLoggedInError } from "../errors"; +import { CommandError, NotLoggedInError } from "../Errors"; import { ReposGetResponseData } from "../github/Types"; import { UserTokenStore } from "../tokens/UserTokenStore"; import axios, { AxiosError } from "axios"; diff --git a/src/Connections/GitlabIssue.ts b/src/Connections/GitlabIssue.ts index 9a6f299b6..78ba3137e 100644 --- a/src/Connections/GitlabIssue.ts +++ b/src/Connections/GitlabIssue.ts @@ -14,8 +14,8 @@ import { BridgeConfigGitLab, GitLabInstance, } from "../config/Config"; -import { GetIssueResponse } from "../Gitlab/Types"; -import { IGitLabWebhookNoteEvent } from "../Gitlab/WebhookTypes"; +import { GetIssueResponse } from "../gitlab/Types"; +import { IGitLabWebhookNoteEvent } from "../gitlab/WebhookTypes"; import { ensureUserIsInRoom, getIntentForUser } from "../IntentUtils"; import { BaseConnection } from "./BaseConnection"; import { ConfigGrantChecker, GrantChecker } from "../grants/GrantCheck"; diff --git a/src/Connections/GitlabRepo.ts b/src/Connections/GitlabRepo.ts index 357c0738f..575aaa9dc 100644 --- a/src/Connections/GitlabRepo.ts +++ b/src/Connections/GitlabRepo.ts @@ -15,7 +15,7 @@ import { IGitLabWebhookReleaseEvent, IGitLabWebhookTagPushEvent, IGitLabWebhookWikiPageEvent, -} from "../Gitlab/WebhookTypes"; +} from "../gitlab/WebhookTypes"; import { CommandConnection } from "./CommandConnection"; import { Connection, @@ -24,20 +24,20 @@ import { InstantiateConnectionOpts, ProvisionConnectionOpts, } from "./IConnection"; -import { ConnectionWarning, GetConnectionsResponseItem } from "../Widgets/api"; +import { ConnectionWarning, GetConnectionsResponseItem } from "../widgets/Api"; import { ErrCode, ApiError, ValidatorApiError } from "../api"; import { AccessLevel, SerializedGitlabDiscussionThreads, -} from "../Gitlab/Types"; +} from "../gitlab/Types"; import Ajv, { JSONSchemaType } from "ajv"; -import { CommandError } from "../errors"; +import { CommandError } from "../Errors"; import QuickLRU from "@alloc/quick-lru"; import { HookFilter } from "../HookFilter"; -import { GitLabClient } from "../Gitlab/Client"; -import { IBridgeStorageProvider } from "../Stores/StorageProvider"; +import { GitLabClient } from "../gitlab/Client"; +import { IBridgeStorageProvider } from "../stores/StorageProvider"; import axios from "axios"; -import { GitLabGrantChecker } from "../Gitlab/GrantChecker"; +import { GitLabGrantChecker } from "../gitlab/GrantChecker"; export interface GitLabRepoConnectionState extends IConnectionState { instance: string; diff --git a/src/Connections/HoundConnection.ts b/src/Connections/HoundConnection.ts index 71c849f47..f626267a6 100644 --- a/src/Connections/HoundConnection.ts +++ b/src/Connections/HoundConnection.ts @@ -7,8 +7,8 @@ import { InstantiateConnectionOpts, ProvisionConnectionOpts, } from "./IConnection"; -import { CommandError } from "../errors"; -import { IBridgeStorageProvider } from "../Stores/StorageProvider"; +import { CommandError } from "../Errors"; +import { IBridgeStorageProvider } from "../stores/StorageProvider"; import { Logger } from "matrix-appservice-bridge"; export interface HoundConnectionState extends IConnectionState { challengeId: string; diff --git a/src/Connections/IConnection.ts b/src/Connections/IConnection.ts index 4a621f1be..f1cdb1e15 100644 --- a/src/Connections/IConnection.ts +++ b/src/Connections/IConnection.ts @@ -1,6 +1,6 @@ import { MatrixEvent, MatrixMessageContent } from "../MatrixEvent"; import { IssuesOpenedEvent, IssuesEditedEvent } from "@octokit/webhooks-types"; -import { ConnectionWarning, GetConnectionsResponseItem } from "../Widgets/api"; +import { ConnectionWarning, GetConnectionsResponseItem } from "../widgets/Api"; import { Appservice, Intent, @@ -11,7 +11,7 @@ import { BridgeConfig, BridgePermissionLevel } from "../config/Config"; import { UserTokenStore } from "../tokens/UserTokenStore"; import { CommentProcessor } from "../CommentProcessor"; import { MessageSenderClient } from "../MatrixSender"; -import { IBridgeStorageProvider } from "../Stores/StorageProvider"; +import { IBridgeStorageProvider } from "../stores/StorageProvider"; import { GithubInstance } from "../github/GithubInstance"; import "reflect-metadata"; diff --git a/src/Connections/JiraProject.ts b/src/Connections/JiraProject.ts index 720d101b5..f2619b6fe 100644 --- a/src/Connections/JiraProject.ts +++ b/src/Connections/JiraProject.ts @@ -23,10 +23,10 @@ import { botCommand, BotCommands, compileBotCommands } from "../BotCommands"; import { MatrixMessageContent } from "../MatrixEvent"; import { CommandConnection } from "./CommandConnection"; import { UserTokenStore } from "../tokens/UserTokenStore"; -import { CommandError, NotLoggedInError } from "../errors"; +import { CommandError, NotLoggedInError } from "../Errors"; import { ApiError, ErrCode } from "../api"; import JiraApi from "jira-client"; -import { GetConnectionsResponseItem } from "../Widgets/api"; +import { GetConnectionsResponseItem } from "../widgets/Api"; import { BridgeConfigJira } from "../config/Config"; import { HookshotJiraApi } from "../jira/Client"; import { GrantChecker } from "../grants/GrantCheck"; diff --git a/src/Connections/OpenProjectConnection.ts b/src/Connections/OpenProjectConnection.ts index b91b3d1e6..603e3d235 100644 --- a/src/Connections/OpenProjectConnection.ts +++ b/src/Connections/OpenProjectConnection.ts @@ -14,15 +14,15 @@ import { CommandConnection } from "./CommandConnection"; import { UserTokenStore } from "../tokens/UserTokenStore"; import { ApiError, ErrCode } from "../api"; import { OpenProjectWebhookPayloadWorkPackage } from "../openproject/types"; -import { BridgeOpenProjectConfig } from "../config/sections/openproject"; +import { BridgeOpenProjectConfig } from "../config/sections/OpenProject"; import { formatWorkPackageDiff, formatWorkPackageForMatrix, } from "../openproject/format"; -import { IBridgeStorageProvider } from "../Stores/StorageProvider"; +import { IBridgeStorageProvider } from "../stores/StorageProvider"; import { workPackageToCacheState } from "../openproject/state"; import { OpenProjectGrantChecker } from "../openproject/GrantChecker"; -import { GetConnectionsResponseItem } from "../Widgets/api"; +import { GetConnectionsResponseItem } from "../widgets/Api"; export type OpenProjectEventsNames = | "work_package:created" diff --git a/src/Connections/OutboundHook.ts b/src/Connections/OutboundHook.ts index 2192c3b43..4b4b2cd43 100644 --- a/src/Connections/OutboundHook.ts +++ b/src/Connections/OutboundHook.ts @@ -13,7 +13,7 @@ import { FileMessageEventContent, Intent, StateEvent } from "matrix-bot-sdk"; import { randomUUID } from "crypto"; import UserAgent from "../UserAgent"; import { hashId } from "../libRs"; -import { GetConnectionsResponseItem } from "../Widgets/api"; +import { GetConnectionsResponseItem } from "../widgets/Api"; export interface OutboundHookConnectionState extends IConnectionState { name: string; diff --git a/src/Connections/SetupConnection.ts b/src/Connections/SetupConnection.ts index 8611a1ab5..1114fa63a 100644 --- a/src/Connections/SetupConnection.ts +++ b/src/Connections/SetupConnection.ts @@ -13,13 +13,13 @@ import { JiraProjectConnectionState, OutboundHookConnection, } from "."; -import { CommandError } from "../errors"; +import { CommandError } from "../Errors"; import { BridgePermissionLevel } from "../config/Config"; import markdown from "markdown-it"; import { FigmaFileConnection } from "./FigmaFileConnection"; import { FeedConnection, FeedConnectionState } from "./FeedConnection"; import { URL } from "url"; -import { SetupWidget } from "../Widgets/SetupWidget"; +import { SetupWidget } from "../widgets/SetupWidget"; import { AdminRoom } from "../AdminRoom"; import { GitLabRepoConnection } from "./GitlabRepo"; import { diff --git a/src/errors.ts b/src/Errors.ts similarity index 100% rename from src/errors.ts rename to src/Errors.ts diff --git a/src/MatrixSender.ts b/src/MatrixSender.ts index 3a0f60763..bcab66796 100644 --- a/src/MatrixSender.ts +++ b/src/MatrixSender.ts @@ -1,5 +1,5 @@ import { BridgeConfig } from "./config/Config"; -import { MessageQueue, createMessageQueue } from "./MessageQueue"; +import { MessageQueue, createMessageQueue } from "./messageQueue"; import { Appservice } from "matrix-bot-sdk"; import { Logger } from "matrix-appservice-bridge"; import { randomUUID } from "node:crypto"; diff --git a/src/NotificationsProcessor.ts b/src/NotificationsProcessor.ts index 67aab0ac6..f1ba61bd1 100644 --- a/src/NotificationsProcessor.ts +++ b/src/NotificationsProcessor.ts @@ -1,6 +1,6 @@ import { MessageSenderClient } from "./MatrixSender"; -import { IBridgeStorageProvider } from "./Stores/StorageProvider"; -import { UserNotificationsEvent } from "./Notifications/UserNotificationWatcher"; +import { IBridgeStorageProvider } from "./stores/StorageProvider"; +import { UserNotificationsEvent } from "./notifications/UserNotificationWatcher"; import { Logger } from "matrix-appservice-bridge"; import { AdminRoom } from "./AdminRoom"; import markdown from "markdown-it"; diff --git a/src/Webhooks.ts b/src/Webhooks.ts index 24afea20e..0f060935f 100644 --- a/src/Webhooks.ts +++ b/src/Webhooks.ts @@ -2,7 +2,7 @@ import { BridgeConfig } from "./config/Config"; import { Router, default as express, Request, Response } from "express"; import { EventEmitter } from "events"; -import { MessageQueue, createMessageQueue } from "./MessageQueue"; +import { MessageQueue, createMessageQueue } from "./messageQueue"; import { Logger } from "matrix-appservice-bridge"; import qs from "querystring"; import axios from "axios"; @@ -11,7 +11,7 @@ import { IGitLabWebhookIssueStateEvent, IGitLabWebhookMREvent, IGitLabWebhookReleaseEvent, -} from "./Gitlab/WebhookTypes"; +} from "./gitlab/WebhookTypes"; import { EmitterWebhookEvent, Webhooks as OctokitWebhooks, @@ -20,7 +20,7 @@ import { IJiraWebhookEvent } from "./jira/WebhookTypes"; import { JiraWebhooksRouter } from "./jira/Router"; import { GitHubOAuthTokenResponse } from "./github/Types"; import Metrics from "./Metrics"; -import { FigmaWebhooksRouter } from "./figma/router"; +import { FigmaWebhooksRouter } from "./figma/Router"; import { GenericWebhooksRouter } from "./generic/Router"; import { GithubInstance } from "./github/GithubInstance"; import QuickLRU from "@alloc/quick-lru"; diff --git a/src/api/error.ts b/src/api/Error.ts similarity index 100% rename from src/api/error.ts rename to src/api/Error.ts diff --git a/src/api/index.ts b/src/api/index.ts index 0ad13d914..a2eefdce9 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -1 +1 @@ -export * from "./error"; +export * from "./Error"; diff --git a/src/config/Config.ts b/src/config/Config.ts index 4e0b9d8b3..9553546d9 100644 --- a/src/config/Config.ts +++ b/src/config/Config.ts @@ -12,22 +12,22 @@ import { configKey, hideKey } from "./Decorators"; import { BridgeConfigListener, ResourceTypeArray } from "../ListenerService"; import { GitHubRepoConnectionOptions } from "../Connections/GithubRepo"; import { BridgeConfigActorPermission, BridgePermissions } from "../libRs"; -import { ConfigError } from "../errors"; +import { ConfigError } from "../Errors"; import { ApiError, ErrCode } from "../api"; import { GithubInstance, GITHUB_CLOUD_URL } from "../github/GithubInstance"; import { Logger } from "matrix-appservice-bridge"; -import { BridgeConfigCache } from "./sections/cache"; +import { BridgeConfigCache } from "./sections/Cache"; import { BridgeConfigGenericWebhooks, BridgeConfigQueue, BridgeGenericWebhooksConfigYAML, } from "./sections"; import { GenericHookServiceConfig } from "../Connections"; -import { BridgeConfigEncryption } from "./sections/encryption"; +import { BridgeConfigEncryption } from "./sections/Encryption"; import { BridgeOpenProjectConfig, BridgeOpenProjectConfigYAML, -} from "./sections/openproject"; +} from "./sections/OpenProject"; import { OpenProjectServiceConfig } from "../Connections/OpenProjectConnection"; const log = new Logger("Config"); diff --git a/src/config/sections/cache.ts b/src/config/sections/Cache.ts similarity index 100% rename from src/config/sections/cache.ts rename to src/config/sections/Cache.ts diff --git a/src/config/sections/encryption.ts b/src/config/sections/Encryption.ts similarity index 95% rename from src/config/sections/encryption.ts rename to src/config/sections/Encryption.ts index 2f1e5fd18..6d0ec4c5f 100644 --- a/src/config/sections/encryption.ts +++ b/src/config/sections/Encryption.ts @@ -1,4 +1,4 @@ -import { ConfigError } from "../../errors"; +import { ConfigError } from "../../Errors"; import { configKey } from "../Decorators"; interface BridgeConfigEncryptionYAML { diff --git a/src/config/sections/generichooks.ts b/src/config/sections/GenericHooks.ts similarity index 98% rename from src/config/sections/generichooks.ts rename to src/config/sections/GenericHooks.ts index 421f2cdfc..fdf6cb6a3 100644 --- a/src/config/sections/generichooks.ts +++ b/src/config/sections/GenericHooks.ts @@ -1,5 +1,5 @@ import { GenericHookServiceConfig } from "../../Connections"; -import { ConfigError } from "../../errors"; +import { ConfigError } from "../../Errors"; import { hideKey } from "../Decorators"; const parseDurationImport = import("parse-duration"); diff --git a/src/config/sections/openproject.ts b/src/config/sections/OpenProject.ts similarity index 100% rename from src/config/sections/openproject.ts rename to src/config/sections/OpenProject.ts diff --git a/src/config/sections/queue.ts b/src/config/sections/Queue.ts similarity index 100% rename from src/config/sections/queue.ts rename to src/config/sections/Queue.ts diff --git a/src/config/sections/index.ts b/src/config/sections/index.ts index 53fa74006..be74d9c4e 100644 --- a/src/config/sections/index.ts +++ b/src/config/sections/index.ts @@ -1,3 +1,3 @@ -export * from "./cache"; -export * from "./queue"; -export * from "./generichooks"; +export * from "./Cache"; +export * from "./Queue"; +export * from "./GenericHooks"; diff --git a/src/feeds/FeedReader.ts b/src/feeds/FeedReader.ts index 2de8e89a6..7cd5586a4 100644 --- a/src/feeds/FeedReader.ts +++ b/src/feeds/FeedReader.ts @@ -2,12 +2,12 @@ import { BridgeConfigFeeds } from "../config/Config"; import { ConnectionManager } from "../ConnectionManager"; import { FeedConnection } from "../Connections"; import { Logger } from "matrix-appservice-bridge"; -import { MessageQueue } from "../MessageQueue"; +import { MessageQueue } from "../messageQueue"; import axios from "axios"; import Metrics from "../Metrics"; import { randomUUID } from "crypto"; import { readFeed } from "../libRs"; -import { IBridgeStorageProvider } from "../Stores/StorageProvider"; +import { IBridgeStorageProvider } from "../stores/StorageProvider"; import UserAgent from "../UserAgent"; import { QueueWithBackoff } from "../libRs"; diff --git a/src/figma/router.ts b/src/figma/Router.ts similarity index 94% rename from src/figma/router.ts rename to src/figma/Router.ts index 3d0643439..a8bd115e7 100644 --- a/src/figma/router.ts +++ b/src/figma/Router.ts @@ -1,7 +1,7 @@ import { BridgeConfigFigma } from "../config/Config"; -import { MessageQueue } from "../MessageQueue"; +import { MessageQueue } from "../messageQueue"; import { Request, Response, Router, json } from "express"; -import { FigmaPayload } from "./types"; +import { FigmaPayload } from "./Types"; import { Logger } from "matrix-appservice-bridge"; const log = new Logger("FigmaWebhooksRouter"); diff --git a/src/figma/types.ts b/src/figma/Types.ts similarity index 100% rename from src/figma/types.ts rename to src/figma/Types.ts diff --git a/src/figma/index.ts b/src/figma/index.ts index 4cf359c08..9beec9696 100644 --- a/src/figma/index.ts +++ b/src/figma/index.ts @@ -1,13 +1,13 @@ import { BridgeConfigFigma } from "../config/Config"; import * as Figma from "figma-js"; import { MatrixClient } from "matrix-bot-sdk"; -export * from "./router"; -export * from "./types"; +export * from "./Router"; +export * from "./Types"; import { Logger } from "matrix-appservice-bridge"; import { AxiosError } from "axios"; -export * from "./router"; -export * from "./types"; +export * from "./Router"; +export * from "./Types"; interface FigmaWebhookDefinition { id: string; diff --git a/src/generic/Router.ts b/src/generic/Router.ts index ad55bed34..b1a7bd5c5 100644 --- a/src/generic/Router.ts +++ b/src/generic/Router.ts @@ -1,8 +1,8 @@ -import { MessageQueue } from "../MessageQueue"; +import { MessageQueue } from "../messageQueue"; import express, { NextFunction, Request, Response, Router } from "express"; import { Logger } from "matrix-appservice-bridge"; import { ApiError, ErrCode } from "../api"; -import { GenericWebhookEvent, GenericWebhookEventResult } from "./types"; +import { GenericWebhookEvent, GenericWebhookEventResult } from "./Types"; import * as xml from "xml2js"; import helmet from "helmet"; import { StatusCodes } from "http-status-codes"; diff --git a/src/generic/types.ts b/src/generic/Types.ts similarity index 87% rename from src/generic/types.ts rename to src/generic/Types.ts index 8259af5a1..d8dcbccea 100644 --- a/src/generic/types.ts +++ b/src/generic/Types.ts @@ -1,4 +1,4 @@ -import { ExecuteResultWebhookResponse } from "../generic/transformer"; +import { ExecuteResultWebhookResponse } from "./WebhookTransformer"; export interface GenericWebhookEvent { hookData: unknown; diff --git a/src/generic/transformer.ts b/src/generic/WebhookTransformer.ts similarity index 100% rename from src/generic/transformer.ts rename to src/generic/WebhookTransformer.ts diff --git a/src/github/AdminCommands.ts b/src/github/AdminCommands.ts index 990ca603e..1ed8fee02 100644 --- a/src/github/AdminCommands.ts +++ b/src/github/AdminCommands.ts @@ -1,6 +1,6 @@ import { AdminRoomCommandHandler, Category } from "../AdminRoomCommandHandler"; import { botCommand } from "../BotCommands"; -import { CommandError, TokenError, TokenErrorCode } from "../errors"; +import { CommandError, TokenError, TokenErrorCode } from "../Errors"; import { GithubInstance } from "./GithubInstance"; import { GitHubOAuthToken } from "./Types"; import { Logger } from "matrix-appservice-bridge"; diff --git a/src/Gitlab/Client.ts b/src/gitlab/Client.ts similarity index 100% rename from src/Gitlab/Client.ts rename to src/gitlab/Client.ts diff --git a/src/Gitlab/GrantChecker.ts b/src/gitlab/GrantChecker.ts similarity index 100% rename from src/Gitlab/GrantChecker.ts rename to src/gitlab/GrantChecker.ts diff --git a/src/Gitlab/Types.ts b/src/gitlab/Types.ts similarity index 100% rename from src/Gitlab/Types.ts rename to src/gitlab/Types.ts diff --git a/src/Gitlab/WebhookTypes.ts b/src/gitlab/WebhookTypes.ts similarity index 100% rename from src/Gitlab/WebhookTypes.ts rename to src/gitlab/WebhookTypes.ts diff --git a/src/hound/reader.ts b/src/hound/HoundReader.ts similarity index 97% rename from src/hound/reader.ts rename to src/hound/HoundReader.ts index 7105806ec..29c40acaa 100644 --- a/src/hound/reader.ts +++ b/src/hound/HoundReader.ts @@ -5,8 +5,8 @@ import { HoundPayload, HoundActivity, } from "../Connections/HoundConnection"; -import { MessageQueue } from "../MessageQueue"; -import { IBridgeStorageProvider } from "../Stores/StorageProvider"; +import { MessageQueue } from "../messageQueue"; +import { IBridgeStorageProvider } from "../stores/StorageProvider"; import { BridgeConfigChallengeHound } from "../config/Config"; import { Logger } from "matrix-appservice-bridge"; import { hashId } from "../libRs"; diff --git a/src/jira/Router.ts b/src/jira/Router.ts index 8cf31dbc0..7ffce6007 100644 --- a/src/jira/Router.ts +++ b/src/jira/Router.ts @@ -1,5 +1,5 @@ import { BridgeConfigJira } from "../config/Config"; -import { MessageQueue } from "../MessageQueue"; +import { MessageQueue } from "../messageQueue"; import { Router, Request, Response, NextFunction, json } from "express"; import { UserTokenStore } from "../tokens/UserTokenStore"; import { Logger } from "matrix-appservice-bridge"; diff --git a/src/Managers/BotUsersManager.ts b/src/managers/BotUsersManager.ts similarity index 100% rename from src/Managers/BotUsersManager.ts rename to src/managers/BotUsersManager.ts diff --git a/src/MessageQueue/LocalMQ.ts b/src/messageQueue/LocalMQ.ts similarity index 100% rename from src/MessageQueue/LocalMQ.ts rename to src/messageQueue/LocalMQ.ts diff --git a/src/MessageQueue/MessageQueue.ts b/src/messageQueue/MessageQueue.ts similarity index 100% rename from src/MessageQueue/MessageQueue.ts rename to src/messageQueue/MessageQueue.ts diff --git a/src/MessageQueue/RedisQueue.ts b/src/messageQueue/RedisQueue.ts similarity index 98% rename from src/MessageQueue/RedisQueue.ts rename to src/messageQueue/RedisQueue.ts index a15b9cff0..e877fc51d 100644 --- a/src/MessageQueue/RedisQueue.ts +++ b/src/messageQueue/RedisQueue.ts @@ -5,7 +5,7 @@ import { MessageQueueMessageOut, } from "./Types"; import { Redis, default as redis } from "ioredis"; -import { BridgeConfigQueue } from "../config/sections/queue"; +import { BridgeConfigQueue } from "../config/sections/Queue"; import { EventEmitter } from "events"; import { Logger } from "matrix-appservice-bridge"; import { randomUUID } from "node:crypto"; diff --git a/src/MessageQueue/Types.ts b/src/messageQueue/Types.ts similarity index 100% rename from src/MessageQueue/Types.ts rename to src/messageQueue/Types.ts diff --git a/src/MessageQueue/index.ts b/src/messageQueue/index.ts similarity index 100% rename from src/MessageQueue/index.ts rename to src/messageQueue/index.ts diff --git a/src/Notifications/GitHubWatcher.ts b/src/notifications/GitHubWatcher.ts similarity index 100% rename from src/Notifications/GitHubWatcher.ts rename to src/notifications/GitHubWatcher.ts diff --git a/src/Notifications/GitLabWatcher.ts b/src/notifications/GitLabWatcher.ts similarity index 95% rename from src/Notifications/GitLabWatcher.ts rename to src/notifications/GitLabWatcher.ts index f1fd4f008..3d2412562 100644 --- a/src/Notifications/GitLabWatcher.ts +++ b/src/notifications/GitLabWatcher.ts @@ -1,5 +1,5 @@ import { EventEmitter } from "events"; -import { GitLabClient } from "../Gitlab/Client"; +import { GitLabClient } from "../gitlab/Client"; import { Logger } from "matrix-appservice-bridge"; import { NotificationWatcherTask } from "./NotificationWatcherTask"; diff --git a/src/Notifications/NotificationWatcherTask.ts b/src/notifications/NotificationWatcherTask.ts similarity index 100% rename from src/Notifications/NotificationWatcherTask.ts rename to src/notifications/NotificationWatcherTask.ts diff --git a/src/Notifications/UserNotificationWatcher.ts b/src/notifications/UserNotificationWatcher.ts similarity index 99% rename from src/Notifications/UserNotificationWatcher.ts rename to src/notifications/UserNotificationWatcher.ts index 8f3561262..b69cf250d 100644 --- a/src/Notifications/UserNotificationWatcher.ts +++ b/src/notifications/UserNotificationWatcher.ts @@ -7,7 +7,7 @@ import { createMessageQueue, MessageQueue, MessageQueueMessage, -} from "../MessageQueue"; +} from "../messageQueue"; import { MessageSenderClient } from "../MatrixSender"; import { NotificationWatcherTask } from "./NotificationWatcherTask"; import { GitHubWatcher } from "./GitHubWatcher"; diff --git a/src/openproject/Router.ts b/src/openproject/Router.ts index 6680519ec..fdd48410a 100644 --- a/src/openproject/Router.ts +++ b/src/openproject/Router.ts @@ -1,6 +1,6 @@ import { Request, Response, Router, json } from "express"; -import { BridgeOpenProjectConfig } from "../config/sections/openproject"; -import { MessageQueue } from "../MessageQueue"; +import { BridgeOpenProjectConfig } from "../config/sections/OpenProject"; +import { MessageQueue } from "../messageQueue"; import { OpenProjectWebhookPayload } from "./types"; import { ApiError, ErrCode } from "../api"; import { createHmac } from "node:crypto"; diff --git a/src/openproject/oauth.ts b/src/openproject/oauth.ts index ddadde69f..574525071 100644 --- a/src/openproject/oauth.ts +++ b/src/openproject/oauth.ts @@ -1,4 +1,4 @@ -import { BridgeOpenProjectOAuthConfig } from "../config/sections/openproject"; +import { BridgeOpenProjectOAuthConfig } from "../config/sections/OpenProject"; import { UserTokenStore } from "../tokens/UserTokenStore"; import { OAuthRequest, OAuthRequestResult } from "../tokens/oauth"; import { Logger } from "matrix-appservice-bridge"; diff --git a/src/openproject/widgetApi.ts b/src/openproject/widgetApi.ts index 88080201b..ce3c1d836 100644 --- a/src/openproject/widgetApi.ts +++ b/src/openproject/widgetApi.ts @@ -1,5 +1,5 @@ import { ProvisioningRequest } from "matrix-appservice-bridge"; -import { GetAuthResponse } from "../Widgets/BridgeWidgetInterface"; +import { GetAuthResponse } from "../widgets/BridgeWidgetInterface"; import { UserTokenStore } from "../tokens/UserTokenStore"; import { Response } from "express"; import { ApiError, ErrCode } from "../api"; diff --git a/src/Stores/MemoryStorageProvider.ts b/src/stores/MemoryStorageProvider.ts similarity index 99% rename from src/Stores/MemoryStorageProvider.ts rename to src/stores/MemoryStorageProvider.ts index ea4f3230e..a0efa4a08 100644 --- a/src/Stores/MemoryStorageProvider.ts +++ b/src/stores/MemoryStorageProvider.ts @@ -3,7 +3,7 @@ import { IBridgeStorageProvider, MAX_FEED_ITEMS } from "./StorageProvider"; import { IssuesGetResponseData } from "../github/Types"; import { ProvisionSession } from "matrix-appservice-bridge"; import QuickLRU from "@alloc/quick-lru"; -import { SerializedGitlabDiscussionThreads } from "../Gitlab/Types"; +import { SerializedGitlabDiscussionThreads } from "../gitlab/Types"; import { OpenProjectWorkPackageCacheState } from "../openproject/state"; export class MemoryStorageProvider diff --git a/src/Stores/RedisStorageProvider.ts b/src/stores/RedisStorageProvider.ts similarity index 99% rename from src/Stores/RedisStorageProvider.ts rename to src/stores/RedisStorageProvider.ts index f55bdae24..23b89f0c9 100644 --- a/src/Stores/RedisStorageProvider.ts +++ b/src/stores/RedisStorageProvider.ts @@ -5,7 +5,7 @@ import { Logger } from "matrix-appservice-bridge"; import { IBridgeStorageProvider, MAX_FEED_ITEMS } from "./StorageProvider"; import { IFilterInfo, IStorageProvider } from "matrix-bot-sdk"; import { ProvisionSession } from "matrix-appservice-bridge"; -import { SerializedGitlabDiscussionThreads } from "../Gitlab/Types"; +import { SerializedGitlabDiscussionThreads } from "../gitlab/Types"; import { BridgeConfigCache } from "../config/sections"; import { OpenProjectWorkPackageCacheState } from "../openproject/state"; diff --git a/src/Stores/StorageProvider.ts b/src/stores/StorageProvider.ts similarity index 98% rename from src/Stores/StorageProvider.ts rename to src/stores/StorageProvider.ts index b9f3c0f21..a7fbfdc9e 100644 --- a/src/Stores/StorageProvider.ts +++ b/src/stores/StorageProvider.ts @@ -1,7 +1,7 @@ import { ProvisioningStore } from "matrix-appservice-bridge"; import { IAppserviceStorageProvider, IStorageProvider } from "matrix-bot-sdk"; import { IssuesGetResponseData } from "../github/Types"; -import { SerializedGitlabDiscussionThreads } from "../Gitlab/Types"; +import { SerializedGitlabDiscussionThreads } from "../gitlab/Types"; import { OpenProjectWorkPackageCacheState } from "../openproject/state"; // Some RSS feeds can return a very small number of items then bounce diff --git a/src/tokens/UserTokenStore.ts b/src/tokens/UserTokenStore.ts index b5a49dd2e..42e2b6cad 100644 --- a/src/tokens/UserTokenStore.ts +++ b/src/tokens/UserTokenStore.ts @@ -1,5 +1,5 @@ import { GithubInstance } from "../github/GithubInstance"; -import { GitLabClient } from "../Gitlab/Client"; +import { GitLabClient } from "../gitlab/Client"; import { Intent } from "matrix-bot-sdk"; import { promises as fs } from "fs"; import { Logger } from "matrix-appservice-bridge"; @@ -19,7 +19,7 @@ import { JiraCloudOAuth } from "../jira/oauth/CloudOAuth"; import { JiraOnPremOAuth } from "../jira/oauth/OnPremOAuth"; import { JiraOnPremClient } from "../jira/client/OnPremClient"; import { JiraCloudClient } from "../jira/client/CloudClient"; -import { TokenError, TokenErrorCode } from "../errors"; +import { TokenError, TokenErrorCode } from "../Errors"; import { TypedEmitter } from "tiny-typed-emitter"; import { hashId, TokenEncryption, stringToAlgo } from "../libRs"; import { OpenProjectOAuth } from "../openproject/oauth"; diff --git a/src/Widgets/api.ts b/src/widgets/Api.ts similarity index 100% rename from src/Widgets/api.ts rename to src/widgets/Api.ts diff --git a/src/Widgets/BridgeWidgetApi.ts b/src/widgets/BridgeWidgetApi.ts similarity index 99% rename from src/Widgets/BridgeWidgetApi.ts rename to src/widgets/BridgeWidgetApi.ts index fa3807f60..81cd8193e 100644 --- a/src/Widgets/BridgeWidgetApi.ts +++ b/src/widgets/BridgeWidgetApi.ts @@ -9,10 +9,10 @@ import { GetConnectionsForServiceResponse, } from "./BridgeWidgetInterface"; import { ProvisioningApi, ProvisioningRequest } from "matrix-appservice-bridge"; -import { IBridgeStorageProvider } from "../Stores/StorageProvider"; +import { IBridgeStorageProvider } from "../stores/StorageProvider"; import { ConnectionManager } from "../ConnectionManager"; -import BotUsersManager, { BotUser } from "../Managers/BotUsersManager"; -import { assertUserPermissionsInRoom, GetConnectionsResponseItem } from "./api"; +import BotUsersManager, { BotUser } from "../managers/BotUsersManager"; +import { assertUserPermissionsInRoom, GetConnectionsResponseItem } from "./Api"; import { Appservice, PowerLevelsEvent } from "matrix-bot-sdk"; import { GithubInstance } from "../github/GithubInstance"; import { diff --git a/src/Widgets/BridgeWidgetInterface.ts b/src/widgets/BridgeWidgetInterface.ts similarity index 95% rename from src/Widgets/BridgeWidgetInterface.ts rename to src/widgets/BridgeWidgetInterface.ts index 6b381d6f1..e44d41ff1 100644 --- a/src/Widgets/BridgeWidgetInterface.ts +++ b/src/widgets/BridgeWidgetInterface.ts @@ -1,4 +1,4 @@ -import { GetConnectionsResponseItem } from "./api"; +import { GetConnectionsResponseItem } from "./Api"; export interface BridgeRoomStateGitHub { enabled: boolean; diff --git a/src/Widgets/SetupWidget.ts b/src/widgets/SetupWidget.ts similarity index 98% rename from src/Widgets/SetupWidget.ts rename to src/widgets/SetupWidget.ts index 0c9f961ad..cb97ef28e 100644 --- a/src/Widgets/SetupWidget.ts +++ b/src/widgets/SetupWidget.ts @@ -1,7 +1,7 @@ import { Intent } from "matrix-bot-sdk"; import { BridgeWidgetConfig } from "../config/Config"; import { Logger } from "matrix-appservice-bridge"; -import { CommandError } from "../errors"; +import { CommandError } from "../Errors"; import { HookshotWidgetKind } from "./WidgetKind"; const log = new Logger("SetupWidget"); diff --git a/src/Widgets/WidgetKind.ts b/src/widgets/WidgetKind.ts similarity index 100% rename from src/Widgets/WidgetKind.ts rename to src/widgets/WidgetKind.ts diff --git a/tests/FeedReader.spec.ts b/tests/FeedReader.spec.ts index 3189d08ea..3640badd2 100644 --- a/tests/FeedReader.spec.ts +++ b/tests/FeedReader.spec.ts @@ -4,8 +4,8 @@ import { BridgeConfigFeeds } from "../src/config/Config"; import { ConnectionManager } from "../src/ConnectionManager"; import { IConnection } from "../src/Connections"; import { FeedEntry, FeedReader } from "../src/feeds/FeedReader"; -import { MessageQueue, MessageQueueMessage } from "../src/MessageQueue"; -import { MemoryStorageProvider } from "../src/Stores/MemoryStorageProvider"; +import { MessageQueue, MessageQueueMessage } from "../src/messageQueue"; +import { MemoryStorageProvider } from "../src/stores/MemoryStorageProvider"; import { Server, createServer } from "http"; import { AddressInfo } from "net"; diff --git a/tests/MessageQueueTest.ts b/tests/MessageQueueTest.ts index e9c2baa60..d67072998 100644 --- a/tests/MessageQueueTest.ts +++ b/tests/MessageQueueTest.ts @@ -1,5 +1,5 @@ import { expect } from "chai"; -import { createMessageQueue } from "../src/MessageQueue/MessageQueue"; +import { createMessageQueue } from "../src/messageQueue/messageQueue"; const mq = createMessageQueue(); diff --git a/tests/connections/GenericHookTest.ts b/tests/connections/GenericHookTest.ts index bd53363fc..b1bd0c3c2 100644 --- a/tests/connections/GenericHookTest.ts +++ b/tests/connections/GenericHookTest.ts @@ -12,9 +12,9 @@ import { MessageSenderClient, IMatrixSendMessage, } from "../../src/MatrixSender"; -import { LocalMQ } from "../../src/MessageQueue/LocalMQ"; +import { LocalMQ } from "../../src/messageQueue/LocalMQ"; import { AppserviceMock } from "../utils/AppserviceMock"; -import { MemoryStorageProvider } from "../../src/Stores/MemoryStorageProvider"; +import { MemoryStorageProvider } from "../../src/stores/MemoryStorageProvider"; import { BridgeConfig } from "../../src/config/Config"; import { ProvisionConnectionOpts } from "../../src/Connections"; import { add } from "date-fns"; diff --git a/tests/connections/GithubRepoTest.ts b/tests/connections/GithubRepoTest.ts index 050efa407..20287701a 100644 --- a/tests/connections/GithubRepoTest.ts +++ b/tests/connections/GithubRepoTest.ts @@ -3,7 +3,7 @@ import { GitHubRepoConnectionState, } from "../../src/Connections/GithubRepo"; import { GithubInstance } from "../../src/github/GithubInstance"; -import { createMessageQueue } from "../../src/MessageQueue"; +import { createMessageQueue } from "../../src/messageQueue"; import { UserTokenStore } from "../../src/tokens/UserTokenStore"; import { DefaultConfig } from "../../src/config/Defaults"; import { AppserviceMock } from "../utils/AppserviceMock"; diff --git a/tests/connections/GitlabRepoTest.ts b/tests/connections/GitlabRepoTest.ts index 5f4870111..44152f890 100644 --- a/tests/connections/GitlabRepoTest.ts +++ b/tests/connections/GitlabRepoTest.ts @@ -1,4 +1,4 @@ -import { createMessageQueue } from "../../src/MessageQueue"; +import { createMessageQueue } from "../../src/messageQueue"; import { UserTokenStore } from "../../src/tokens/UserTokenStore"; import { AppserviceMock } from "../utils/AppserviceMock"; import { ApiError, ErrCode, ValidatorApiError } from "../../src/api"; @@ -8,14 +8,14 @@ import { } from "../../src/Connections"; import { expect } from "chai"; import { BridgeConfigGitLab } from "../../src/config/Config"; -import { IBridgeStorageProvider } from "../../src/Stores/StorageProvider"; +import { IBridgeStorageProvider } from "../../src/stores/StorageProvider"; import { IntentMock } from "../utils/IntentMock"; import { IGitlabMergeRequest, IGitlabProject, IGitlabUser, IGitLabWebhookNoteEvent, -} from "../../src/Gitlab/WebhookTypes"; +} from "../../src/gitlab/WebhookTypes"; const ROOM_ID = "!foo:bar"; diff --git a/web/App.tsx b/web/App.tsx index 9638d707a..ed677033e 100644 --- a/web/App.tsx +++ b/web/App.tsx @@ -6,7 +6,7 @@ import { EmbedType, embedTypeParameter, } from "./BridgeAPI"; -import { BridgeRoomState } from "../src/Widgets/BridgeWidgetInterface"; +import { BridgeRoomState } from "../src/widgets/BridgeWidgetInterface"; import { LoadingSpinner } from "./components/elements/LoadingSpinner"; import AdminSettings from "./components/AdminSettings"; import RoomConfigView from "./components/RoomConfigView"; diff --git a/web/BridgeAPI.ts b/web/BridgeAPI.ts index f205d17d6..fb5dca20f 100644 --- a/web/BridgeAPI.ts +++ b/web/BridgeAPI.ts @@ -3,7 +3,7 @@ import { GetAuthPollResponse, GetAuthResponse, GetConnectionsForServiceResponse, -} from "../src/Widgets/BridgeWidgetInterface"; +} from "../src/widgets/BridgeWidgetInterface"; import { GetConnectionsResponseItem } from "../src/provisioning/api"; import { ExchangeOpenAPIRequestBody, diff --git a/web/components/AdminSettings.tsx b/web/components/AdminSettings.tsx index 69d399d5a..a819754d4 100644 --- a/web/components/AdminSettings.tsx +++ b/web/components/AdminSettings.tsx @@ -1,6 +1,6 @@ import { useEffect, useState, useCallback } from "preact/hooks"; import { LoadingSpinner } from "./elements/LoadingSpinner"; -import { BridgeRoomState } from "../../src/Widgets/BridgeWidgetInterface"; +import { BridgeRoomState } from "../../src/widgets/BridgeWidgetInterface"; import GeneralConfig from "./configs/GeneralConfig"; import style from "./AdminSettings.module.scss"; import { BridgeAPI } from "../BridgeAPI"; diff --git a/web/components/GitHubState.tsx b/web/components/GitHubState.tsx index ca63cbc6a..1f2889e86 100644 --- a/web/components/GitHubState.tsx +++ b/web/components/GitHubState.tsx @@ -1,5 +1,5 @@ import { FunctionComponent } from "preact"; -import { BridgeRoomStateGitHub } from "../../src/Widgets/BridgeWidgetInterface"; +import { BridgeRoomStateGitHub } from "../../src/widgets/BridgeWidgetInterface"; import "./GitHubState.css"; const GitHubState: FunctionComponent<{ config: BridgeRoomStateGitHub }> = ({ diff --git a/web/components/roomConfig/Auth.tsx b/web/components/roomConfig/Auth.tsx index ff270836d..7ac5b47cc 100644 --- a/web/components/roomConfig/Auth.tsx +++ b/web/components/roomConfig/Auth.tsx @@ -1,5 +1,5 @@ import { useCallback, useContext, useEffect, useState } from "preact/hooks"; -import { GetAuthResponse } from "../../../src/Widgets/BridgeWidgetInterface"; +import { GetAuthResponse } from "../../../src/widgets/BridgeWidgetInterface"; import { Button } from "../elements"; import { BridgeContext } from "../../context"; diff --git a/web/components/roomConfig/GithubRepoConfig.tsx b/web/components/roomConfig/GithubRepoConfig.tsx index 683751f21..1e545c3f7 100644 --- a/web/components/roomConfig/GithubRepoConfig.tsx +++ b/web/components/roomConfig/GithubRepoConfig.tsx @@ -24,7 +24,7 @@ import { import { DropItem } from "../elements/DropdownSearch"; import { ConnectionSearch } from "../elements/ConnectionSearch"; import { ServiceAuth } from "./Auth"; -import { GetAuthResponse } from "../../../src/Widgets/BridgeWidgetInterface"; +import { GetAuthResponse } from "../../../src/widgets/BridgeWidgetInterface"; import { BridgeContext } from "../../context"; const EventType = "uk.half-shot.matrix-hookshot.github.repository"; diff --git a/web/components/roomConfig/OpenProjectConfig.tsx b/web/components/roomConfig/OpenProjectConfig.tsx index be6634b0e..186fe9fe1 100644 --- a/web/components/roomConfig/OpenProjectConfig.tsx +++ b/web/components/roomConfig/OpenProjectConfig.tsx @@ -17,7 +17,7 @@ import type { import { InputField, ButtonSet, Button } from "../elements"; import { EventHookList } from "../elements/EventHookCheckbox"; import Icon from "../../icons/openproject.png"; -import { GetAuthResponse } from "../../../src/Widgets/BridgeWidgetInterface"; +import { GetAuthResponse } from "../../../src/widgets/BridgeWidgetInterface"; import { BridgeContext } from "../../context"; import { ServiceAuth } from "./Auth"; import { ProjectSearch } from "../elements/ConnectionSearch"; From b77b95cd506ab8fff07fd4d7e648dde2e026cfd0 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Wed, 14 May 2025 14:38:34 +0100 Subject: [PATCH 03/22] Rename --- src/Bridge.ts | 4 ++-- src/Connections/OpenProjectConnection.ts | 6 +++--- src/Webhooks.ts | 2 +- src/jira/Router.ts | 2 +- src/openproject/{adminCommands.ts => AdminCommands.ts} | 0 src/openproject/{client.ts => Client.ts} | 4 ++-- src/openproject/{format.ts => Format.ts} | 4 ++-- src/openproject/{oauth.ts => Oauth.ts} | 2 +- src/openproject/Router.ts | 4 ++-- src/openproject/{state.ts => State.ts} | 2 +- src/openproject/{types.ts => Types.ts} | 0 src/openproject/{widgetApi.ts => WidgetApi.ts} | 0 src/stores/MemoryStorageProvider.ts | 2 +- src/stores/RedisStorageProvider.ts | 2 +- src/stores/StorageProvider.ts | 2 +- src/tokens/{oauth.ts => Oauth.ts} | 0 src/tokens/UserTokenStore.ts | 6 +++--- 17 files changed, 21 insertions(+), 21 deletions(-) rename src/openproject/{adminCommands.ts => AdminCommands.ts} (100%) rename src/openproject/{client.ts => Client.ts} (97%) rename src/openproject/{format.ts => Format.ts} (98%) rename src/openproject/{oauth.ts => Oauth.ts} (98%) rename src/openproject/{state.ts => State.ts} (94%) rename src/openproject/{types.ts => Types.ts} (100%) rename src/openproject/{widgetApi.ts => WidgetApi.ts} (100%) rename src/tokens/{oauth.ts => Oauth.ts} (100%) diff --git a/src/Bridge.ts b/src/Bridge.ts index a0c58c012..65de7d9ff 100644 --- a/src/Bridge.ts +++ b/src/Bridge.ts @@ -105,9 +105,9 @@ import { import * as Sentry from "@sentry/node"; import { HoundConnection, HoundPayload } from "./Connections/HoundConnection"; import { HoundReader } from "./hound/HoundReader"; -import { OpenProjectWebhookPayloadWorkPackage } from "./openproject/types"; +import { OpenProjectWebhookPayloadWorkPackage } from "./openproject/Types"; import { OpenProjectConnection } from "./Connections/OpenProjectConnection"; -import { OAuthRequest, OAuthRequestResult } from "./tokens/oauth"; +import { OAuthRequest, OAuthRequestResult } from "./tokens/Oauth"; const log = new Logger("Bridge"); diff --git a/src/Connections/OpenProjectConnection.ts b/src/Connections/OpenProjectConnection.ts index 603e3d235..5a903fb05 100644 --- a/src/Connections/OpenProjectConnection.ts +++ b/src/Connections/OpenProjectConnection.ts @@ -13,14 +13,14 @@ import { MatrixMessageContent } from "../MatrixEvent"; import { CommandConnection } from "./CommandConnection"; import { UserTokenStore } from "../tokens/UserTokenStore"; import { ApiError, ErrCode } from "../api"; -import { OpenProjectWebhookPayloadWorkPackage } from "../openproject/types"; +import { OpenProjectWebhookPayloadWorkPackage } from "../openproject/Types"; import { BridgeOpenProjectConfig } from "../config/sections/OpenProject"; import { formatWorkPackageDiff, formatWorkPackageForMatrix, -} from "../openproject/format"; +} from "../openproject/Format"; import { IBridgeStorageProvider } from "../stores/StorageProvider"; -import { workPackageToCacheState } from "../openproject/state"; +import { workPackageToCacheState } from "../openproject/State"; import { OpenProjectGrantChecker } from "../openproject/GrantChecker"; import { GetConnectionsResponseItem } from "../widgets/Api"; diff --git a/src/Webhooks.ts b/src/Webhooks.ts index 0f060935f..e3e9826a0 100644 --- a/src/Webhooks.ts +++ b/src/Webhooks.ts @@ -27,7 +27,7 @@ import QuickLRU from "@alloc/quick-lru"; import type { WebhookEventName } from "@octokit/webhooks-types"; import { ApiError, ErrCode } from "./api"; import { OpenProjectWebhooksRouter } from "./openproject/Router"; -import { OAuthRequest } from "./tokens/oauth"; +import { OAuthRequest } from "./tokens/Oauth"; const log = new Logger("Webhooks"); diff --git a/src/jira/Router.ts b/src/jira/Router.ts index 7ffce6007..e8aec85e2 100644 --- a/src/jira/Router.ts +++ b/src/jira/Router.ts @@ -7,7 +7,7 @@ import { ApiError, ErrCode } from "../api"; import { JiraOAuthRequestOnPrem } from "./OAuth"; import { HookshotJiraApi } from "./Client"; import { createHmac } from "node:crypto"; -import { OAuthRequest, OAuthRequestResult } from "../tokens/oauth"; +import { OAuthRequest, OAuthRequestResult } from "../tokens/Oauth"; type JiraOAuthRequestCloud = OAuthRequest; diff --git a/src/openproject/adminCommands.ts b/src/openproject/AdminCommands.ts similarity index 100% rename from src/openproject/adminCommands.ts rename to src/openproject/AdminCommands.ts diff --git a/src/openproject/client.ts b/src/openproject/Client.ts similarity index 97% rename from src/openproject/client.ts rename to src/openproject/Client.ts index 86979bff8..c0859202a 100644 --- a/src/openproject/client.ts +++ b/src/openproject/Client.ts @@ -3,9 +3,9 @@ import { OpenProjectProject, OpenProjectStoredToken, OpenProjectUser, -} from "./types"; +} from "./Types"; import { Logger } from "matrix-appservice-bridge"; -import { OpenProjectOAuth } from "./oauth"; +import { OpenProjectOAuth } from "./Oauth"; const log = new Logger("OpenProjectAPIClient"); diff --git a/src/openproject/format.ts b/src/openproject/Format.ts similarity index 98% rename from src/openproject/format.ts rename to src/openproject/Format.ts index fa0efdd4f..3c72d4f06 100644 --- a/src/openproject/format.ts +++ b/src/openproject/Format.ts @@ -2,8 +2,8 @@ import { OpenProjectEventsNames } from "../Connections/OpenProjectConnection"; import { OpenProjectWorkPackageCacheState, workPackageToCacheState, -} from "./state"; -import { OpenProjectWorkPackage } from "./types"; +} from "./State"; +import { OpenProjectWorkPackage } from "./Types"; export function formatWorkPackageForMatrix( pkg: OpenProjectWorkPackage, diff --git a/src/openproject/oauth.ts b/src/openproject/Oauth.ts similarity index 98% rename from src/openproject/oauth.ts rename to src/openproject/Oauth.ts index 574525071..4527a1dcd 100644 --- a/src/openproject/oauth.ts +++ b/src/openproject/Oauth.ts @@ -1,6 +1,6 @@ import { BridgeOpenProjectOAuthConfig } from "../config/sections/OpenProject"; import { UserTokenStore } from "../tokens/UserTokenStore"; -import { OAuthRequest, OAuthRequestResult } from "../tokens/oauth"; +import { OAuthRequest, OAuthRequestResult } from "../tokens/Oauth"; import { Logger } from "matrix-appservice-bridge"; const log = new Logger("OpenProjectOAuth"); diff --git a/src/openproject/Router.ts b/src/openproject/Router.ts index fdd48410a..9a0668c54 100644 --- a/src/openproject/Router.ts +++ b/src/openproject/Router.ts @@ -1,11 +1,11 @@ import { Request, Response, Router, json } from "express"; import { BridgeOpenProjectConfig } from "../config/sections/OpenProject"; import { MessageQueue } from "../messageQueue"; -import { OpenProjectWebhookPayload } from "./types"; +import { OpenProjectWebhookPayload } from "./Types"; import { ApiError, ErrCode } from "../api"; import { createHmac } from "node:crypto"; import { Logger } from "matrix-appservice-bridge"; -import { OAuthRequest, OAuthRequestResult } from "../tokens/oauth"; +import { OAuthRequest, OAuthRequestResult } from "../tokens/Oauth"; const log = new Logger("OpenProjectWebhooksRouter"); export class OpenProjectWebhooksRouter { diff --git a/src/openproject/state.ts b/src/openproject/State.ts similarity index 94% rename from src/openproject/state.ts rename to src/openproject/State.ts index ea27a8480..3a908efd0 100644 --- a/src/openproject/state.ts +++ b/src/openproject/State.ts @@ -1,4 +1,4 @@ -import { OpenProjectWorkPackage } from "./types"; +import { OpenProjectWorkPackage } from "./Types"; export function workPackageToCacheState( pkg: OpenProjectWorkPackage, diff --git a/src/openproject/types.ts b/src/openproject/Types.ts similarity index 100% rename from src/openproject/types.ts rename to src/openproject/Types.ts diff --git a/src/openproject/widgetApi.ts b/src/openproject/WidgetApi.ts similarity index 100% rename from src/openproject/widgetApi.ts rename to src/openproject/WidgetApi.ts diff --git a/src/stores/MemoryStorageProvider.ts b/src/stores/MemoryStorageProvider.ts index a0efa4a08..8682c53cb 100644 --- a/src/stores/MemoryStorageProvider.ts +++ b/src/stores/MemoryStorageProvider.ts @@ -4,7 +4,7 @@ import { IssuesGetResponseData } from "../github/Types"; import { ProvisionSession } from "matrix-appservice-bridge"; import QuickLRU from "@alloc/quick-lru"; import { SerializedGitlabDiscussionThreads } from "../gitlab/Types"; -import { OpenProjectWorkPackageCacheState } from "../openproject/state"; +import { OpenProjectWorkPackageCacheState } from "../openproject/State"; export class MemoryStorageProvider extends MSP diff --git a/src/stores/RedisStorageProvider.ts b/src/stores/RedisStorageProvider.ts index 23b89f0c9..586c6f4c4 100644 --- a/src/stores/RedisStorageProvider.ts +++ b/src/stores/RedisStorageProvider.ts @@ -7,7 +7,7 @@ import { IFilterInfo, IStorageProvider } from "matrix-bot-sdk"; import { ProvisionSession } from "matrix-appservice-bridge"; import { SerializedGitlabDiscussionThreads } from "../gitlab/Types"; import { BridgeConfigCache } from "../config/sections"; -import { OpenProjectWorkPackageCacheState } from "../openproject/state"; +import { OpenProjectWorkPackageCacheState } from "../openproject/State"; const BOT_SYNC_TOKEN_KEY = "bot.sync_token."; const BOT_FILTER_KEY = "bot.filter."; diff --git a/src/stores/StorageProvider.ts b/src/stores/StorageProvider.ts index a7fbfdc9e..aab839da3 100644 --- a/src/stores/StorageProvider.ts +++ b/src/stores/StorageProvider.ts @@ -2,7 +2,7 @@ import { ProvisioningStore } from "matrix-appservice-bridge"; import { IAppserviceStorageProvider, IStorageProvider } from "matrix-bot-sdk"; import { IssuesGetResponseData } from "../github/Types"; import { SerializedGitlabDiscussionThreads } from "../gitlab/Types"; -import { OpenProjectWorkPackageCacheState } from "../openproject/state"; +import { OpenProjectWorkPackageCacheState } from "../openproject/State"; // Some RSS feeds can return a very small number of items then bounce // back to their "normal" size, so we cannot just clobber the recent GUID list per request or else we'll diff --git a/src/tokens/oauth.ts b/src/tokens/Oauth.ts similarity index 100% rename from src/tokens/oauth.ts rename to src/tokens/Oauth.ts diff --git a/src/tokens/UserTokenStore.ts b/src/tokens/UserTokenStore.ts index 42e2b6cad..4ff94ec94 100644 --- a/src/tokens/UserTokenStore.ts +++ b/src/tokens/UserTokenStore.ts @@ -22,9 +22,9 @@ import { JiraCloudClient } from "../jira/client/CloudClient"; import { TokenError, TokenErrorCode } from "../Errors"; import { TypedEmitter } from "tiny-typed-emitter"; import { hashId, TokenEncryption, stringToAlgo } from "../libRs"; -import { OpenProjectOAuth } from "../openproject/oauth"; -import { OpenProjectStoredToken } from "../openproject/types"; -import { OpenProjectAPIClient } from "../openproject/client"; +import { OpenProjectOAuth } from "../openproject/Oauth"; +import { OpenProjectStoredToken } from "../openproject/Types"; +import { OpenProjectAPIClient } from "../openproject/Client"; const ACCOUNT_DATA_TYPE = "uk.half-shot.matrix-hookshot.github.password-store:"; const ACCOUNT_DATA_GITLAB_TYPE = From 256a83bdf732176e3c3b9acdf98861ff2a51a02e Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Wed, 14 May 2025 14:40:07 +0100 Subject: [PATCH 04/22] changelog --- changelog.d/1055.misc | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/1055.misc diff --git a/changelog.d/1055.misc b/changelog.d/1055.misc new file mode 100644 index 000000000..4abfa4c16 --- /dev/null +++ b/changelog.d/1055.misc @@ -0,0 +1 @@ +Apply prettier formatting, and use a consistent file naming scheme. From 6b4a04d6abf7a77c2976336a98d8748fe8442fdb Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Wed, 14 May 2025 14:48:51 +0100 Subject: [PATCH 05/22] fix imports --- src/AdminRoom.ts | 2 +- src/widgets/BridgeWidgetApi.ts | 2 +- tests/AdminRoomTest.ts | 1 - 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/AdminRoom.ts b/src/AdminRoom.ts index e146baa15..bd873a2ca 100644 --- a/src/AdminRoom.ts +++ b/src/AdminRoom.ts @@ -38,7 +38,7 @@ import { ProjectsListResponseData } from "./github/Types"; import { UserTokenStore } from "./tokens/UserTokenStore"; import { Logger } from "matrix-appservice-bridge"; import markdown from "markdown-it"; -import { OpenProjectBotCommands } from "./openproject/adminCommands"; +import { OpenProjectBotCommands } from "./openproject/AdminCommands"; type ProjectsListForRepoResponseData = Endpoints["GET /repos/{owner}/{repo}/projects"]["response"]; type ProjectsListForUserResponseData = diff --git a/src/widgets/BridgeWidgetApi.ts b/src/widgets/BridgeWidgetApi.ts index 81cd8193e..59212d337 100644 --- a/src/widgets/BridgeWidgetApi.ts +++ b/src/widgets/BridgeWidgetApi.ts @@ -20,7 +20,7 @@ import { TokenType, UserTokenStore, } from "../tokens/UserTokenStore"; -import { OpenProjectWidgetAPI } from "../openproject/widgetApi"; +import { OpenProjectWidgetAPI } from "../openproject/WidgetApi"; const log = new Logger("BridgeWidgetApi"); diff --git a/tests/AdminRoomTest.ts b/tests/AdminRoomTest.ts index a2da15992..1eac1691e 100644 --- a/tests/AdminRoomTest.ts +++ b/tests/AdminRoomTest.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ import { expect } from "chai"; import { AdminRoom } from "../src/AdminRoom"; import { DefaultConfig } from "../src/config/Defaults"; From 61a6eed1d0d8e63290055f9577e66e5cb4dfeefc Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Wed, 14 May 2025 14:52:51 +0100 Subject: [PATCH 06/22] more lint --- src/AdminRoom.ts | 1 - src/ConnectionManager.ts | 1 - src/Connections/GithubRepo.ts | 1 - src/Connections/GitlabRepo.ts | 2 +- src/Connections/IConnection.ts | 1 - src/Connections/JiraProject.ts | 1 - src/Connections/OpenProjectConnection.ts | 1 - src/Connections/SetupConnection.ts | 2 +- src/config/Decorators.ts | 1 - src/grants/GrantCheck.ts | 2 +- src/notifications/GitHubWatcher.ts | 1 - 11 files changed, 3 insertions(+), 11 deletions(-) diff --git a/src/AdminRoom.ts b/src/AdminRoom.ts index bd873a2ca..0944e460d 100644 --- a/src/AdminRoom.ts +++ b/src/AdminRoom.ts @@ -843,7 +843,6 @@ export class AdminRoom extends AdminRoomCommandHandler { } } - const res = compileBotCommands( AdminRoom.prototype as any, GitHubBotCommands.prototype as any, diff --git a/src/ConnectionManager.ts b/src/ConnectionManager.ts index c4bfa46d3..5ab7db2d3 100644 --- a/src/ConnectionManager.ts +++ b/src/ConnectionManager.ts @@ -555,7 +555,6 @@ export class ConnectionManager extends EventEmitter { ) as OpenProjectConnection[]; } - public getAllConnectionsOfType( typeT: new (...params: any[]) => T, ): T[] { diff --git a/src/Connections/GithubRepo.ts b/src/Connections/GithubRepo.ts index 91944adc8..7e73ee9c5 100644 --- a/src/Connections/GithubRepo.ts +++ b/src/Connections/GithubRepo.ts @@ -2007,7 +2007,6 @@ export class GitHubRepoConnection } } - const res = compileBotCommands( GitHubRepoConnection.prototype as any, CommandConnection.prototype as any, diff --git a/src/Connections/GitlabRepo.ts b/src/Connections/GitlabRepo.ts index 575aaa9dc..ffb3ecbbf 100644 --- a/src/Connections/GitlabRepo.ts +++ b/src/Connections/GitlabRepo.ts @@ -1298,7 +1298,7 @@ ${data.description}`; } // Typescript doesn't understand Prototypes very well yet. - + const res = compileBotCommands( GitLabRepoConnection.prototype as any, CommandConnection.prototype as any, diff --git a/src/Connections/IConnection.ts b/src/Connections/IConnection.ts index f1cdb1e15..f696225ac 100644 --- a/src/Connections/IConnection.ts +++ b/src/Connections/IConnection.ts @@ -132,7 +132,6 @@ export interface InstantiateConnectionOpts { github?: GithubInstance; } export interface ProvisionConnectionOpts extends InstantiateConnectionOpts { - getAllConnectionsOfType( typeT: new (...params: any[]) => T, ): T[]; diff --git a/src/Connections/JiraProject.ts b/src/Connections/JiraProject.ts index f2619b6fe..4998a7ca2 100644 --- a/src/Connections/JiraProject.ts +++ b/src/Connections/JiraProject.ts @@ -777,7 +777,6 @@ export class JiraProjectConnection } } - const res = compileBotCommands( JiraProjectConnection.prototype as any, CommandConnection.prototype as any, diff --git a/src/Connections/OpenProjectConnection.ts b/src/Connections/OpenProjectConnection.ts index 5a903fb05..5d580e253 100644 --- a/src/Connections/OpenProjectConnection.ts +++ b/src/Connections/OpenProjectConnection.ts @@ -503,7 +503,6 @@ export class OpenProjectConnection } } - const res = compileBotCommands( OpenProjectConnection.prototype as any, CommandConnection.prototype as any, diff --git a/src/Connections/SetupConnection.ts b/src/Connections/SetupConnection.ts index 1114fa63a..08ad3743e 100644 --- a/src/Connections/SetupConnection.ts +++ b/src/Connections/SetupConnection.ts @@ -997,7 +997,7 @@ export class SetupConnection extends CommandConnection { } // Typescript doesn't understand Prototypes very well yet. - + const res = compileBotCommands( SetupConnection.prototype as any, CommandConnection.prototype as any, diff --git a/src/config/Decorators.ts b/src/config/Decorators.ts index 0eb2f5f55..436c28266 100644 --- a/src/config/Decorators.ts +++ b/src/config/Decorators.ts @@ -6,7 +6,6 @@ export function configKey(comment?: string, optional = false) { return Reflect.metadata(configKeyMetadataKey, [comment, optional]); } - export function getConfigKeyMetadata( target: any, propertyKey: string, diff --git a/src/grants/GrantCheck.ts b/src/grants/GrantCheck.ts index 4a5958faa..982c19961 100644 --- a/src/grants/GrantCheck.ts +++ b/src/grants/GrantCheck.ts @@ -45,7 +45,7 @@ export class GrantChecker { * * By default, this always returns false. */ - + protected checkFallback( _roomId: string, _connectionId: cId, diff --git a/src/notifications/GitHubWatcher.ts b/src/notifications/GitHubWatcher.ts index 2f32186b1..cba615a9c 100644 --- a/src/notifications/GitHubWatcher.ts +++ b/src/notifications/GitHubWatcher.ts @@ -153,7 +153,6 @@ export class GitHubWatcher pull_number: ev.subject.url_data.number, owner: rawEvent.repository.owner.login, repo: rawEvent.repository.name, - }) ).data as any; ev.subject.reviews = ( From 16e7b13203c7ce96ba3e7315e22ea245ddc504f1 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Wed, 14 May 2025 14:57:56 +0100 Subject: [PATCH 07/22] even more import fixes --- tests/MessageQueueTest.ts | 2 +- tests/connections/GenericHookTest.ts | 2 +- web/components/roomConfig/OutboundWebhookConfig.tsx | 9 +++++---- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/tests/MessageQueueTest.ts b/tests/MessageQueueTest.ts index d67072998..b8931eebe 100644 --- a/tests/MessageQueueTest.ts +++ b/tests/MessageQueueTest.ts @@ -1,5 +1,5 @@ import { expect } from "chai"; -import { createMessageQueue } from "../src/messageQueue/messageQueue"; +import { createMessageQueue } from "../src/messageQueue/MessageQueue"; const mq = createMessageQueue(); diff --git a/tests/connections/GenericHookTest.ts b/tests/connections/GenericHookTest.ts index b1bd0c3c2..07db70ab3 100644 --- a/tests/connections/GenericHookTest.ts +++ b/tests/connections/GenericHookTest.ts @@ -18,7 +18,7 @@ import { MemoryStorageProvider } from "../../src/stores/MemoryStorageProvider"; import { BridgeConfig } from "../../src/config/Config"; import { ProvisionConnectionOpts } from "../../src/Connections"; import { add } from "date-fns"; -import { WebhookTransformer } from "../../src/generic/transformer"; +import { WebhookTransformer } from "../../src/generic/WebhookTransformer"; const ROOM_ID = "!foo:bar"; diff --git a/web/components/roomConfig/OutboundWebhookConfig.tsx b/web/components/roomConfig/OutboundWebhookConfig.tsx index bc694b442..10a1c9392 100644 --- a/web/components/roomConfig/OutboundWebhookConfig.tsx +++ b/web/components/roomConfig/OutboundWebhookConfig.tsx @@ -8,6 +8,7 @@ import type { import { ConnectionConfigurationProps, RoomConfig } from "./RoomConfig"; import { InputField, ButtonSet, Button } from "../elements"; import WebhookIcon from "../../icons/webhook.png"; +import { MouseEventHandler } from "preact/compat"; const ConnectionConfiguration: FunctionComponent< ConnectionConfigurationProps< @@ -40,8 +41,8 @@ const ConnectionConfiguration: FunctionComponent< [canEdit, onSave, nameRef, outboundUrl, existingConnection], ); - const onUrlChange = useCallback( - (evt: any) => { + const onUrlChange = useCallback>( + (evt) => { setOutboundUrl(evt.target?.value); }, [setOutboundUrl], @@ -49,8 +50,8 @@ const ConnectionConfiguration: FunctionComponent< const [tokenRevealed, setTokenRevealed] = useState(false); - const revealToken = useCallback( - (evt: any) => { + const revealToken = useCallback>( + (evt) => { evt.preventDefault(); setTokenRevealed(true); }, From f82827cd23caa49573f5d0c75f1f63f013a31297 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Wed, 14 May 2025 15:10:09 +0100 Subject: [PATCH 08/22] Add create package command --- src/Connections/OpenProjectConnection.ts | 17 ++++++++++++++++- src/openproject/Client.ts | 16 +++++++++++++++- src/openproject/Types.ts | 2 +- 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/src/Connections/OpenProjectConnection.ts b/src/Connections/OpenProjectConnection.ts index 5d580e253..dc8503391 100644 --- a/src/Connections/OpenProjectConnection.ts +++ b/src/Connections/OpenProjectConnection.ts @@ -8,7 +8,7 @@ import { import { Appservice, Intent, StateEvent } from "matrix-bot-sdk"; import { Logger } from "matrix-appservice-bridge"; import markdownit from "markdown-it"; -import { BotCommands, compileBotCommands } from "../BotCommands"; +import { botCommand, BotCommands, compileBotCommands } from "../BotCommands"; import { MatrixMessageContent } from "../MatrixEvent"; import { CommandConnection } from "./CommandConnection"; import { UserTokenStore } from "../tokens/UserTokenStore"; @@ -23,6 +23,7 @@ import { IBridgeStorageProvider } from "../stores/StorageProvider"; import { workPackageToCacheState } from "../openproject/State"; import { OpenProjectGrantChecker } from "../openproject/GrantChecker"; import { GetConnectionsResponseItem } from "../widgets/Api"; +import { NotLoggedInError } from "../Errors"; export type OpenProjectEventsNames = | "work_package:created" @@ -439,6 +440,20 @@ export class OpenProjectConnection ); } + @botCommand("create workpackage", { help: "Create a new work package", requiredArgs: ["subject"], optionalArgs: ["description"], includeUserId: true}) + public async commandCreateWorkPackage(userId: string, subject: string, description?: string){ + const client = await this.tokenStore.getOpenProjectForUser(userId); + if (!client) { + throw new NotLoggedInError(); + } + const wp = await client.createWorkPackage(this.projectId, subject, description); + if (this.state.events.includes('work_package:created')) { + // If we're listening for creation events, skip this. + return; + } + console.log(wp); + } + public static getProvisionerDetails(botUserId: string) { return { service: "openproject", diff --git a/src/openproject/Client.ts b/src/openproject/Client.ts index c0859202a..5559e67e2 100644 --- a/src/openproject/Client.ts +++ b/src/openproject/Client.ts @@ -1,8 +1,9 @@ -import axios, { Method } from "axios"; +import axios, { formToJSON, Method } from "axios"; import { OpenProjectProject, OpenProjectStoredToken, OpenProjectUser, + OpenProjectWorkPackage, } from "./Types"; import { Logger } from "matrix-appservice-bridge"; import { OpenProjectOAuth } from "./Oauth"; @@ -101,4 +102,17 @@ export class OpenProjectAPIClient { `/api/v3/projects/${encodeURIComponent(projectId)}`, ); } + + + async createWorkPackage(projectId: number, subject: string, description?: string): Promise { + const wp: Partial = { + subject + }; + if (description) { + wp.description = { raw: description, format: "markdown" }; + } + return this.apiRequest( + `/api/v3/projects/${encodeURIComponent(projectId)}/work_packages`, 'POST', wp + ); + } } diff --git a/src/openproject/Types.ts b/src/openproject/Types.ts index 1e590b50f..02d9f9527 100644 --- a/src/openproject/Types.ts +++ b/src/openproject/Types.ts @@ -58,7 +58,7 @@ export interface OpenProjectWorkPackage { id: number; lockVersion: number; subject: string; - description: { format: "markdown"; raw: ""; html: "" }; + description: { format: "markdown"; raw: string; html?: string }; scheduleManually: boolean; startDate: null; dueDate: string | null; From ca7e2b37f6182cd61037b4dc1e5768ecff6cabd0 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Wed, 14 May 2025 16:02:12 +0100 Subject: [PATCH 09/22] Refactor and add command for creating a work package based off a reply. --- src/AdminRoom.ts | 1 + src/BotCommands.ts | 19 +++++++-- src/Bridge.ts | 53 ++++++++++++++---------- src/Connections/CommandConnection.ts | 4 +- src/Connections/GithubRepo.ts | 6 +-- src/Connections/IConnection.ts | 2 +- src/Connections/OpenProjectConnection.ts | 27 +++++++++--- src/MatrixEvent.ts | 2 +- 8 files changed, 78 insertions(+), 36 deletions(-) diff --git a/src/AdminRoom.ts b/src/AdminRoom.ts index 0944e460d..ac232ca19 100644 --- a/src/AdminRoom.ts +++ b/src/AdminRoom.ts @@ -787,6 +787,7 @@ export class AdminRoom extends AdminRoomCommandHandler { const result = await handleCommand( this.userId, command, + undefined, AdminRoom.botCommands, this, checkPermission, diff --git a/src/BotCommands.ts b/src/BotCommands.ts index 97140596a..fb4a162b6 100644 --- a/src/BotCommands.ts +++ b/src/BotCommands.ts @@ -1,7 +1,7 @@ import markdown from "markdown-it"; import { ApiError } from "./api"; import { CommandError } from "./Errors"; -import { MatrixMessageContent } from "./MatrixEvent"; +import { MatrixEvent, MatrixMessageContent } from "./MatrixEvent"; import { BridgePermissionLevel } from "./config/Config"; import { PermissionCheckFn } from "./Connections"; @@ -35,13 +35,14 @@ export interface BotCommandOptions { requiredArgs?: string[]; optionalArgs?: string[]; includeUserId?: boolean; + includeReply?: boolean; category?: string; permissionLevel?: BridgePermissionLevel; permissionService?: string; } type BotCommandResult = { status?: boolean; reaction?: string } | undefined; -type BotCommandFunction = (...args: string[]) => Promise; +type BotCommandFunction = (...args: unknown[]) => Promise; export type BotCommands = { [prefix: string]: { fn: BotCommandFunction } & BotCommandOptions; @@ -81,6 +82,7 @@ export function compileBotCommands( optionalArgs: b.optionalArgs, includeUserId: b.includeUserId, category: b.category, + includeReply: b.includeReply, }; } }); @@ -142,6 +144,7 @@ interface CommandResultErrorHuman { export async function handleCommand( userId: string, command: string, + parentEvent: MatrixEvent|undefined, botCommands: BotCommands, obj: unknown, permissionCheckFn: PermissionCheckFn, @@ -179,6 +182,13 @@ export async function handleCommand( humanError: "You do not have permission to use this command.", }; } + if (!command.includeReply && parentEvent) { + // Ignore replies if we aren't expecting one. + return { + handled: false, + } + } + if ( command.requiredArgs && command.requiredArgs.length > parts.length - i @@ -188,10 +198,13 @@ export async function handleCommand( humanError: "Missing at least one required parameter.", }; } - const args = parts.slice(i); + const args: unknown[] = parts.slice(i); if (command.includeUserId) { args.splice(0, 0, userId); } + if (command.includeReply) { + args.splice(1, 0, parentEvent); + } try { const result = await botCommands[prefix].fn.apply(obj, args); return { handled: true, result }; diff --git a/src/Bridge.ts b/src/Bridge.ts index 65de7d9ff..eff61efac 100644 --- a/src/Bridge.ts +++ b/src/Bridge.ts @@ -7,7 +7,6 @@ import { import { Appservice, RichRepliesPreprocessor, - IRichReplyMetadata, StateEvent, EventKind, PowerLevelsEvent, @@ -1335,21 +1334,31 @@ export class Bridge { `Got message roomId=${roomId} type=${event.type} from=${event.sender}`, ); log.debug("Content:", JSON.stringify(event)); - let processedReply; - let processedReplyMetadata: IRichReplyMetadata | undefined = undefined; - try { - processedReply = await this.replyProcessor.processEvent( - event, - this.as.botClient, - EventKind.RoomEvent, - ); - processedReplyMetadata = processedReply?.mx_richreply; - } catch (ex) { - log.warn( - `Event ${event.event_id} could not be processed by the reply processor, possibly a faulty event`, - ex, - ); + + let replyEvent: MatrixEvent|undefined; + if (event.content["m.relates_to"]?.["m.in_reply_to"]) { + if (event.content.formatted_body?.includes('')) { + // This is a legacy fallback reply: + try { + const processedReply = await this.replyProcessor.processEvent( + event, + this.as.botClient, + EventKind.RoomEvent, + ); + replyEvent = processedReply?.mx_richreply.realEvent; + } catch (ex) { + log.warn( + `Event ${event.event_id} could not be processed by the reply processor, possibly a faulty event`, + ex, + ); + } + } else { + // This is a new style reply. + const parentEventId = event.content["m.relates_to"]?.["m.in_reply_to"].event_id; + replyEvent = await this.as.botClient.getEvent(roomId, parentEventId); + } } + const adminRoom = this.adminRooms.get(roomId); const checkPermission = (service: string, level: BridgePermissionLevel) => this.config.checkPermission(event.sender, service, level); @@ -1375,7 +1384,7 @@ export class Bridge { handled = await connection.onMessageEvent( event, checkPermission, - processedReplyMetadata, + replyEvent, ); } } catch (ex) { @@ -1446,19 +1455,19 @@ export class Bridge { return; } - if (processedReply && processedReplyMetadata) { + if (replyEvent) { log.info( - `Handling reply to ${processedReplyMetadata.parentEventId} for ${adminRoom.userId}`, + `Handling reply to ${replyEvent.event_id} for ${adminRoom.userId}`, ); // This might be a reply to a notification try { - const ev = processedReplyMetadata.realEvent; + const evContent = replyEvent.content as any; const splitParts: string[] = - ev.content["uk.half-shot.matrix-hookshot.github.repo"]?.name.split( + evContent["uk.half-shot.matrix-hookshot.github.repo"]?.name.split( "/", ); const issueNumber = - ev.content["uk.half-shot.matrix-hookshot.github.issue"]?.number; + evContent["uk.half-shot.matrix-hookshot.github.issue"]?.number; if (splitParts && issueNumber) { log.info(`Handling reply for ${splitParts}${issueNumber}`); const connections = @@ -1470,7 +1479,7 @@ export class Bridge { await Promise.all( connections.map(async (c) => { if (c instanceof GitHubIssueConnection) { - return c.onMatrixIssueComment(processedReply); + return c.onMatrixIssueComment(replyEvent as any); } }), ); diff --git a/src/Connections/CommandConnection.ts b/src/Connections/CommandConnection.ts index 6114ea88b..c5fbd24d6 100644 --- a/src/Connections/CommandConnection.ts +++ b/src/Connections/CommandConnection.ts @@ -5,7 +5,7 @@ import { HelpFunction, } from "../BotCommands"; import { Logger } from "matrix-appservice-bridge"; -import { MatrixClient } from "matrix-bot-sdk"; +import { IRichReplyMetadata, MatrixClient, MessageEvent } from "matrix-bot-sdk"; import { MatrixMessageContent, MatrixEvent } from "../MatrixEvent"; import { BaseConnection } from "./BaseConnection"; import { IConnectionState, PermissionCheckFn } from "."; @@ -54,10 +54,12 @@ export abstract class CommandConnection< public async onMessageEvent( ev: MatrixEvent, checkPermission: PermissionCheckFn, + parentEvent?: MatrixEvent, ) { const commandResult = await handleCommand( ev.sender, ev.content.body, + parentEvent, this.botCommands, this, checkPermission, diff --git a/src/Connections/GithubRepo.ts b/src/Connections/GithubRepo.ts index 7e73ee9c5..8cc8495a2 100644 --- a/src/Connections/GithubRepo.ts +++ b/src/Connections/GithubRepo.ts @@ -849,7 +849,7 @@ export class GitHubRepoConnection public async onMessageEvent( ev: MatrixEvent, checkPermission: PermissionCheckFn, - reply?: IRichReplyMetadata, + reply?: MatrixEvent, ): Promise { if (await super.onMessageEvent(ev, checkPermission)) { return true; @@ -857,9 +857,9 @@ export class GitHubRepoConnection const body = ev.content.body?.trim(); if (reply) { const repoInfo = - reply.realEvent.content["uk.half-shot.matrix-hookshot.github.repo"]; + reply.content["uk.half-shot.matrix-hookshot.github.repo"]; const pullRequestId = - reply.realEvent.content[ + reply.content[ "uk.half-shot.matrix-hookshot.github.pull_request" ]?.number; // Emojis can be multi-byte, so make sure we split properly diff --git a/src/Connections/IConnection.ts b/src/Connections/IConnection.ts index f696225ac..fcc8ce4de 100644 --- a/src/Connections/IConnection.ts +++ b/src/Connections/IConnection.ts @@ -66,7 +66,7 @@ export interface IConnection { onMessageEvent?: ( ev: MatrixEvent, checkPermission: PermissionCheckFn, - replyMetadata?: IRichReplyMetadata, + parentEvent?: MatrixEvent, ) => Promise; onIssueCreated?: (ev: IssuesOpenedEvent) => Promise; diff --git a/src/Connections/OpenProjectConnection.ts b/src/Connections/OpenProjectConnection.ts index dc8503391..37dc4894a 100644 --- a/src/Connections/OpenProjectConnection.ts +++ b/src/Connections/OpenProjectConnection.ts @@ -5,7 +5,7 @@ import { InstantiateConnectionOpts, ProvisionConnectionOpts, } from "./IConnection"; -import { Appservice, Intent, StateEvent } from "matrix-bot-sdk"; +import { Appservice, Intent, MatrixEvent, MessageEventContent, StateEvent } from "matrix-bot-sdk"; import { Logger } from "matrix-appservice-bridge"; import markdownit from "markdown-it"; import { botCommand, BotCommands, compileBotCommands } from "../BotCommands"; @@ -23,7 +23,7 @@ import { IBridgeStorageProvider } from "../stores/StorageProvider"; import { workPackageToCacheState } from "../openproject/State"; import { OpenProjectGrantChecker } from "../openproject/GrantChecker"; import { GetConnectionsResponseItem } from "../widgets/Api"; -import { NotLoggedInError } from "../Errors"; +import { CommandError, NotLoggedInError } from "../Errors"; export type OpenProjectEventsNames = | "work_package:created" @@ -440,13 +440,30 @@ export class OpenProjectConnection ); } - @botCommand("create workpackage", { help: "Create a new work package", requiredArgs: ["subject"], optionalArgs: ["description"], includeUserId: true}) - public async commandCreateWorkPackage(userId: string, subject: string, description?: string){ + @botCommand("create workpackage", { help: "Create a new work package", requiredArgs: ["subject"], optionalArgs: ["description"], includeUserId: true, includeReply: true}) + public async commandCreateWorkPackage(userId: string, reply: MatrixEvent|undefined, subject: string, cmdDescription?: string){ + let finalDescription: string|undefined; + if (reply) { + if (reply.type !== 'm.room.message') { + throw new CommandError('Reply was not a m.room.message', 'You can only use textual events as work package descriptions.'); + } + const replyContent = reply.content as MessageEventContent; + if (!replyContent.body?.trim()) { + throw new CommandError('Source message had no body', 'This event has no content and cannot be used.'); + } + if (cmdDescription) { + finalDescription = `${cmdDescription}\n\n${replyContent.body}` + } else { + finalDescription = replyContent.body; + } + } else { + finalDescription = cmdDescription; + } const client = await this.tokenStore.getOpenProjectForUser(userId); if (!client) { throw new NotLoggedInError(); } - const wp = await client.createWorkPackage(this.projectId, subject, description); + const wp = await client.createWorkPackage(this.projectId, subject, finalDescription); if (this.state.events.includes('work_package:created')) { // If we're listening for creation events, skip this. return; diff --git a/src/MatrixEvent.ts b/src/MatrixEvent.ts index 4815be38d..a938a9e88 100644 --- a/src/MatrixEvent.ts +++ b/src/MatrixEvent.ts @@ -41,4 +41,4 @@ export interface MatrixReactionContent extends MatrixEventContent { key: string; rel_type: "m.annotation"; }; -} +} \ No newline at end of file From 0ad8fd462ced8f980b4a5312cc9ad6ca647bc375 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Wed, 14 May 2025 16:49:00 +0100 Subject: [PATCH 10/22] Add close command --- src/BotCommands.ts | 4 +- src/Bridge.ts | 7 +- src/Connections/GithubRepo.ts | 5 +- src/Connections/OpenProjectConnection.ts | 158 +++++++++++++++++++++-- src/MatrixEvent.ts | 2 +- src/openproject/Client.ts | 60 +++++++-- src/openproject/Format.ts | 37 +++++- src/openproject/Types.ts | 6 + 8 files changed, 248 insertions(+), 31 deletions(-) diff --git a/src/BotCommands.ts b/src/BotCommands.ts index fb4a162b6..b7e2088de 100644 --- a/src/BotCommands.ts +++ b/src/BotCommands.ts @@ -144,7 +144,7 @@ interface CommandResultErrorHuman { export async function handleCommand( userId: string, command: string, - parentEvent: MatrixEvent|undefined, + parentEvent: MatrixEvent | undefined, botCommands: BotCommands, obj: unknown, permissionCheckFn: PermissionCheckFn, @@ -186,7 +186,7 @@ export async function handleCommand( // Ignore replies if we aren't expecting one. return { handled: false, - } + }; } if ( diff --git a/src/Bridge.ts b/src/Bridge.ts index eff61efac..297cd0306 100644 --- a/src/Bridge.ts +++ b/src/Bridge.ts @@ -1335,9 +1335,9 @@ export class Bridge { ); log.debug("Content:", JSON.stringify(event)); - let replyEvent: MatrixEvent|undefined; + let replyEvent: MatrixEvent | undefined; if (event.content["m.relates_to"]?.["m.in_reply_to"]) { - if (event.content.formatted_body?.includes('')) { + if (event.content.formatted_body?.includes("")) { // This is a legacy fallback reply: try { const processedReply = await this.replyProcessor.processEvent( @@ -1354,7 +1354,8 @@ export class Bridge { } } else { // This is a new style reply. - const parentEventId = event.content["m.relates_to"]?.["m.in_reply_to"].event_id; + const parentEventId = + event.content["m.relates_to"]?.["m.in_reply_to"].event_id; replyEvent = await this.as.botClient.getEvent(roomId, parentEventId); } } diff --git a/src/Connections/GithubRepo.ts b/src/Connections/GithubRepo.ts index 8cc8495a2..cab7bec08 100644 --- a/src/Connections/GithubRepo.ts +++ b/src/Connections/GithubRepo.ts @@ -859,9 +859,8 @@ export class GitHubRepoConnection const repoInfo = reply.content["uk.half-shot.matrix-hookshot.github.repo"]; const pullRequestId = - reply.content[ - "uk.half-shot.matrix-hookshot.github.pull_request" - ]?.number; + reply.content["uk.half-shot.matrix-hookshot.github.pull_request"] + ?.number; // Emojis can be multi-byte, so make sure we split properly const reviewKey = Object.keys(EMOJI_TO_REVIEW_STATE).find((k) => k.includes(body.split(" ")[0]), diff --git a/src/Connections/OpenProjectConnection.ts b/src/Connections/OpenProjectConnection.ts index 37dc4894a..b3a1bbb23 100644 --- a/src/Connections/OpenProjectConnection.ts +++ b/src/Connections/OpenProjectConnection.ts @@ -5,7 +5,13 @@ import { InstantiateConnectionOpts, ProvisionConnectionOpts, } from "./IConnection"; -import { Appservice, Intent, MatrixEvent, MessageEventContent, StateEvent } from "matrix-bot-sdk"; +import { + Appservice, + Intent, + MatrixEvent, + MessageEventContent, + StateEvent, +} from "matrix-bot-sdk"; import { Logger } from "matrix-appservice-bridge"; import markdownit from "markdown-it"; import { botCommand, BotCommands, compileBotCommands } from "../BotCommands"; @@ -18,6 +24,7 @@ import { BridgeOpenProjectConfig } from "../config/sections/OpenProject"; import { formatWorkPackageDiff, formatWorkPackageForMatrix, + OpenProjectWorkPackageMatrixEvent, } from "../openproject/Format"; import { IBridgeStorageProvider } from "../stores/StorageProvider"; import { workPackageToCacheState } from "../openproject/State"; @@ -440,19 +447,37 @@ export class OpenProjectConnection ); } - @botCommand("create workpackage", { help: "Create a new work package", requiredArgs: ["subject"], optionalArgs: ["description"], includeUserId: true, includeReply: true}) - public async commandCreateWorkPackage(userId: string, reply: MatrixEvent|undefined, subject: string, cmdDescription?: string){ - let finalDescription: string|undefined; + @botCommand("create", { + help: "Create a new work package", + requiredArgs: ["type", "subject"], + optionalArgs: ["description"], + includeUserId: true, + includeReply: true, + }) + public async commandCreateWorkPackage( + userId: string, + reply: MatrixEvent | undefined, + type: string, + subject: string, + cmdDescription?: string, + ) { + let finalDescription: string | undefined; if (reply) { - if (reply.type !== 'm.room.message') { - throw new CommandError('Reply was not a m.room.message', 'You can only use textual events as work package descriptions.'); + if (reply.type !== "m.room.message") { + throw new CommandError( + "Reply was not a m.room.message", + "You can only use textual events as work package descriptions.", + ); } const replyContent = reply.content as MessageEventContent; if (!replyContent.body?.trim()) { - throw new CommandError('Source message had no body', 'This event has no content and cannot be used.'); + throw new CommandError( + "Source message had no body", + "This event has no content and cannot be used.", + ); } if (cmdDescription) { - finalDescription = `${cmdDescription}\n\n${replyContent.body}` + finalDescription = `${cmdDescription}\n\n${replyContent.body}`; } else { finalDescription = replyContent.body; } @@ -463,12 +488,121 @@ export class OpenProjectConnection if (!client) { throw new NotLoggedInError(); } - const wp = await client.createWorkPackage(this.projectId, subject, finalDescription); - if (this.state.events.includes('work_package:created')) { - // If we're listening for creation events, skip this. + const allTypes = await client.getTypesInProject(this.projectId); + const foundType = allTypes.find( + (t) => t.name.toLowerCase() === type.toLowerCase(), + ); + if (!foundType) { + throw new CommandError( + "Type not understood", + `Work package type not known. You can use ${allTypes.map((t) => (t.name.includes(" ") ? `"${t.name}"` : t.name)).join(", ")}`, + ); + } + const workPackage = await client.createWorkPackage( + this.projectId, + foundType, + subject, + finalDescription, + ); + await this.storage.setOpenProjectWorkPackageState( + workPackageToCacheState(workPackage), + workPackage.id, + ); + if (this.state.events.includes("work_package:created")) { + // Don't send an event if we're going to anyway. + return; + } + + const extraData = formatWorkPackageForMatrix( + workPackage, + this.config.baseURL, + ); + const content = `${workPackage._embedded.author.name} created a new work package [${workPackage.id}](${extraData["org.matrix.matrix-hookshot.openproject.work_package"].url}): "${workPackage.subject}"`; + await this.intent.sendEvent(this.roomId, { + msgtype: "m.notice", + body: content, + formatted_body: md.renderInline(content), + format: "org.matrix.custom.html", + ...formatWorkPackageForMatrix(workPackage, this.config.baseURL), + }); + } + + @botCommand("close", { + help: "Close a work package", + optionalArgs: ["workPackageId"], + includeUserId: true, + includeReply: true, + }) + public async commandCloseWorkPackage( + userId: string, + reply: MatrixEvent | undefined, + number?: string, + ) { + let finalDescription: string | undefined; + let workPackageId: number; + if (reply) { + const replyContent = reply.content as OpenProjectWorkPackageMatrixEvent; + if ( + reply.type !== "m.room.message" || + !replyContent["org.matrix.matrix-hookshot.openproject.project"].id || + !replyContent["org.matrix.matrix-hookshot.openproject.work_package"].id + ) { + throw new CommandError( + "Did not reference a hookshot event", + "You can only close work packages by referencing a work package event.", + ); + } + if ( + replyContent["org.matrix.matrix-hookshot.openproject.project"].id !== + this.projectId + ) { + // This is not us. + throw new CommandError( + "Wrong project ID", + "Wrong command for this project.", + ); + } + workPackageId = + replyContent["org.matrix.matrix-hookshot.openproject.work_package"].id; + } else if (number) { + workPackageId = parseInt(number); + } else { + throw new CommandError( + "No ID provided", + "You must provide a work package ID", + ); + } + if (isNaN(workPackageId)) { + throw new CommandError( + "Invalid work package ID", + '"Work Package ID must be a valid number".', + ); + } + const client = await this.tokenStore.getOpenProjectForUser(userId); + if (!client) { + throw new NotLoggedInError(); + } + const workPackage = await client.updateWorkPackage( + this.projectId, + workPackageId, + ); + if (this.state.events.includes("work_package:updated")) { + // Don't send an event if we're going to anyway. return; } - console.log(wp); + + const extraData = formatWorkPackageForMatrix( + workPackage, + this.config.baseURL, + ); + const content = `${workPackage._embedded.author.name} closed work package [${workPackage.id}](${extraData["org.matrix.matrix-hookshot.openproject.work_package"].url}): "${workPackage.subject}"`; + await this.intent.sendEvent(this.roomId, { + msgtype: "m.notice", + body: content, + formatted_body: md.renderInline(content), + format: "org.matrix.custom.html", + ...formatWorkPackageForMatrix(workPackage, this.config.baseURL), + }); } public static getProvisionerDetails(botUserId: string) { diff --git a/src/MatrixEvent.ts b/src/MatrixEvent.ts index a938a9e88..4815be38d 100644 --- a/src/MatrixEvent.ts +++ b/src/MatrixEvent.ts @@ -41,4 +41,4 @@ export interface MatrixReactionContent extends MatrixEventContent { key: string; rel_type: "m.annotation"; }; -} \ No newline at end of file +} diff --git a/src/openproject/Client.ts b/src/openproject/Client.ts index 5559e67e2..36a8fe077 100644 --- a/src/openproject/Client.ts +++ b/src/openproject/Client.ts @@ -2,6 +2,7 @@ import axios, { formToJSON, Method } from "axios"; import { OpenProjectProject, OpenProjectStoredToken, + OpenProjectType, OpenProjectUser, OpenProjectWorkPackage, } from "./Types"; @@ -12,6 +13,10 @@ const log = new Logger("OpenProjectAPIClient"); type OpenProjectProjectWithUrl = OpenProjectProject & { project_url: string }; +type OpenProjectIterableResult = { + _embedded: { elements: T[] }; +}; + export class OpenProjectAPIClient { private storedToken: OpenProjectStoredToken; constructor( @@ -77,17 +82,15 @@ export class OpenProjectAPIClient { { name_and_identifier: { operator: "~", values: [nameAndIdentifier] } }, ]; projects = ( - await this.apiRequest<{ - _embedded: { elements: OpenProjectProject[] }; - }>( + await this.apiRequest>( `/api/v3/projects?filters=${encodeURIComponent(JSON.stringify(query))}`, ) )._embedded.elements; } else { projects = ( - await this.apiRequest<{ - _embedded: { elements: OpenProjectProject[] }; - }>(`/api/v3/projects`) + await this.apiRequest>( + `/api/v3/projects`, + ) )._embedded.elements; } // Note: We take the first page of results here for now. @@ -103,16 +106,55 @@ export class OpenProjectAPIClient { ); } + async getTypesInProject(projectId: number): Promise { + // TODO: Paginate? + return ( + await this.apiRequest>( + `/api/v3/projects/${encodeURIComponent(projectId)}/types`, + ) + )._embedded.elements; + } - async createWorkPackage(projectId: number, subject: string, description?: string): Promise { + async createWorkPackage( + projectId: number, + type: OpenProjectType, + subject: string, + description?: string, + ): Promise { const wp: Partial = { - subject + subject, + _links: { + type: type._links.self, + } as any, }; if (description) { wp.description = { raw: description, format: "markdown" }; } return this.apiRequest( - `/api/v3/projects/${encodeURIComponent(projectId)}/work_packages`, 'POST', wp + `/api/v3/projects/${encodeURIComponent(projectId)}/work_packages`, + "POST", + wp, + ); + } + + // TODO: Make this more generic + async updateWorkPackage( + projectId: number, + workPackageId: number, + ): Promise { + const existingWp = await this.apiRequest( + `/api/v3/projects/${encodeURIComponent(projectId)}/work_packages/${workPackageId}`, + "GET", + ); + return this.apiRequest( + `/api/v3/projects/${encodeURIComponent(projectId)}/work_packages/${workPackageId}`, + "PATCH", + { + lockVersion: (await existingWp).lockVersion, + status: { + title: "Closed", + }, + }, ); } } diff --git a/src/openproject/Format.ts b/src/openproject/Format.ts index 3c72d4f06..30921cb05 100644 --- a/src/openproject/Format.ts +++ b/src/openproject/Format.ts @@ -1,14 +1,49 @@ import { OpenProjectEventsNames } from "../Connections/OpenProjectConnection"; +import { MatrixMessageContent } from "../MatrixEvent"; import { OpenProjectWorkPackageCacheState, workPackageToCacheState, } from "./State"; import { OpenProjectWorkPackage } from "./Types"; +export interface OpenProjectWorkPackageMatrixEvent { + "org.matrix.matrix-hookshot.openproject.work_package": { + id: number; + subject: string; + description: { + plain: string; + html?: string; + }; + url: string; + author: { + name: string; + url: string; + }; + assignee?: { + name: string; + url: string; + }; + status: { + name: string; + color: string; + }; + type: { + name: string; + color: string; + }; + }; + "org.matrix.matrix-hookshot.openproject.project": { + id: number; + name: string; + url: string; + }; + external_url: string; +} + export function formatWorkPackageForMatrix( pkg: OpenProjectWorkPackage, baseURL: URL, -) { +): OpenProjectWorkPackageMatrixEvent { const url = new URL( baseURL.href + `projects/${pkg._embedded.project.identifier}/work_packages/${pkg.id}`, diff --git a/src/openproject/Types.ts b/src/openproject/Types.ts index 02d9f9527..614f4db64 100644 --- a/src/openproject/Types.ts +++ b/src/openproject/Types.ts @@ -26,6 +26,12 @@ export interface OpenProjectType { createdAt: StringDate; updatedAt: StringDate; color: string; + _links: { + self: { + href: string; + title: string; + }; + }; } export interface OpenProjectPriority { From ddebc1df0cb6dcc80e5e548e4deaf388223929b9 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Wed, 14 May 2025 16:55:47 +0100 Subject: [PATCH 11/22] fixup --- src/Bridge.ts | 54 ++++++++++++++++++++++++++++++--------------------- 1 file changed, 32 insertions(+), 22 deletions(-) diff --git a/src/Bridge.ts b/src/Bridge.ts index 65de7d9ff..297cd0306 100644 --- a/src/Bridge.ts +++ b/src/Bridge.ts @@ -7,7 +7,6 @@ import { import { Appservice, RichRepliesPreprocessor, - IRichReplyMetadata, StateEvent, EventKind, PowerLevelsEvent, @@ -1335,21 +1334,32 @@ export class Bridge { `Got message roomId=${roomId} type=${event.type} from=${event.sender}`, ); log.debug("Content:", JSON.stringify(event)); - let processedReply; - let processedReplyMetadata: IRichReplyMetadata | undefined = undefined; - try { - processedReply = await this.replyProcessor.processEvent( - event, - this.as.botClient, - EventKind.RoomEvent, - ); - processedReplyMetadata = processedReply?.mx_richreply; - } catch (ex) { - log.warn( - `Event ${event.event_id} could not be processed by the reply processor, possibly a faulty event`, - ex, - ); + + let replyEvent: MatrixEvent | undefined; + if (event.content["m.relates_to"]?.["m.in_reply_to"]) { + if (event.content.formatted_body?.includes("")) { + // This is a legacy fallback reply: + try { + const processedReply = await this.replyProcessor.processEvent( + event, + this.as.botClient, + EventKind.RoomEvent, + ); + replyEvent = processedReply?.mx_richreply.realEvent; + } catch (ex) { + log.warn( + `Event ${event.event_id} could not be processed by the reply processor, possibly a faulty event`, + ex, + ); + } + } else { + // This is a new style reply. + const parentEventId = + event.content["m.relates_to"]?.["m.in_reply_to"].event_id; + replyEvent = await this.as.botClient.getEvent(roomId, parentEventId); + } } + const adminRoom = this.adminRooms.get(roomId); const checkPermission = (service: string, level: BridgePermissionLevel) => this.config.checkPermission(event.sender, service, level); @@ -1375,7 +1385,7 @@ export class Bridge { handled = await connection.onMessageEvent( event, checkPermission, - processedReplyMetadata, + replyEvent, ); } } catch (ex) { @@ -1446,19 +1456,19 @@ export class Bridge { return; } - if (processedReply && processedReplyMetadata) { + if (replyEvent) { log.info( - `Handling reply to ${processedReplyMetadata.parentEventId} for ${adminRoom.userId}`, + `Handling reply to ${replyEvent.event_id} for ${adminRoom.userId}`, ); // This might be a reply to a notification try { - const ev = processedReplyMetadata.realEvent; + const evContent = replyEvent.content as any; const splitParts: string[] = - ev.content["uk.half-shot.matrix-hookshot.github.repo"]?.name.split( + evContent["uk.half-shot.matrix-hookshot.github.repo"]?.name.split( "/", ); const issueNumber = - ev.content["uk.half-shot.matrix-hookshot.github.issue"]?.number; + evContent["uk.half-shot.matrix-hookshot.github.issue"]?.number; if (splitParts && issueNumber) { log.info(`Handling reply for ${splitParts}${issueNumber}`); const connections = @@ -1470,7 +1480,7 @@ export class Bridge { await Promise.all( connections.map(async (c) => { if (c instanceof GitHubIssueConnection) { - return c.onMatrixIssueComment(processedReply); + return c.onMatrixIssueComment(replyEvent as any); } }), ); From ab9c34fc2a15a05bb6e5c343d946077566a9ceef Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Wed, 14 May 2025 16:58:59 +0100 Subject: [PATCH 12/22] changelog --- changelog.d/1056.feature | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 changelog.d/1056.feature diff --git a/changelog.d/1056.feature b/changelog.d/1056.feature new file mode 100644 index 000000000..17adc7a60 --- /dev/null +++ b/changelog.d/1056.feature @@ -0,0 +1,2 @@ +Add support for OpenProject. + From 39ce6b880d25ad9dfb148c0f74b0e70380813517 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Thu, 15 May 2025 09:47:52 +0100 Subject: [PATCH 13/22] Add support for "global" commands. --- src/BotCommands.ts | 20 ++++++++++++++++---- src/Connections/CommandConnection.ts | 3 ++- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/src/BotCommands.ts b/src/BotCommands.ts index b7e2088de..78d346e9a 100644 --- a/src/BotCommands.ts +++ b/src/BotCommands.ts @@ -39,6 +39,7 @@ export interface BotCommandOptions { category?: string; permissionLevel?: BridgePermissionLevel; permissionService?: string; + runOnGlobalPrefix?: boolean; } type BotCommandResult = { status?: boolean; reaction?: string } | undefined; @@ -83,6 +84,7 @@ export function compileBotCommands( includeUserId: b.includeUserId, category: b.category, includeReply: b.includeReply, + runOnGlobalPrefix: b.runOnGlobalPrefix, }; } }); @@ -150,17 +152,21 @@ export async function handleCommand( permissionCheckFn: PermissionCheckFn, defaultPermissionService?: string, prefix?: string, + globalPrefix?: string, ): Promise< | CommandResultNotHandled | CommandResultSuccess | CommandResultErrorUnknown | CommandResultErrorHuman > { - if (prefix) { - if (!command.startsWith(prefix)) { - return { handled: false }; - } + let usingGlobalPrefix = false; + if (prefix && command.startsWith(prefix)) { command = command.substring(prefix.length); + } else if (globalPrefix && command.startsWith(globalPrefix)) { + usingGlobalPrefix = true; + command = command.substring(globalPrefix.length); + } else { + return { handled: false }; } const parts = (await stringArgv).parseArgsStringToArgv(command); for (let i = parts.length; i > 0; i--) { @@ -188,6 +194,12 @@ export async function handleCommand( handled: false, }; } + if (!command.runOnGlobalPrefix && usingGlobalPrefix) { + // Ignore global prefix commands. + return { + handled: false, + }; + } if ( command.requiredArgs && diff --git a/src/Connections/CommandConnection.ts b/src/Connections/CommandConnection.ts index c5fbd24d6..70962fa7b 100644 --- a/src/Connections/CommandConnection.ts +++ b/src/Connections/CommandConnection.ts @@ -40,7 +40,7 @@ export abstract class CommandConnection< } public conflictsWithCommandPrefix(commandPrefix: string) { - return this.commandPrefix == commandPrefix + " "; + return this.commandPrefix === commandPrefix + " "; } public async onStateUpdate(stateEv: MatrixEvent) { @@ -65,6 +65,7 @@ export abstract class CommandConnection< checkPermission, this.serviceName, this.commandPrefix, + this.defaultCommandPrefix, ); if (commandResult.handled !== true) { // Not for us. From 60adfbb135f5c950df8dc23e3c990b5a484bf06f Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Thu, 15 May 2025 09:48:03 +0100 Subject: [PATCH 14/22] Add url-join --- package.json | 1 + yarn.lock | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/package.json b/package.json index 24aa4498a..b4f7b3130 100644 --- a/package.json +++ b/package.json @@ -77,6 +77,7 @@ "source-map-support": "^0.5.21", "string-argv": "^0.3.1", "tiny-typed-emitter": "^2.1.0", + "url-join": "^5.0.0", "vite-plugin-magical-svg": "^1.1.1", "winston": "^3.11.0", "xml2js": "^0.6.2", diff --git a/yarn.lock b/yarn.lock index 759aa2705..634ed26c3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8254,6 +8254,11 @@ uri-js@^4.2.2: dependencies: punycode "^2.1.0" +url-join@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/url-join/-/url-join-5.0.0.tgz#c2f1e5cbd95fa91082a93b58a1f42fecb4bdbcf1" + integrity sha512-n2huDr9h9yzd6exQVnH/jU5mr+Pfx08LRXXZhkLLetAMESRj+anQsTAh940iMrIetKAmry9coFuZQ2jY8/p3WA== + url-parse@^1.5.3: version "1.5.10" resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.5.10.tgz#9d3c2f736c1d75dd3bd2be507dcc111f1e2ea9c1" From a74ac318343e76113670a70aa18d5d9595011b47 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Thu, 15 May 2025 09:48:08 +0100 Subject: [PATCH 15/22] Add close command. --- src/Connections/OpenProjectConnection.ts | 56 +++++++++++++++++------- src/openproject/Client.ts | 37 +++++++++++++--- src/openproject/Types.ts | 6 +++ 3 files changed, 76 insertions(+), 23 deletions(-) diff --git a/src/Connections/OpenProjectConnection.ts b/src/Connections/OpenProjectConnection.ts index b3a1bbb23..b2d49046f 100644 --- a/src/Connections/OpenProjectConnection.ts +++ b/src/Connections/OpenProjectConnection.ts @@ -529,23 +529,26 @@ export class OpenProjectConnection @botCommand("close", { help: "Close a work package", - optionalArgs: ["workPackageId"], + optionalArgs: ["workPackageId", "description"], includeUserId: true, includeReply: true, + // We allow uses to call global close. + runOnGlobalPrefix: true, }) public async commandCloseWorkPackage( userId: string, reply: MatrixEvent | undefined, - number?: string, + workPackageIdOrComment?: string, + comment?: string, ) { - let finalDescription: string | undefined; + let finalComment: string | undefined; let workPackageId: number; if (reply) { const replyContent = reply.content as OpenProjectWorkPackageMatrixEvent; if ( reply.type !== "m.room.message" || - !replyContent["org.matrix.matrix-hookshot.openproject.project"].id || - !replyContent["org.matrix.matrix-hookshot.openproject.work_package"].id + !replyContent["org.matrix.matrix-hookshot.openproject.project"]?.id || + !replyContent["org.matrix.matrix-hookshot.openproject.work_package"]?.id ) { throw new CommandError( "Did not reference a hookshot event", @@ -557,15 +560,14 @@ export class OpenProjectConnection this.projectId ) { // This is not us. - throw new CommandError( - "Wrong project ID", - "Wrong command for this project.", - ); + return; } workPackageId = replyContent["org.matrix.matrix-hookshot.openproject.work_package"].id; - } else if (number) { - workPackageId = parseInt(number); + finalComment = workPackageIdOrComment; + } else if (workPackageIdOrComment) { + workPackageId = parseInt(workPackageIdOrComment); + finalComment = comment; } else { throw new CommandError( "No ID provided", @@ -575,17 +577,39 @@ export class OpenProjectConnection if (isNaN(workPackageId)) { throw new CommandError( "Invalid work package ID", - '"Work Package ID must be a valid number".', + "Work Package ID must be a valid number", ); } + const client = await this.tokenStore.getOpenProjectForUser(userId); if (!client) { throw new NotLoggedInError(); } - const workPackage = await client.updateWorkPackage( - this.projectId, - workPackageId, - ); + + // TODO: Cache this. + const validStatuses = await client.getStatuses(); + // Prefer the "closed" status, but if that fails then we'll just use whatever status is used for closed. + const closedStatus = + validStatuses.find((s) => s.name.toLowerCase() === "closed") ?? + validStatuses.find((s) => s.isClosed); + + if (!closedStatus) { + throw new CommandError( + "No closed status on OpenProject", + "This instance doesn't have a closed status, so the work package cannot be closed.", + ); + } + + const workPackage = await client.updateWorkPackage(workPackageId, { + _links: { + status: { + href: closedStatus?._links.self.href, + }, + }, + }); + if (finalComment) { + await client.addWorkPackageComment(workPackageId, finalComment); + } if (this.state.events.includes("work_package:updated")) { // Don't send an event if we're going to anyway. return; diff --git a/src/openproject/Client.ts b/src/openproject/Client.ts index 593b9f4b8..246d2dd76 100644 --- a/src/openproject/Client.ts +++ b/src/openproject/Client.ts @@ -1,6 +1,7 @@ import axios, { Method } from "axios"; import { OpenProjectProject, + OpenProjectStatus, OpenProjectStoredToken, OpenProjectType, OpenProjectUser, @@ -8,6 +9,7 @@ import { } from "./Types"; import { Logger } from "matrix-appservice-bridge"; import { OpenProjectOAuth } from "./Oauth"; +const urlJoin = import("url-join").then((d) => d.default); const log = new Logger("OpenProjectAPIClient"); @@ -34,7 +36,8 @@ export class OpenProjectAPIClient { data?: R, ): Promise { await this.checkTokenAge(); - const url = `${this.baseUrl.origin}/${this.baseUrl.pathname}${path}`; + + const url = (await urlJoin)(this.baseUrl.toString(), path); const res = await axios.request({ url, method: method, @@ -115,6 +118,15 @@ export class OpenProjectAPIClient { )._embedded.elements; } + async getStatuses(): Promise { + // TODO: Paginate? + return ( + await this.apiRequest>( + `/api/v3/statuses`, + ) + )._embedded.elements; + } + async createWorkPackage( projectId: number, type: OpenProjectType, @@ -137,22 +149,33 @@ export class OpenProjectAPIClient { ); } - // TODO: Make this more generic async updateWorkPackage( - projectId: number, workPackageId: number, + // TODO: Type. + update: object, ): Promise { + // Needed for the lockVersion. const existingWp = await this.apiRequest( - `/api/v3/projects/${encodeURIComponent(projectId)}/work_packages/${workPackageId}`, + `/api/v3/work_packages/${workPackageId}`, "GET", ); return this.apiRequest( - `/api/v3/projects/${encodeURIComponent(projectId)}/work_packages/${workPackageId}`, + `/api/v3/work_packages/${workPackageId}`, "PATCH", { lockVersion: (await existingWp).lockVersion, - status: { - title: "Closed", + ...update, + }, + ); + } + + async addWorkPackageComment(wpId: number, comment: string): Promise { + await this.apiRequest( + `/api/v3/work_packages/${encodeURIComponent(wpId)}/activities`, + "POST", + { + comment: { + raw: comment, }, }, ); diff --git a/src/openproject/Types.ts b/src/openproject/Types.ts index 614f4db64..0bd6ca764 100644 --- a/src/openproject/Types.ts +++ b/src/openproject/Types.ts @@ -17,6 +17,12 @@ export interface OpenProjectStatus { isDefault: boolean; isReadonly: boolean; color: string; + _links: { + self: { + href: string; + title: string; + }; + }; } export interface OpenProjectType { From 0b55f092226dd5ce851598fea96b2225b62faeac Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Thu, 15 May 2025 12:58:58 +0100 Subject: [PATCH 16/22] Add priortiy, assign and responsible commands. --- src/Connections/OpenProjectConnection.ts | 253 +++++++++++++++++++---- src/openproject/Client.ts | 37 +++- src/openproject/Types.ts | 37 ++++ 3 files changed, 284 insertions(+), 43 deletions(-) diff --git a/src/Connections/OpenProjectConnection.ts b/src/Connections/OpenProjectConnection.ts index b2d49046f..c80fe39fa 100644 --- a/src/Connections/OpenProjectConnection.ts +++ b/src/Connections/OpenProjectConnection.ts @@ -11,6 +11,7 @@ import { MatrixEvent, MessageEventContent, StateEvent, + UserID, } from "matrix-bot-sdk"; import { Logger } from "matrix-appservice-bridge"; import markdownit from "markdown-it"; @@ -19,7 +20,7 @@ import { MatrixMessageContent } from "../MatrixEvent"; import { CommandConnection } from "./CommandConnection"; import { UserTokenStore } from "../tokens/UserTokenStore"; import { ApiError, ErrCode } from "../api"; -import { OpenProjectWebhookPayloadWorkPackage } from "../openproject/Types"; +import { OpenProjectUser, OpenProjectWebhookPayloadWorkPackage } from "../openproject/Types"; import { BridgeOpenProjectConfig } from "../config/sections/OpenProject"; import { formatWorkPackageDiff, @@ -395,6 +396,10 @@ export class OpenProjectConnection log.info( `onWorkPackageUpdated ${this.roomId} ${this.projectId} ${data.work_package.id}`, ); + await this.storage.setOpenProjectWorkPackageState( + workPackageToCacheState(data.work_package), + data.work_package.id, + ); const creator = data.work_package._embedded.author; if (!creator) { @@ -441,10 +446,31 @@ export class OpenProjectConnection format: "org.matrix.custom.html", ...formatWorkPackageForMatrix(data.work_package, this.config.baseURL), }); - await this.storage.setOpenProjectWorkPackageState( - workPackageToCacheState(data.work_package), - data.work_package.id, - ); + } + + public getWorkPackageIDFromReply(reply?: MatrixEvent): number|undefined { + if (!reply) { + return undefined; + } + const replyContent = reply?.content as OpenProjectWorkPackageMatrixEvent; + if ( + reply.type !== "m.room.message" || + !replyContent["org.matrix.matrix-hookshot.openproject.project"]?.id || + !replyContent["org.matrix.matrix-hookshot.openproject.work_package"]?.id + ) { + throw new CommandError( + "Did not reference a hookshot event", + "You can only close work packages by referencing a work package event.", + ); + } + if ( + replyContent["org.matrix.matrix-hookshot.openproject.project"].id !== + this.projectId + ) { + // This is not us. + return; + } + return replyContent["org.matrix.matrix-hookshot.openproject.work_package"].id; } @botCommand("create", { @@ -461,6 +487,10 @@ export class OpenProjectConnection subject: string, cmdDescription?: string, ) { + const client = await this.tokenStore.getOpenProjectForUser(userId); + if (!client) { + throw new NotLoggedInError(); + } let finalDescription: string | undefined; if (reply) { if (reply.type !== "m.room.message") { @@ -484,10 +514,6 @@ export class OpenProjectConnection } else { finalDescription = cmdDescription; } - const client = await this.tokenStore.getOpenProjectForUser(userId); - if (!client) { - throw new NotLoggedInError(); - } const allTypes = await client.getTypesInProject(this.projectId); const foundType = allTypes.find( (t) => t.name.toLowerCase() === type.toLowerCase(), @@ -532,7 +558,7 @@ export class OpenProjectConnection optionalArgs: ["workPackageId", "description"], includeUserId: true, includeReply: true, - // We allow uses to call global close. + // We allow uses to call global for shorthand replies. runOnGlobalPrefix: true, }) public async commandCloseWorkPackage( @@ -541,29 +567,15 @@ export class OpenProjectConnection workPackageIdOrComment?: string, comment?: string, ) { + const client = await this.tokenStore.getOpenProjectForUser(userId); + if (!client) { + throw new NotLoggedInError(); + } let finalComment: string | undefined; - let workPackageId: number; - if (reply) { - const replyContent = reply.content as OpenProjectWorkPackageMatrixEvent; - if ( - reply.type !== "m.room.message" || - !replyContent["org.matrix.matrix-hookshot.openproject.project"]?.id || - !replyContent["org.matrix.matrix-hookshot.openproject.work_package"]?.id - ) { - throw new CommandError( - "Did not reference a hookshot event", - "You can only close work packages by referencing a work package event.", - ); - } - if ( - replyContent["org.matrix.matrix-hookshot.openproject.project"].id !== - this.projectId - ) { - // This is not us. - return; - } - workPackageId = - replyContent["org.matrix.matrix-hookshot.openproject.work_package"].id; + let workPackageId; + const replyWp = this.getWorkPackageIDFromReply(reply); + if (replyWp) { + workPackageId = replyWp; finalComment = workPackageIdOrComment; } else if (workPackageIdOrComment) { workPackageId = parseInt(workPackageIdOrComment); @@ -581,10 +593,6 @@ export class OpenProjectConnection ); } - const client = await this.tokenStore.getOpenProjectForUser(userId); - if (!client) { - throw new NotLoggedInError(); - } // TODO: Cache this. const validStatuses = await client.getStatuses(); @@ -629,6 +637,179 @@ export class OpenProjectConnection }); } + @botCommand("priority", { + help: "Set the priority a work package", + optionalArgs: ["workPackageId", "priority"], + includeUserId: true, + includeReply: true, + // We allow uses to call global for shorthand replies. + runOnGlobalPrefix: true, + }) + public async commandSetPriority( + userId: string, + reply: MatrixEvent | undefined, + workPackageIdOrPriority?: string, + priority?: string, + ) { + const client = await this.tokenStore.getOpenProjectForUser(userId); + if (!client) { + throw new NotLoggedInError(); + } + let finalPriority: string | undefined; + let workPackageId; + const replyWp = this.getWorkPackageIDFromReply(reply); + if (replyWp) { + workPackageId = replyWp; + finalPriority = workPackageIdOrPriority; + } else if (workPackageIdOrPriority) { + workPackageId = parseInt(workPackageIdOrPriority); + finalPriority = priority; + } else { + throw new CommandError( + "No ID provided", + "You must provide a work package ID", + ); + } + if (isNaN(workPackageId)) { + throw new CommandError( + "Invalid work package ID", + "Work Package ID must be a valid number", + ); + } + const priorities = await client.getPriorities(); + if (!finalPriority) { + throw new CommandError( + "Priority not provided", + `Priority not provided. You can use ${priorities.map((t) => (t.name.includes(" ") ? `"${t.name}"` : t.name)).join(", ")}`, + ); + } + + // Prefer the "closed" status, but if that fails then we'll just use whatever status is used for closed. + const priorityRef = finalPriority && (await client.getPriorities()).find((s) => s.name.toLowerCase() === finalPriority?.toLowerCase()); + + if (!priorityRef) { + throw new CommandError( + "Priority not understood", + `Priority not understood. You can use ${priorities.map((t) => (t.name.includes(" ") ? `"${t.name}"` : t.name)).join(", ")}`, + ); + } + + const workPackage = await client.updateWorkPackage(workPackageId, { + _links: { + priority: { + href: priorityRef?._links.self.href, + }, + }, + }); + } + + public async helperChangeWorkPackageUser(field: 'assignee'|'responsible', userId: string, reply: MatrixEvent | undefined, workPackageIdOrUser?: string, providedUser?: string) { + const client = await this.tokenStore.getOpenProjectForUser(userId); + if (!client) { + throw new NotLoggedInError(); + } + let assigneeName: string | undefined; + let workPackageId; + const replyWp = this.getWorkPackageIDFromReply(reply); + if (replyWp) { + workPackageId = replyWp; + assigneeName = workPackageIdOrUser; + } else if (workPackageIdOrUser) { + workPackageId = parseInt(workPackageIdOrUser); + assigneeName = providedUser; + } else { + throw new CommandError( + "No ID provided", + "You must provide a work package ID", + ); + } + if (isNaN(workPackageId)) { + throw new CommandError( + "Invalid work package ID", + "Work Package ID must be a valid number", + ); + } + + let userHref: {href: string|null}; + + if (assigneeName) { + if (["none", "unset"].includes(assigneeName.toLowerCase())) { + userHref = {href: null}; + } else { + try { + const matrixId = new UserID(assigneeName); + const userClient = await this.tokenStore.getOpenProjectForUser(matrixId.toString()); + if (!userClient) { + throw new CommandError( + "Invalid user", + "Matrix user does not map to a OpenProject user", + ); + } + userHref = (await userClient.getIdentity())._links.self; + } catch (ex) { + if (ex instanceof CommandError) { + throw ex; + } + // Not a matrix ID + const foundUser = await client.searchForUserInProject(this.projectId, assigneeName); + if (!foundUser) { + throw new CommandError( + "Invalid user", + "Could not find a user by that name", + ); + } + userHref = foundUser; + } + } + } else { + // Self assign. + userHref = (await client.getIdentity())._links.self; + } + + await client.updateWorkPackage(workPackageId, { + _links: { + [field]: { + href: userHref.href, + }, + }, + }); + + } + + @botCommand("assign", { + help: "Assign a work package", + optionalArgs: ["workPackageId", "assignee"], + includeUserId: true, + includeReply: true, + // We allow uses to call global for shorthand replies. + runOnGlobalPrefix: true, + }) + public async commandAssignWorkPackage( + userId: string, + reply: MatrixEvent | undefined, + workPackageIdOrAssignee?: string, + cliAssignee?: string, + ) { + return this.helperChangeWorkPackageUser("assignee", userId, reply, workPackageIdOrAssignee, cliAssignee); + } + + @botCommand("responsible", { + help: "Set a user as responsible for a package", + optionalArgs: ["workPackageId", "responsibleUser"], + includeUserId: true, + includeReply: true, + // We allow uses to call global for shorthand replies. + runOnGlobalPrefix: true, + }) + public async commandResponsibleWorkPackage( + userId: string, + reply: MatrixEvent | undefined, + workPackageIdOrAssignee?: string, + cliAssignee?: string, + ) { + return this.helperChangeWorkPackageUser("responsible", userId, reply, workPackageIdOrAssignee, cliAssignee); + } + public static getProvisionerDetails(botUserId: string) { return { service: "openproject", diff --git a/src/openproject/Client.ts b/src/openproject/Client.ts index 246d2dd76..05752058e 100644 --- a/src/openproject/Client.ts +++ b/src/openproject/Client.ts @@ -1,5 +1,8 @@ import axios, { Method } from "axios"; import { + OpenProjectIterableResult, + OpenProjectMembership, + OpenProjectPriority, OpenProjectProject, OpenProjectStatus, OpenProjectStoredToken, @@ -15,10 +18,6 @@ const log = new Logger("OpenProjectAPIClient"); type OpenProjectProjectWithUrl = OpenProjectProject & { project_url: string }; -type OpenProjectIterableResult = { - _embedded: { elements: T[] }; -}; - export class OpenProjectAPIClient { private storedToken: OpenProjectStoredToken; constructor( @@ -54,15 +53,15 @@ export class OpenProjectAPIClient { if (!this.storedToken.refresh_token || !this.storedToken.expires_in) { throw Error("Cannot refresh token, token does not support it"); } - if (this.storedToken.expires_in + 60000 > Date.now()) { + if (this.storedToken.expires_in > Date.now()) { return; } - log.info(`Refreshing oauth token`); + log.info(`Refreshing oauth token`, Date.now(),this.storedToken.expires_in); const data = await this.oauth.exchangeRefreshToken( this.storedToken.refresh_token, ); this.storedToken = { - expires_in: data.expires_in, + expires_in: Date.now() + (data.expires_in * 1000), refresh_token: data.refresh_token, access_token: data.access_token, }; @@ -118,6 +117,21 @@ export class OpenProjectAPIClient { )._embedded.elements; } + async searchForUserInProject( + projectId: number, + title: string, + ): Promise<{href: string, title: string}|undefined> { + const project = await this.getProject(projectId); + const memberships = await this.apiRequest>(project._links.memberships.href); + const lowercaseTitle = title.trim().toLowerCase(); + for (const membership of memberships._embedded.elements) { + if (membership._links.principal.title.toLowerCase() === lowercaseTitle) { + return membership._links.principal; + } + } + return undefined; + } + async getStatuses(): Promise { // TODO: Paginate? return ( @@ -127,6 +141,15 @@ export class OpenProjectAPIClient { )._embedded.elements; } + async getPriorities(): Promise { + // TODO: Paginate? + return ( + await this.apiRequest>( + `/api/v3/priorities`, + ) + )._embedded.elements; + } + async createWorkPackage( projectId: number, type: OpenProjectType, diff --git a/src/openproject/Types.ts b/src/openproject/Types.ts index 0bd6ca764..0e82bb680 100644 --- a/src/openproject/Types.ts +++ b/src/openproject/Types.ts @@ -1,5 +1,11 @@ type StringDate = string; + +export type OpenProjectIterableResult = { + _embedded: { elements: T[] }; +}; + + export interface OpenProjectUser { id: number; name: string; @@ -8,6 +14,12 @@ export interface OpenProjectUser { updatedAt: StringDate; avatar: string; status: "active"; + _links: { + self: { + href: string; + title: string; + }; + }; } export interface OpenProjectStatus { @@ -48,6 +60,11 @@ export interface OpenProjectPriority { createdAt: StringDate; updatedAt: StringDate; color: string; + _links: { + self: { + href: string; + }; + }; } export interface OpenProjectProject { @@ -63,6 +80,11 @@ export interface OpenProjectProject { raw: string; html: string; }; + _links: { + memberships: { + href: string, + } + } } export interface OpenProjectWorkPackage { @@ -102,6 +124,21 @@ export interface OpenProjectWorkPackage { }; } +export interface OpenProjectMembership { + _type: "Membership"; + id: number, + _links: { + principal: { + href: string, + title: string, + }, + roles: { + href: string, + title: string, + }[], + } +} + export interface OpenProjectWebhookPayloadWorkPackage { action: "work_package:created" | "work_package:updated"; work_package: OpenProjectWorkPackage; From 1cec19a817e74e2094f4e5fb09727136044c3c49 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Thu, 15 May 2025 12:59:14 +0100 Subject: [PATCH 17/22] lint --- src/Connections/OpenProjectConnection.ts | 57 ++++++++++++++++++------ src/openproject/Client.ts | 10 +++-- src/openproject/Types.ts | 24 +++++----- 3 files changed, 61 insertions(+), 30 deletions(-) diff --git a/src/Connections/OpenProjectConnection.ts b/src/Connections/OpenProjectConnection.ts index c80fe39fa..395513584 100644 --- a/src/Connections/OpenProjectConnection.ts +++ b/src/Connections/OpenProjectConnection.ts @@ -20,7 +20,10 @@ import { MatrixMessageContent } from "../MatrixEvent"; import { CommandConnection } from "./CommandConnection"; import { UserTokenStore } from "../tokens/UserTokenStore"; import { ApiError, ErrCode } from "../api"; -import { OpenProjectUser, OpenProjectWebhookPayloadWorkPackage } from "../openproject/Types"; +import { + OpenProjectUser, + OpenProjectWebhookPayloadWorkPackage, +} from "../openproject/Types"; import { BridgeOpenProjectConfig } from "../config/sections/OpenProject"; import { formatWorkPackageDiff, @@ -448,7 +451,9 @@ export class OpenProjectConnection }); } - public getWorkPackageIDFromReply(reply?: MatrixEvent): number|undefined { + public getWorkPackageIDFromReply( + reply?: MatrixEvent, + ): number | undefined { if (!reply) { return undefined; } @@ -470,7 +475,8 @@ export class OpenProjectConnection // This is not us. return; } - return replyContent["org.matrix.matrix-hookshot.openproject.work_package"].id; + return replyContent["org.matrix.matrix-hookshot.openproject.work_package"] + .id; } @botCommand("create", { @@ -593,7 +599,6 @@ export class OpenProjectConnection ); } - // TODO: Cache this. const validStatuses = await client.getStatuses(); // Prefer the "closed" status, but if that fails then we'll just use whatever status is used for closed. @@ -685,7 +690,11 @@ export class OpenProjectConnection } // Prefer the "closed" status, but if that fails then we'll just use whatever status is used for closed. - const priorityRef = finalPriority && (await client.getPriorities()).find((s) => s.name.toLowerCase() === finalPriority?.toLowerCase()); + const priorityRef = + finalPriority && + (await client.getPriorities()).find( + (s) => s.name.toLowerCase() === finalPriority?.toLowerCase(), + ); if (!priorityRef) { throw new CommandError( @@ -703,7 +712,13 @@ export class OpenProjectConnection }); } - public async helperChangeWorkPackageUser(field: 'assignee'|'responsible', userId: string, reply: MatrixEvent | undefined, workPackageIdOrUser?: string, providedUser?: string) { + public async helperChangeWorkPackageUser( + field: "assignee" | "responsible", + userId: string, + reply: MatrixEvent | undefined, + workPackageIdOrUser?: string, + providedUser?: string, + ) { const client = await this.tokenStore.getOpenProjectForUser(userId); if (!client) { throw new NotLoggedInError(); @@ -730,15 +745,17 @@ export class OpenProjectConnection ); } - let userHref: {href: string|null}; + let userHref: { href: string | null }; if (assigneeName) { if (["none", "unset"].includes(assigneeName.toLowerCase())) { - userHref = {href: null}; + userHref = { href: null }; } else { try { const matrixId = new UserID(assigneeName); - const userClient = await this.tokenStore.getOpenProjectForUser(matrixId.toString()); + const userClient = await this.tokenStore.getOpenProjectForUser( + matrixId.toString(), + ); if (!userClient) { throw new CommandError( "Invalid user", @@ -751,7 +768,10 @@ export class OpenProjectConnection throw ex; } // Not a matrix ID - const foundUser = await client.searchForUserInProject(this.projectId, assigneeName); + const foundUser = await client.searchForUserInProject( + this.projectId, + assigneeName, + ); if (!foundUser) { throw new CommandError( "Invalid user", @@ -773,7 +793,6 @@ export class OpenProjectConnection }, }, }); - } @botCommand("assign", { @@ -790,7 +809,13 @@ export class OpenProjectConnection workPackageIdOrAssignee?: string, cliAssignee?: string, ) { - return this.helperChangeWorkPackageUser("assignee", userId, reply, workPackageIdOrAssignee, cliAssignee); + return this.helperChangeWorkPackageUser( + "assignee", + userId, + reply, + workPackageIdOrAssignee, + cliAssignee, + ); } @botCommand("responsible", { @@ -807,7 +832,13 @@ export class OpenProjectConnection workPackageIdOrAssignee?: string, cliAssignee?: string, ) { - return this.helperChangeWorkPackageUser("responsible", userId, reply, workPackageIdOrAssignee, cliAssignee); + return this.helperChangeWorkPackageUser( + "responsible", + userId, + reply, + workPackageIdOrAssignee, + cliAssignee, + ); } public static getProvisionerDetails(botUserId: string) { diff --git a/src/openproject/Client.ts b/src/openproject/Client.ts index 05752058e..0e7da7a00 100644 --- a/src/openproject/Client.ts +++ b/src/openproject/Client.ts @@ -56,12 +56,12 @@ export class OpenProjectAPIClient { if (this.storedToken.expires_in > Date.now()) { return; } - log.info(`Refreshing oauth token`, Date.now(),this.storedToken.expires_in); + log.info(`Refreshing oauth token`, Date.now(), this.storedToken.expires_in); const data = await this.oauth.exchangeRefreshToken( this.storedToken.refresh_token, ); this.storedToken = { - expires_in: Date.now() + (data.expires_in * 1000), + expires_in: Date.now() + data.expires_in * 1000, refresh_token: data.refresh_token, access_token: data.access_token, }; @@ -120,9 +120,11 @@ export class OpenProjectAPIClient { async searchForUserInProject( projectId: number, title: string, - ): Promise<{href: string, title: string}|undefined> { + ): Promise<{ href: string; title: string } | undefined> { const project = await this.getProject(projectId); - const memberships = await this.apiRequest>(project._links.memberships.href); + const memberships = await this.apiRequest< + OpenProjectIterableResult + >(project._links.memberships.href); const lowercaseTitle = title.trim().toLowerCase(); for (const membership of memberships._embedded.elements) { if (membership._links.principal.title.toLowerCase() === lowercaseTitle) { diff --git a/src/openproject/Types.ts b/src/openproject/Types.ts index 0e82bb680..edd33bb3a 100644 --- a/src/openproject/Types.ts +++ b/src/openproject/Types.ts @@ -1,11 +1,9 @@ type StringDate = string; - export type OpenProjectIterableResult = { _embedded: { elements: T[] }; }; - export interface OpenProjectUser { id: number; name: string; @@ -82,9 +80,9 @@ export interface OpenProjectProject { }; _links: { memberships: { - href: string, - } - } + href: string; + }; + }; } export interface OpenProjectWorkPackage { @@ -126,17 +124,17 @@ export interface OpenProjectWorkPackage { export interface OpenProjectMembership { _type: "Membership"; - id: number, + id: number; _links: { principal: { - href: string, - title: string, - }, + href: string; + title: string; + }; roles: { - href: string, - title: string, - }[], - } + href: string; + title: string; + }[]; + }; } export interface OpenProjectWebhookPayloadWorkPackage { From 08fdad40b65fd94d45987bbca89b61d1538252a2 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Fri, 16 May 2025 11:21:48 +0100 Subject: [PATCH 18/22] Tweaks --- src/Connections/OpenProjectConnection.ts | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/src/Connections/OpenProjectConnection.ts b/src/Connections/OpenProjectConnection.ts index 395513584..cb1c72b1d 100644 --- a/src/Connections/OpenProjectConnection.ts +++ b/src/Connections/OpenProjectConnection.ts @@ -21,7 +21,6 @@ import { CommandConnection } from "./CommandConnection"; import { UserTokenStore } from "../tokens/UserTokenStore"; import { ApiError, ErrCode } from "../api"; import { - OpenProjectUser, OpenProjectWebhookPayloadWorkPackage, } from "../openproject/Types"; import { BridgeOpenProjectConfig } from "../config/sections/OpenProject"; @@ -385,7 +384,7 @@ export class OpenProjectConnection body: content, formatted_body: md.renderInline(content), format: "org.matrix.custom.html", - ...formatWorkPackageForMatrix(data.work_package, this.config.baseURL), + ...extraData, }); await this.storage.setOpenProjectWorkPackageState( workPackageToCacheState(data.work_package), @@ -447,7 +446,7 @@ export class OpenProjectConnection formatted_body: md.renderInline(content) + (postfix ? md.render(postfix) : ""), format: "org.matrix.custom.html", - ...formatWorkPackageForMatrix(data.work_package, this.config.baseURL), + ...extraData, }); } @@ -465,7 +464,7 @@ export class OpenProjectConnection ) { throw new CommandError( "Did not reference a hookshot event", - "You can only close work packages by referencing a work package event.", + "You must reply to a work package message when running this command.", ); } if ( @@ -555,7 +554,7 @@ export class OpenProjectConnection body: content, formatted_body: md.renderInline(content), format: "org.matrix.custom.html", - ...formatWorkPackageForMatrix(workPackage, this.config.baseURL), + ...extraData, }); } @@ -638,12 +637,12 @@ export class OpenProjectConnection body: content, formatted_body: md.renderInline(content), format: "org.matrix.custom.html", - ...formatWorkPackageForMatrix(workPackage, this.config.baseURL), + ...extraData, }); } @botCommand("priority", { - help: "Set the priority a work package", + help: "Set the priority for a work package", optionalArgs: ["workPackageId", "priority"], includeUserId: true, includeReply: true, @@ -796,7 +795,7 @@ export class OpenProjectConnection } @botCommand("assign", { - help: "Assign a work package", + help: "Assign a work package to a new user (use 'unset' to remove)", optionalArgs: ["workPackageId", "assignee"], includeUserId: true, includeReply: true, @@ -819,7 +818,7 @@ export class OpenProjectConnection } @botCommand("responsible", { - help: "Set a user as responsible for a package", + help: "Assign a responsible user to a work package (use 'unset' to remove)", optionalArgs: ["workPackageId", "responsibleUser"], includeUserId: true, includeReply: true, From 432b98d884af6b4b0a478f29f9032165524c343c Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Fri, 16 May 2025 11:29:12 +0100 Subject: [PATCH 19/22] Add support for integrated bot commands. --- src/BotCommands.ts | 82 +++++++++++++++++++++++- src/Bridge.ts | 4 +- src/Connections/CommandConnection.ts | 21 +++++- src/Connections/IConnection.ts | 2 +- src/Connections/OpenProjectConnection.ts | 5 ++ src/openproject/Format.ts | 17 +++++ 6 files changed, 126 insertions(+), 5 deletions(-) diff --git a/src/BotCommands.ts b/src/BotCommands.ts index 78d346e9a..f99732f2c 100644 --- a/src/BotCommands.ts +++ b/src/BotCommands.ts @@ -40,6 +40,8 @@ export interface BotCommandOptions { permissionLevel?: BridgePermissionLevel; permissionService?: string; runOnGlobalPrefix?: boolean; + // For org.matrix.matrix-hookshot.command activation + eventCommandName?: string; } type BotCommandResult = { status?: boolean; reaction?: string } | undefined; @@ -85,6 +87,7 @@ export function compileBotCommands( category: b.category, includeReply: b.includeReply, runOnGlobalPrefix: b.runOnGlobalPrefix, + eventCommandName: b.eventCommandName, }; } }); @@ -148,7 +151,7 @@ export async function handleCommand( command: string, parentEvent: MatrixEvent | undefined, botCommands: BotCommands, - obj: unknown, + parentThis: unknown, permissionCheckFn: PermissionCheckFn, defaultPermissionService?: string, prefix?: string, @@ -218,7 +221,7 @@ export async function handleCommand( args.splice(1, 0, parentEvent); } try { - const result = await botCommands[prefix].fn.apply(obj, args); + const result = await botCommands[prefix].fn.apply(parentThis, args); return { handled: true, result }; } catch (ex) { const commandError = ex as CommandError; @@ -235,3 +238,78 @@ export async function handleCommand( } return { handled: false }; } + +export interface HookshotCommandContent { + command: string, + "m.relates_to": { + "rel_type": "org.matrix-hooshot.command-target", + "event_id": string, + }, +} + +export async function handleEventCommand( + userId: string, + eventContent: HookshotCommandContent, + parentEvent: MatrixEvent, + botCommands: BotCommands, + obj: unknown, + permissionCheckFn: PermissionCheckFn, + defaultPermissionService?: string, +): Promise< + | CommandResultNotHandled + | CommandResultSuccess + | CommandResultErrorUnknown + | CommandResultErrorHuman +> { + const command = Object.values(botCommands).find(c => c.eventCommandName === eventContent.command); + if (!command) { + return { + handled: false, + } + } + const permissionService = + command.permissionService || defaultPermissionService; + if ( + permissionService && + !permissionCheckFn( + permissionService, + command.permissionLevel || BridgePermissionLevel.commands, + ) + ) { + return { + handled: true, + humanError: "You do not have permission to use this command.", + }; + } + + + if ( + command.requiredArgs?.length + ) { + return { + handled: true, + humanError: "Missing at least one required parameter.", + }; + } + const args: unknown[] = []; + if (command.includeUserId) { + args.splice(0, 0, userId); + } + if (command.includeReply) { + args.splice(1, 0, parentEvent); + } + try { + const result = await command.fn.apply(obj, args); + return { handled: true, result }; + } catch (ex) { + const commandError = ex as CommandError; + if (ex instanceof ApiError) { + return { handled: true, humanError: ex.error }; + } + return { + handled: true, + error: commandError, + humanError: commandError.humanError, + }; + } +} diff --git a/src/Bridge.ts b/src/Bridge.ts index 297cd0306..43cdc23e4 100644 --- a/src/Bridge.ts +++ b/src/Bridge.ts @@ -1754,7 +1754,9 @@ export class Bridge { id: connection.connectionId, }); try { - await connection.onEvent(event); + const checkPermission = (service: string, level: BridgePermissionLevel) => + this.config.checkPermission(event.sender, service, level); + await connection.onEvent(event, checkPermission); } catch (ex) { Sentry.captureException(ex, scope); log.warn( diff --git a/src/Connections/CommandConnection.ts b/src/Connections/CommandConnection.ts index 70962fa7b..b67478553 100644 --- a/src/Connections/CommandConnection.ts +++ b/src/Connections/CommandConnection.ts @@ -2,10 +2,12 @@ import { botCommand, BotCommands, handleCommand, + handleEventCommand, HelpFunction, + HookshotCommandContent, } from "../BotCommands"; import { Logger } from "matrix-appservice-bridge"; -import { IRichReplyMetadata, MatrixClient, MessageEvent } from "matrix-bot-sdk"; +import { MatrixClient } from "matrix-bot-sdk"; import { MatrixMessageContent, MatrixEvent } from "../MatrixEvent"; import { BaseConnection } from "./BaseConnection"; import { IConnectionState, PermissionCheckFn } from "."; @@ -51,6 +53,23 @@ export abstract class CommandConnection< content: unknown, ): Promise | ValidatedStateType; + public async onEvent(ev: MatrixEvent, checkPermission: PermissionCheckFn) { + if (ev.type !== "org.matrix.matrix-hookshot.command") { + return; + } + const content = (ev.content as HookshotCommandContent); + const res = await handleEventCommand( + ev.sender, + content, + await this.botClient.getEvent(this.roomId, content["m.relates_to"].event_id), + this.botCommands, + this, + checkPermission, + this.serviceName, + ); + console.log(ev, '=>', res); + } + public async onMessageEvent( ev: MatrixEvent, checkPermission: PermissionCheckFn, diff --git a/src/Connections/IConnection.ts b/src/Connections/IConnection.ts index fcc8ce4de..5f5c542c6 100644 --- a/src/Connections/IConnection.ts +++ b/src/Connections/IConnection.ts @@ -57,7 +57,7 @@ export interface IConnection { /** * When a room gets any event */ - onEvent?: (ev: MatrixEvent) => Promise; + onEvent?: (ev: MatrixEvent, checkPermission: PermissionCheckFn) => Promise; /** * When a room gets a message event. diff --git a/src/Connections/OpenProjectConnection.ts b/src/Connections/OpenProjectConnection.ts index cb1c72b1d..36432beb3 100644 --- a/src/Connections/OpenProjectConnection.ts +++ b/src/Connections/OpenProjectConnection.ts @@ -377,6 +377,7 @@ export class OpenProjectConnection const extraData = formatWorkPackageForMatrix( data.work_package, this.config.baseURL, + this.stateKey, ); const content = `${creator.name} created a new work package [${data.work_package.id}](${extraData["org.matrix.matrix-hookshot.openproject.work_package"].url}): "${data.work_package.subject}"`; await this.intent.sendEvent(this.roomId, { @@ -410,6 +411,7 @@ export class OpenProjectConnection const extraData = formatWorkPackageForMatrix( data.work_package, this.config.baseURL, + this.stateKey, ); const oldChanges = await this.storage.getOpenProjectWorkPackageState( data.work_package._embedded.project.id, @@ -547,6 +549,7 @@ export class OpenProjectConnection const extraData = formatWorkPackageForMatrix( workPackage, this.config.baseURL, + this.stateKey, ); const content = `${workPackage._embedded.author.name} created a new work package [${workPackage.id}](${extraData["org.matrix.matrix-hookshot.openproject.work_package"].url}): "${workPackage.subject}"`; await this.intent.sendEvent(this.roomId, { @@ -565,6 +568,7 @@ export class OpenProjectConnection includeReply: true, // We allow uses to call global for shorthand replies. runOnGlobalPrefix: true, + eventCommandName: "org.matrix.matrix-hookshot.openproject.command.close" }) public async commandCloseWorkPackage( userId: string, @@ -630,6 +634,7 @@ export class OpenProjectConnection const extraData = formatWorkPackageForMatrix( workPackage, this.config.baseURL, + this.stateKey, ); const content = `${workPackage._embedded.author.name} closed work package [${workPackage.id}](${extraData["org.matrix.matrix-hookshot.openproject.work_package"].url}): "${workPackage.subject}"`; await this.intent.sendEvent(this.roomId, { diff --git a/src/openproject/Format.ts b/src/openproject/Format.ts index 6c2ad74e0..0f684d128 100644 --- a/src/openproject/Format.ts +++ b/src/openproject/Format.ts @@ -36,12 +36,21 @@ export interface OpenProjectWorkPackageMatrixEvent { name: string; url: string; }; + "org.matrix.matrix-hookshot.commands": { + "org.matrix.matrix-hookshot.openproject.command.close": { + label: string; + }; + "org.matrix.matrix-hookshot.openproject.command.flag": { + label: string; + }; + }; external_url: string; } export function formatWorkPackageForMatrix( pkg: OpenProjectWorkPackage, baseURL: URL, + _stateKey: string, ): OpenProjectWorkPackageMatrixEvent { const url = new URL( baseURL.href + @@ -88,6 +97,14 @@ export function formatWorkPackageForMatrix( baseURL, ).toString(), }, + "org.matrix.matrix-hookshot.commands": { + "org.matrix.matrix-hookshot.openproject.command.close": { + label: "Close work package", + }, + "org.matrix.matrix-hookshot.openproject.command.flag": { + label: "Flag work package", + }, + }, external_url: url, }; } From fef37b6a82f7882450745fbcbe4213d9c2471dd2 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Mon, 19 May 2025 12:10:22 +0100 Subject: [PATCH 20/22] Dynamic event changes --- src/BotCommands.ts | 21 ++++++++++----------- src/Bridge.ts | 6 ++++-- src/Connections/CommandConnection.ts | 13 +++++++++---- src/Connections/IConnection.ts | 5 ++++- src/Connections/OpenProjectConnection.ts | 6 ++---- 5 files changed, 29 insertions(+), 22 deletions(-) diff --git a/src/BotCommands.ts b/src/BotCommands.ts index 7d48f6bbe..ffd1b45a0 100644 --- a/src/BotCommands.ts +++ b/src/BotCommands.ts @@ -301,11 +301,11 @@ export async function handleCommand( } export interface HookshotCommandContent { - command: string, + command: string; "m.relates_to": { - "rel_type": "org.matrix-hooshot.command-target", - "event_id": string, - }, + rel_type: "org.matrix-hooshot.command-target"; + event_id: string; + }; } export async function handleEventCommand( @@ -322,11 +322,13 @@ export async function handleEventCommand( | CommandResultErrorUnknown | CommandResultErrorHuman > { - const command = Object.values(botCommands).find(c => c.eventCommandName === eventContent.command); + const command = Object.values(botCommands).find( + (c) => c.eventCommandName === eventContent.command, + ); if (!command) { return { handled: false, - } + }; } const permissionService = command.permissionService || defaultPermissionService; @@ -342,11 +344,8 @@ export async function handleEventCommand( humanError: "You do not have permission to use this command.", }; } - - if ( - command.requiredArgs?.length - ) { + if (command.requiredArgs?.length) { return { handled: true, humanError: "Missing at least one required parameter.", @@ -360,7 +359,7 @@ export async function handleEventCommand( args.splice(1, 0, parentEvent); } try { - const result = (await command.fn as any).apply(obj, args); + const result = ((await command.fn) as any).apply(obj, args); return { handled: true, result }; } catch (ex) { const commandError = ex as CommandError; diff --git a/src/Bridge.ts b/src/Bridge.ts index 43cdc23e4..8faf3c6d1 100644 --- a/src/Bridge.ts +++ b/src/Bridge.ts @@ -1754,8 +1754,10 @@ export class Bridge { id: connection.connectionId, }); try { - const checkPermission = (service: string, level: BridgePermissionLevel) => - this.config.checkPermission(event.sender, service, level); + const checkPermission = ( + service: string, + level: BridgePermissionLevel, + ) => this.config.checkPermission(event.sender, service, level); await connection.onEvent(event, checkPermission); } catch (ex) { Sentry.captureException(ex, scope); diff --git a/src/Connections/CommandConnection.ts b/src/Connections/CommandConnection.ts index 529a4160d..240bbdc3b 100644 --- a/src/Connections/CommandConnection.ts +++ b/src/Connections/CommandConnection.ts @@ -53,21 +53,26 @@ export abstract class CommandConnection< content: unknown, ): Promise | ValidatedStateType; - public async onEvent(ev: MatrixEvent, checkPermission: PermissionCheckFn) { + public async onEvent( + ev: MatrixEvent, + checkPermission: PermissionCheckFn, + ) { if (ev.type !== "org.matrix.matrix-hookshot.command") { return; } - const content = (ev.content as HookshotCommandContent); + const content = ev.content as HookshotCommandContent; const res = await handleEventCommand( ev.sender, content, - await this.botClient.getEvent(this.roomId, content["m.relates_to"].event_id), + await this.botClient.getEvent( + this.roomId, + content["m.relates_to"].event_id, + ), this.botCommands, this, checkPermission, this.serviceName, ); - console.log(ev, '=>', res); } public async onMessageEvent( diff --git a/src/Connections/IConnection.ts b/src/Connections/IConnection.ts index 5f5c542c6..4c1f1ba8d 100644 --- a/src/Connections/IConnection.ts +++ b/src/Connections/IConnection.ts @@ -57,7 +57,10 @@ export interface IConnection { /** * When a room gets any event */ - onEvent?: (ev: MatrixEvent, checkPermission: PermissionCheckFn) => Promise; + onEvent?: ( + ev: MatrixEvent, + checkPermission: PermissionCheckFn, + ) => Promise; /** * When a room gets a message event. diff --git a/src/Connections/OpenProjectConnection.ts b/src/Connections/OpenProjectConnection.ts index 36432beb3..70299cd8e 100644 --- a/src/Connections/OpenProjectConnection.ts +++ b/src/Connections/OpenProjectConnection.ts @@ -20,9 +20,7 @@ import { MatrixMessageContent } from "../MatrixEvent"; import { CommandConnection } from "./CommandConnection"; import { UserTokenStore } from "../tokens/UserTokenStore"; import { ApiError, ErrCode } from "../api"; -import { - OpenProjectWebhookPayloadWorkPackage, -} from "../openproject/Types"; +import { OpenProjectWebhookPayloadWorkPackage } from "../openproject/Types"; import { BridgeOpenProjectConfig } from "../config/sections/OpenProject"; import { formatWorkPackageDiff, @@ -568,7 +566,7 @@ export class OpenProjectConnection includeReply: true, // We allow uses to call global for shorthand replies. runOnGlobalPrefix: true, - eventCommandName: "org.matrix.matrix-hookshot.openproject.command.close" + eventCommandName: "org.matrix.matrix-hookshot.openproject.command.close", }) public async commandCloseWorkPackage( userId: string, From b052428f5a0a6506467c1bd15daee123609a15ca Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Mon, 19 May 2025 12:10:35 +0100 Subject: [PATCH 21/22] Add hookshot Element module. --- package.json | 5 + vite.elementmodule.config.mjs | 47 ++ web/elementModule/eventWidget.tsx | 98 +++ web/elementModule/index.tsx | 70 ++ yarn.lock | 1065 ++++++++++++++++++++++++++++- 5 files changed, 1270 insertions(+), 15 deletions(-) create mode 100644 vite.elementmodule.config.mjs create mode 100644 web/elementModule/eventWidget.tsx create mode 100644 web/elementModule/index.tsx diff --git a/package.json b/package.json index c954ca35e..ef7725384 100644 --- a/package.json +++ b/package.json @@ -86,6 +86,7 @@ "devDependencies": { "@babel/core": "^7.26.9", "@codemirror/lang-javascript": "^6.0.2", + "@element-hq/element-web-module-api": "^1.0.0", "@eslint/eslintrc": "^3.2.0", "@eslint/js": "^9.15.0", "@fontsource/inter": "^5.1.0", @@ -108,6 +109,7 @@ "@types/node": "^22", "@types/xml2js": "^0.4.11", "@uiw/react-codemirror": "^4.12.3", + "@vitejs/plugin-react": "^4.4.1", "busboy": "^1.6.0", "chai": "^4", "eslint": "^9.15.0", @@ -119,12 +121,15 @@ "preact": "^10.26.2", "prettier": "^3.5.3", "rimraf": "6.0.1", + "rollup-plugin-external-globals": "^0.13.0", "sass": "^1.81.0", + "styled-components": "^6.1.18", "testcontainers": "^10.25.0", "ts-node": "10.9.2", "typescript": "^5.7.2", "typescript-eslint": "^8.16.0", "vite": "^5.4.19", + "vite-plugin-node-polyfills": "^0.23.0", "vitest": "^3.1.3" }, "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" diff --git a/vite.elementmodule.config.mjs b/vite.elementmodule.config.mjs new file mode 100644 index 000000000..805c9149c --- /dev/null +++ b/vite.elementmodule.config.mjs @@ -0,0 +1,47 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import { nodePolyfills } from "vite-plugin-node-polyfills"; +import externalGlobals from "rollup-plugin-external-globals"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +export default defineConfig({ + build: { + lib: { + entry: resolve("web", "elementModule", "index.tsx"), + name: "hookshot-openproject", + fileName: "index", + formats: ["es"], + }, + outDir: "public/elementModule", + target: "esnext", + sourcemap: true, + rollupOptions: { + external: ["preact", "react"], + }, + }, + plugins: [ + react(), + nodePolyfills({ + include: ["events"], + }), + externalGlobals({ + // Reuse React from the host app + react: "window.React", + }), + ], + define: { + // Use production mode for the build as it is tested against production builds of Element Web, + // this is required for React JSX versions to be compatible. + process: { env: { NODE_ENV: "production" } }, + }, +}); diff --git a/web/elementModule/eventWidget.tsx b/web/elementModule/eventWidget.tsx new file mode 100644 index 000000000..dbd667eab --- /dev/null +++ b/web/elementModule/eventWidget.tsx @@ -0,0 +1,98 @@ +import { styled } from "styled-components"; + + + +export interface OpenProjectContent { + 'org.matrix.matrix-hookshot.openproject.work_package'?: { + id: number, + subject: string, + description: { + plain: string, + html: string, + }|string, + url: string, + author: { + name: string, + url: string, + }, + assignee?: { + name: string, + url: string, + }, + status: { + name: string, + color: string, + }, + type: { + name: string, + color: string, + } + }, + 'org.matrix.matrix-hookshot.openproject.project'?: { + id: number, + name: string, + url: string, + }, + "org.matrix.matrix-hookshot.commands": { + "org.matrix.matrix-hookshot.openproject.command.close": { + label: "Close work package", + identifier: string, + workpackage_id: number, + }, + "org.matrix.matrix-hookshot.openproject.command.flag": { + label: "Flag work package", + identifier: string, + workpackage_id: number, + }, + }, +} + +const Root = styled.div` + margin-top: var(--cpd-space-2x); + gap: var(--cpd-space-2x); + display: flex; + flex-direction: column; +` + +const WidgetProject = styled.div` + font: var(--cpd-font-body-md-regular); + font-weight: var(--cpd-font-weight-semibold); +` + +const WidgetWorkPackageStatus = styled.div` + background: var(--cpd-color-gray-200); + border-radius: var(--cpd-space-2x); + padding: var(--cpd-space-2x); + font-weight: var(--cpd-font-weight-semibold); +` + +const WidgetWorkPackageTitle = styled.div` + display: flex; + flex-direction: row; + gap: var(--cpd-space-2x); + > * { + margin-top: auto; + margin-bottom: auto; + } +`; + +export function OpenProjectEventWidget({data}: {data: OpenProjectContent}) { + const topicToHtml = (...args: any) => args[0]; + const { ["org.matrix.matrix-hookshot.openproject.work_package"]: pkg, ["org.matrix.matrix-hookshot.openproject.project"]: project } = data; + const description = pkg?.description && (typeof pkg.description === "string" ? topicToHtml(pkg.description ?? "No description") : topicToHtml(pkg.description.plain ?? "No description", pkg.description.html, undefined, true)); + return + {project && + {project.name} + } + {pkg &&
+ + {pkg.type.name} + {pkg.status.name} + {pkg.subject} + {pkg.id} + by {pkg.author.name} + +

{description}

+
} +
; +} \ No newline at end of file diff --git a/web/elementModule/index.tsx b/web/elementModule/index.tsx new file mode 100644 index 000000000..5026fcd8c --- /dev/null +++ b/web/elementModule/index.tsx @@ -0,0 +1,70 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + + +import { type Module, type Api, type ModuleFactory, CustomComponentTarget } from "@element-hq/element-web-module-api"; +import { OpenProjectContent, OpenProjectEventWidget } from "./eventWidget"; + +declare global { + interface Window { + // XXX: temporary hack until we rewrite everything in modern modules + mxMatrixClientPeg: { + safeGet(): { + getSafeUserId(): string; + sendEvent(roomId: string, threadIdOrEventType: string, eventTypeOrContent: string|object, contentOrTxnId?: string|object, txnIdOrVoid?: string): Promise; + }; + }; + } +} + +class HookshotModule implements Module { + public static readonly moduleApiVersion = "^1.0.0"; + + public constructor(private api: Api) { + } + + public async load(): Promise { + this.api.customComponents.register(CustomComponentTarget.TextualBody, (props) => { + const content = props.mxEvent.getContent(); + const workPackageData = content["org.matrix.matrix-hookshot.openproject.work_package"]; + if (!workPackageData) { + return null; + } + return ; + }) + this.api.customComponents.register(CustomComponentTarget.MessageContextMenu, ({ mxEvent, closeMenu}, originalComponent) => { + const content = mxEvent.getContent() as OpenProjectContent; + const workPackageData = content["org.matrix.matrix-hookshot.openproject.work_package"]; + if (!workPackageData) { + return null; + } + const client = window.mxMatrixClientPeg.safeGet(); + + return <> + {this.api.customComponents.buildContextMenuBlock( + Object.entries(content["org.matrix.matrix-hookshot.commands"] ?? {}).map(([commandName, details]) => ({ + label: details.label, + iconClassName: 'mx_MessageContextMenu_iconReport', + onClick: async () => { + await client.sendEvent(mxEvent.getRoomId(), 'org.matrix.matrix-hookshot.command', { + command: commandName, + "m.relates_to": { + rel_type: "org.matrix-hooshot.command-target", + event_id: mxEvent.getId(), + }, + }) + closeMenu() + }, + })) + )} + {originalComponent} + + }) + } +} + +export default HookshotModule satisfies ModuleFactory; diff --git a/yarn.lock b/yarn.lock index 634ed26c3..e1f80847d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -29,6 +29,15 @@ js-tokens "^4.0.0" picocolors "^1.0.0" +"@babel/code-frame@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.27.1.tgz#200f715e66d52a23b221a9435534a91cc13ad5be" + integrity sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg== + dependencies: + "@babel/helper-validator-identifier" "^7.27.1" + js-tokens "^4.0.0" + picocolors "^1.1.1" + "@babel/compat-data@^7.25.9": version "7.26.2" resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.26.2.tgz#278b6b13664557de95b8f35b90d96785850bb56e" @@ -39,6 +48,11 @@ resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.26.8.tgz#821c1d35641c355284d4a870b8a4a7b0c141e367" integrity sha512-oH5UPLMWR3L2wEFLnFJ1TZXqHufiTKAiLfqw5zkhS4dKXLJ10yVztfil/twG8EDTA4F/tvVNw9nOl4ZMslB8rQ== +"@babel/compat-data@^7.27.2": + version "7.27.2" + resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.27.2.tgz#4183f9e642fd84e74e3eea7ffa93a412e3b102c9" + integrity sha512-TUtMJYRPyUb/9aU8f3K0mjmjf6M9N5Woshn2CS6nqJSeJtTtQcpLUXjGt9vbF8ZGff0El99sWkLgzwW3VXnxZQ== + "@babel/core@^7.22.1", "@babel/core@^7.23.9": version "7.26.0" resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.26.0.tgz#d78b6023cc8f3114ccf049eb219613f74a747b40" @@ -60,6 +74,27 @@ json5 "^2.2.3" semver "^6.3.1" +"@babel/core@^7.26.10": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.27.1.tgz#89de51e86bd12246003e3524704c49541b16c3e6" + integrity sha512-IaaGWsQqfsQWVLqMn9OB92MNN7zukfVA4s7KKAI0KfrrDsZ0yhi5uV4baBuLuN7n3vsZpwP8asPPcVwApxvjBQ== + dependencies: + "@ampproject/remapping" "^2.2.0" + "@babel/code-frame" "^7.27.1" + "@babel/generator" "^7.27.1" + "@babel/helper-compilation-targets" "^7.27.1" + "@babel/helper-module-transforms" "^7.27.1" + "@babel/helpers" "^7.27.1" + "@babel/parser" "^7.27.1" + "@babel/template" "^7.27.1" + "@babel/traverse" "^7.27.1" + "@babel/types" "^7.27.1" + convert-source-map "^2.0.0" + debug "^4.1.0" + gensync "^1.0.0-beta.2" + json5 "^2.2.3" + semver "^6.3.1" + "@babel/core@^7.26.9": version "7.26.9" resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.26.9.tgz#71838542a4b1e49dfed353d7acbc6eb89f4a76f2" @@ -103,6 +138,17 @@ "@jridgewell/trace-mapping" "^0.3.25" jsesc "^3.0.2" +"@babel/generator@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.27.1.tgz#862d4fad858f7208edd487c28b58144036b76230" + integrity sha512-UnJfnIpc/+JO0/+KRVQNGU+y5taA5vCbwN8+azkX6beii/ZF+enZJSOKo11ZSzGJjlNfJHfQtmQT8H+9TXPG2w== + dependencies: + "@babel/parser" "^7.27.1" + "@babel/types" "^7.27.1" + "@jridgewell/gen-mapping" "^0.3.5" + "@jridgewell/trace-mapping" "^0.3.25" + jsesc "^3.0.2" + "@babel/helper-annotate-as-pure@^7.25.9": version "7.25.9" resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.25.9.tgz#d8eac4d2dc0d7b6e11fa6e535332e0d3184f06b4" @@ -132,6 +178,17 @@ lru-cache "^5.1.1" semver "^6.3.1" +"@babel/helper-compilation-targets@^7.27.1": + version "7.27.2" + resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz#46a0f6efab808d51d29ce96858dd10ce8732733d" + integrity sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ== + dependencies: + "@babel/compat-data" "^7.27.2" + "@babel/helper-validator-option" "^7.27.1" + browserslist "^4.24.0" + lru-cache "^5.1.1" + semver "^6.3.1" + "@babel/helper-module-imports@^7.25.9": version "7.25.9" resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.25.9.tgz#e7f8d20602ebdbf9ebbea0a0751fb0f2a4141715" @@ -140,6 +197,14 @@ "@babel/traverse" "^7.25.9" "@babel/types" "^7.25.9" +"@babel/helper-module-imports@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz#7ef769a323e2655e126673bb6d2d6913bbead204" + integrity sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w== + dependencies: + "@babel/traverse" "^7.27.1" + "@babel/types" "^7.27.1" + "@babel/helper-module-transforms@^7.26.0": version "7.26.0" resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.26.0.tgz#8ce54ec9d592695e58d84cd884b7b5c6a2fdeeae" @@ -149,26 +214,55 @@ "@babel/helper-validator-identifier" "^7.25.9" "@babel/traverse" "^7.25.9" +"@babel/helper-module-transforms@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.27.1.tgz#e1663b8b71d2de948da5c4fb2a20ca4f3ec27a6f" + integrity sha512-9yHn519/8KvTU5BjTVEEeIM3w9/2yXNKoD82JifINImhpKkARMJKPP59kLo+BafpdN5zgNeIcS4jsGDmd3l58g== + dependencies: + "@babel/helper-module-imports" "^7.27.1" + "@babel/helper-validator-identifier" "^7.27.1" + "@babel/traverse" "^7.27.1" + "@babel/helper-plugin-utils@^7.25.9": version "7.25.9" resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.25.9.tgz#9cbdd63a9443a2c92a725cca7ebca12cc8dd9f46" integrity sha512-kSMlyUVdWe25rEsRGviIgOWnoT/nfABVWlqt9N19/dIPWViAOW2s9wznP5tURbs/IDuNk4gPy3YdYRgH3uxhBw== +"@babel/helper-plugin-utils@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz#ddb2f876534ff8013e6c2b299bf4d39b3c51d44c" + integrity sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw== + "@babel/helper-string-parser@^7.25.9": version "7.25.9" resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz#1aabb72ee72ed35789b4bbcad3ca2862ce614e8c" integrity sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA== +"@babel/helper-string-parser@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz#54da796097ab19ce67ed9f88b47bb2ec49367687" + integrity sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA== + "@babel/helper-validator-identifier@^7.25.9": version "7.25.9" resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz#24b64e2c3ec7cd3b3c547729b8d16871f22cbdc7" integrity sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ== +"@babel/helper-validator-identifier@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz#a7054dcc145a967dd4dc8fee845a57c1316c9df8" + integrity sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow== + "@babel/helper-validator-option@^7.25.9": version "7.25.9" resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.25.9.tgz#86e45bd8a49ab7e03f276577f96179653d41da72" integrity sha512-e/zv1co8pp55dNdEcCynfj9X7nyUKUXoUEwfXqaZt0omVOmDe9oOTdKStH4GmAw6zxMFs50ZayuMfHDKlO7Tfw== +"@babel/helper-validator-option@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz#fa52f5b1e7db1ab049445b421c4471303897702f" + integrity sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg== + "@babel/helpers@^7.26.0": version "7.26.0" resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.26.0.tgz#30e621f1eba5aa45fe6f4868d2e9154d884119a4" @@ -185,6 +279,21 @@ "@babel/template" "^7.26.9" "@babel/types" "^7.26.9" +"@babel/helpers@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.27.1.tgz#ffc27013038607cdba3288e692c3611c06a18aa4" + integrity sha512-FCvFTm0sWV8Fxhpp2McP5/W53GPllQ9QeQ7SiqGWjMf/LVG07lFa5+pgK05IRhVwtvafT22KF+ZSnM9I545CvQ== + dependencies: + "@babel/template" "^7.27.1" + "@babel/types" "^7.27.1" + +"@babel/parser@^7.1.0", "@babel/parser@^7.20.7", "@babel/parser@^7.27.1", "@babel/parser@^7.27.2": + version "7.27.2" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.27.2.tgz#577518bedb17a2ce4212afd052e01f7df0941127" + integrity sha512-QYLs8299NA7WM/bZAdp+CviYYkVoYXlDW2rzliy3chxd1PQjej7JORuMJDJXJUb9g0TT+B99EwaVLKmX+sPXWw== + dependencies: + "@babel/types" "^7.27.1" + "@babel/parser@^7.23.9", "@babel/parser@^7.25.9", "@babel/parser@^7.26.0", "@babel/parser@^7.26.2": version "7.26.2" resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.26.2.tgz#fd7b6f487cfea09889557ef5d4eeb9ff9a5abd11" @@ -213,6 +322,20 @@ dependencies: "@babel/plugin-transform-react-jsx" "^7.25.9" +"@babel/plugin-transform-react-jsx-self@^7.25.9": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz#af678d8506acf52c577cac73ff7fe6615c85fc92" + integrity sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw== + dependencies: + "@babel/helper-plugin-utils" "^7.27.1" + +"@babel/plugin-transform-react-jsx-source@^7.25.9": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz#dcfe2c24094bb757bf73960374e7c55e434f19f0" + integrity sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw== + dependencies: + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/plugin-transform-react-jsx@^7.22.15", "@babel/plugin-transform-react-jsx@^7.25.9": version "7.25.9" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.25.9.tgz#06367940d8325b36edff5e2b9cbe782947ca4166" @@ -249,6 +372,15 @@ "@babel/parser" "^7.26.9" "@babel/types" "^7.26.9" +"@babel/template@^7.27.1": + version "7.27.2" + resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.27.2.tgz#fa78ceed3c4e7b63ebf6cb39e5852fca45f6809d" + integrity sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw== + dependencies: + "@babel/code-frame" "^7.27.1" + "@babel/parser" "^7.27.2" + "@babel/types" "^7.27.1" + "@babel/traverse@^7.25.9": version "7.25.9" resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.25.9.tgz#a50f8fe49e7f69f53de5bea7e413cd35c5e13c84" @@ -275,6 +407,27 @@ debug "^4.3.1" globals "^11.1.0" +"@babel/traverse@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.27.1.tgz#4db772902b133bbddd1c4f7a7ee47761c1b9f291" + integrity sha512-ZCYtZciz1IWJB4U61UPu4KEaqyfj+r5T1Q5mqPo+IBpcG9kHv30Z0aD8LXPgC1trYa6rK0orRyAhqUgk4MjmEg== + dependencies: + "@babel/code-frame" "^7.27.1" + "@babel/generator" "^7.27.1" + "@babel/parser" "^7.27.1" + "@babel/template" "^7.27.1" + "@babel/types" "^7.27.1" + debug "^4.3.1" + globals "^11.1.0" + +"@babel/types@^7.0.0", "@babel/types@^7.20.7", "@babel/types@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.27.1.tgz#9defc53c16fc899e46941fc6901a9eea1c9d8560" + integrity sha512-+EzkxvLNfiUeKMgy/3luqfsCWFRXLb7U6wNQTk60tovuckwB15B191tJWvpp4HjiQWdJkCxO3Wbvc6jlk3Xb2Q== + dependencies: + "@babel/helper-string-parser" "^7.27.1" + "@babel/helper-validator-identifier" "^7.27.1" + "@babel/types@^7.25.9", "@babel/types@^7.26.0": version "7.26.0" resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.26.0.tgz#deabd08d6b753bc8e0f198f8709fb575e31774ff" @@ -404,6 +557,28 @@ enabled "2.0.x" kuler "^2.0.0" +"@element-hq/element-web-module-api@^1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@element-hq/element-web-module-api/-/element-web-module-api-1.0.0.tgz#df09108b0346a44ad2898c603d1a6cda5f50d80b" + integrity sha512-FYId5tYgaKvpqAXRXqs0pY4+7/A09bEl1mCxFqlS9jlZOCjlMZVvZuv8spbY8ZN9HaMvuVmx9J00Fn2gCJd0TQ== + +"@emotion/is-prop-valid@1.2.2": + version "1.2.2" + resolved "https://registry.yarnpkg.com/@emotion/is-prop-valid/-/is-prop-valid-1.2.2.tgz#d4175076679c6a26faa92b03bb786f9e52612337" + integrity sha512-uNsoYd37AFmaCdXlg6EYD1KaPOaRWRByMCYzbKUX4+hhMfrxdVSelShywL4JVaAeM/eHUOSprYBQls+/neX3pw== + dependencies: + "@emotion/memoize" "^0.8.1" + +"@emotion/memoize@^0.8.1": + version "0.8.1" + resolved "https://registry.yarnpkg.com/@emotion/memoize/-/memoize-0.8.1.tgz#c1ddb040429c6d21d38cc945fe75c818cfb68e17" + integrity sha512-W2P2c/VRW1/1tLox0mVUalvnWXxavmv/Oum2aPsRcoDJuob75FC3Y8FbpfLwUegRcxINtGUMPq0tFCvYNTBXNA== + +"@emotion/unitless@0.8.1": + version "0.8.1" + resolved "https://registry.yarnpkg.com/@emotion/unitless/-/unitless-0.8.1.tgz#182b5a4704ef8ad91bde93f7a860a88fd92c79a3" + integrity sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ== + "@esbuild/aix-ppc64@0.21.5": version "0.21.5" resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz#c7184a326533fcdf1b8ee0733e21c713b975575f" @@ -1669,6 +1844,15 @@ resolved "https://registry.yarnpkg.com/@rollup/plugin-alias/-/plugin-alias-5.1.1.tgz#53601d88cda8b1577aa130b4a6e452283605bf26" integrity sha512-PR9zDb+rOzkRb2VD+EuKB7UC41vU5DIwZ5qqCpk0KJudcWAyi8rvYOhS7+L5aZCspw1stTViLgN5v6FF1p5cgQ== +"@rollup/plugin-inject@^5.0.5": + version "5.0.5" + resolved "https://registry.yarnpkg.com/@rollup/plugin-inject/-/plugin-inject-5.0.5.tgz#616f3a73fe075765f91c5bec90176608bed277a3" + integrity sha512-2+DEJbNBoPROPkgTDNe8/1YXWcqxbN5DTjASVIOx8HS+pITXushyNiBV56RB08zuptzz8gT3YfkqriTBVycepg== + dependencies: + "@rollup/pluginutils" "^5.0.1" + estree-walker "^2.0.2" + magic-string "^0.30.3" + "@rollup/pluginutils@^4.1.1", "@rollup/pluginutils@^4.2.1": version "4.2.1" resolved "https://registry.yarnpkg.com/@rollup/pluginutils/-/pluginutils-4.2.1.tgz#e6c6c3aba0744edce3fb2074922d3776c0af2a6d" @@ -1677,6 +1861,15 @@ estree-walker "^2.0.1" picomatch "^2.2.2" +"@rollup/pluginutils@^5.0.1", "@rollup/pluginutils@^5.1.0": + version "5.1.4" + resolved "https://registry.yarnpkg.com/@rollup/pluginutils/-/pluginutils-5.1.4.tgz#bb94f1f9eaaac944da237767cdfee6c5b2262d4a" + integrity sha512-USm05zrsFxYLPdWWq+K3STlWiT/3ELn3RcV5hJMghpeAIhxfsUIg6mt12CBJBInWMV4VneoV7SfGv8xIwo2qNQ== + dependencies: + "@types/estree" "^1.0.0" + estree-walker "^2.0.2" + picomatch "^4.0.2" + "@rollup/rollup-android-arm-eabi@4.40.2": version "4.40.2" resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.40.2.tgz#c228d00a41f0dbd6fb8b7ea819bbfbf1c1157a10" @@ -1892,6 +2085,39 @@ dependencies: ajv "*" +"@types/babel__core@^7.20.5": + version "7.20.5" + resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.20.5.tgz#3df15f27ba85319caa07ba08d0721889bb39c017" + integrity sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA== + dependencies: + "@babel/parser" "^7.20.7" + "@babel/types" "^7.20.7" + "@types/babel__generator" "*" + "@types/babel__template" "*" + "@types/babel__traverse" "*" + +"@types/babel__generator@*": + version "7.27.0" + resolved "https://registry.yarnpkg.com/@types/babel__generator/-/babel__generator-7.27.0.tgz#b5819294c51179957afaec341442f9341e4108a9" + integrity sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg== + dependencies: + "@babel/types" "^7.0.0" + +"@types/babel__template@*": + version "7.4.4" + resolved "https://registry.yarnpkg.com/@types/babel__template/-/babel__template-7.4.4.tgz#5672513701c1b2199bc6dad636a9d7491586766f" + integrity sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A== + dependencies: + "@babel/parser" "^7.1.0" + "@babel/types" "^7.0.0" + +"@types/babel__traverse@*": + version "7.20.7" + resolved "https://registry.yarnpkg.com/@types/babel__traverse/-/babel__traverse-7.20.7.tgz#968cdc2366ec3da159f61166428ee40f370e56c2" + integrity sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng== + dependencies: + "@babel/types" "^7.20.7" + "@types/body-parser@*": version "1.19.5" resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.19.5.tgz#04ce9a3b677dc8bd681a17da1ab9835dc9d3ede4" @@ -2138,6 +2364,11 @@ "@types/node" "*" "@types/ssh2-streams" "*" +"@types/stylis@4.2.5": + version "4.2.5" + resolved "https://registry.yarnpkg.com/@types/stylis/-/stylis-4.2.5.tgz#1daa6456f40959d06157698a653a9ab0a70281df" + integrity sha512-1Xve+NMN7FWjY14vLoY5tL3BVEQ/n42YLwaqJIPYhotZ9uBHt87VceMwWQpzmdEt2TNXIorIFG+YeCUUW7RInw== + "@types/tough-cookie@*": version "4.0.5" resolved "https://registry.yarnpkg.com/@types/tough-cookie/-/tough-cookie-4.0.5.tgz#cb6e2a691b70cb177c6e3ae9c1d2e8b2ea8cd304" @@ -2282,6 +2513,17 @@ ts-xor "^1.3.0" vaul "^1.0.0" +"@vitejs/plugin-react@^4.4.1": + version "4.4.1" + resolved "https://registry.yarnpkg.com/@vitejs/plugin-react/-/plugin-react-4.4.1.tgz#d7d1e9c9616d7536b0953637edfee7c6cbe2fe0f" + integrity sha512-IpEm5ZmeXAP/osiBXVVP5KjFMzbWOonMs0NaQQl+xYnUAcq4oHUBsF2+p4MgKWG4YMmFYJU8A6sxRPuowllm6w== + dependencies: + "@babel/core" "^7.26.10" + "@babel/plugin-transform-react-jsx-self" "^7.25.9" + "@babel/plugin-transform-react-jsx-source" "^7.25.9" + "@types/babel__core" "^7.20.5" + react-refresh "^0.17.0" + "@vitest/expect@3.1.3": version "3.1.3" resolved "https://registry.yarnpkg.com/@vitest/expect/-/expect-3.1.3.tgz#bbca175cd2f23d7de9448a215baed8f3d7abd7b7" @@ -2639,6 +2881,15 @@ arraybuffer.prototype.slice@^1.0.3: is-array-buffer "^3.0.4" is-shared-array-buffer "^1.0.2" +asn1.js@^4.10.1: + version "4.10.1" + resolved "https://registry.yarnpkg.com/asn1.js/-/asn1.js-4.10.1.tgz#b9c2bf5805f1e64aadeed6df3a2bfafb5a73f5a0" + integrity sha512-p32cOF5q0Zqs9uBiONKYLm6BClCoBCM5O9JfeUSlnQLBTxYdTK+pW+nXflm8UkKd2UYlEbYz5qEi0JuZR9ckSw== + dependencies: + bn.js "^4.0.0" + inherits "^2.0.1" + minimalistic-assert "^1.0.0" + asn1@^0.2.6, asn1@~0.2.3: version "0.2.6" resolved "https://registry.yarnpkg.com/asn1/-/asn1-0.2.6.tgz#0d3a7bb6e64e02a90c0303b31f292868ea09a08d" @@ -2651,6 +2902,17 @@ assert-plus@1.0.0, assert-plus@^1.0.0: resolved "https://registry.yarnpkg.com/assert-plus/-/assert-plus-1.0.0.tgz#f12e0f3c5d77b0b1cdd9146942e4e96c1e4dd525" integrity sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw== +assert@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/assert/-/assert-2.1.0.tgz#6d92a238d05dc02e7427c881fb8be81c8448b2dd" + integrity sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw== + dependencies: + call-bind "^1.0.2" + is-nan "^1.3.2" + object-is "^1.1.5" + object.assign "^4.1.4" + util "^0.12.5" + assertion-error@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-1.1.0.tgz#e60b6b0e8f301bd97e5375215bda406c85118c0b" @@ -2833,6 +3095,16 @@ bluebird@^3.5.0: resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f" integrity sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg== +bn.js@^4.0.0, bn.js@^4.1.0, bn.js@^4.11.9: + version "4.12.2" + resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.12.2.tgz#3d8fed6796c24e177737f7cc5172ee04ef39ec99" + integrity sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw== + +bn.js@^5.2.1: + version "5.2.2" + resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-5.2.2.tgz#82c09f9ebbb17107cd72cb7fd39bd1f9d0aaa566" + integrity sha512-v2YAxEmKaBLahNwE1mjp4WON6huMNeuDvagFZW+ASCuA/ku0bXR9hSMw0XpiqMoA3+rmnyck/tPRSFQkoC9Cuw== + body-parser@1.20.3, body-parser@^1.19.0: version "1.20.3" resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.3.tgz#1953431221c6fb5cd63c4b36d53fab0928e548c6" @@ -2878,6 +3150,11 @@ braces@^3.0.3, braces@~3.0.2: dependencies: fill-range "^7.1.1" +brorand@^1.0.1, brorand@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/brorand/-/brorand-1.1.0.tgz#12c25efe40a45e3c323eb8675a0a0ce57b22371f" + integrity sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w== + brotli@^1.3.3: version "1.3.3" resolved "https://registry.yarnpkg.com/brotli/-/brotli-1.3.3.tgz#7365d8cc00f12cf765d2b2c898716bcf4b604d48" @@ -2885,11 +3162,81 @@ brotli@^1.3.3: dependencies: base64-js "^1.1.2" +browser-resolve@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/browser-resolve/-/browser-resolve-2.0.0.tgz#99b7304cb392f8d73dba741bb2d7da28c6d7842b" + integrity sha512-7sWsQlYL2rGLy2IWm8WL8DCTJvYLc/qlOnsakDac87SOoCd16WLsaAMdCiAqsTNHIe+SXfaqyxyo6THoWqs8WQ== + dependencies: + resolve "^1.17.0" + browser-stdout@^1.3.1: version "1.3.1" resolved "https://registry.yarnpkg.com/browser-stdout/-/browser-stdout-1.3.1.tgz#baa559ee14ced73452229bad7326467c61fabd60" integrity sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw== +browserify-aes@^1.0.4, browserify-aes@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/browserify-aes/-/browserify-aes-1.2.0.tgz#326734642f403dabc3003209853bb70ad428ef48" + integrity sha512-+7CHXqGuspUn/Sl5aO7Ea0xWGAtETPXNSAjHo48JfLdPWcMng33Xe4znFvQweqc/uzk5zSOI3H52CYnjCfb5hA== + dependencies: + buffer-xor "^1.0.3" + cipher-base "^1.0.0" + create-hash "^1.1.0" + evp_bytestokey "^1.0.3" + inherits "^2.0.1" + safe-buffer "^5.0.1" + +browserify-cipher@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/browserify-cipher/-/browserify-cipher-1.0.1.tgz#8d6474c1b870bfdabcd3bcfcc1934a10e94f15f0" + integrity sha512-sPhkz0ARKbf4rRQt2hTpAHqn47X3llLkUGn+xEJzLjwY8LRs2p0v7ljvI5EyoRO/mexrNunNECisZs+gw2zz1w== + dependencies: + browserify-aes "^1.0.4" + browserify-des "^1.0.0" + evp_bytestokey "^1.0.0" + +browserify-des@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/browserify-des/-/browserify-des-1.0.2.tgz#3af4f1f59839403572f1c66204375f7a7f703e9c" + integrity sha512-BioO1xf3hFwz4kc6iBhI3ieDFompMhrMlnDFC4/0/vd5MokpuAc3R+LYbwTA9A5Yc9pq9UYPqffKpW2ObuwX5A== + dependencies: + cipher-base "^1.0.1" + des.js "^1.0.0" + inherits "^2.0.1" + safe-buffer "^5.1.2" + +browserify-rsa@^4.0.0, browserify-rsa@^4.1.0: + version "4.1.1" + resolved "https://registry.yarnpkg.com/browserify-rsa/-/browserify-rsa-4.1.1.tgz#06e530907fe2949dc21fc3c2e2302e10b1437238" + integrity sha512-YBjSAiTqM04ZVei6sXighu679a3SqWORA3qZTEqZImnlkDIFtKc6pNutpjyZ8RJTjQtuYfeetkxM11GwoYXMIQ== + dependencies: + bn.js "^5.2.1" + randombytes "^2.1.0" + safe-buffer "^5.2.1" + +browserify-sign@^4.2.3: + version "4.2.3" + resolved "https://registry.yarnpkg.com/browserify-sign/-/browserify-sign-4.2.3.tgz#7afe4c01ec7ee59a89a558a4b75bd85ae62d4208" + integrity sha512-JWCZW6SKhfhjJxO8Tyiiy+XYB7cqd2S5/+WeYHsKdNKFlCBhKbblba1A/HN/90YwtxKc8tCErjffZl++UNmGiw== + dependencies: + bn.js "^5.2.1" + browserify-rsa "^4.1.0" + create-hash "^1.2.0" + create-hmac "^1.1.7" + elliptic "^6.5.5" + hash-base "~3.0" + inherits "^2.0.4" + parse-asn1 "^5.1.7" + readable-stream "^2.3.8" + safe-buffer "^5.2.1" + +browserify-zlib@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/browserify-zlib/-/browserify-zlib-0.2.0.tgz#2869459d9aa3be245fe8fe2ca1f46e2e7f54d73f" + integrity sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA== + dependencies: + pako "~1.0.5" + browserslist@^4.24.0: version "4.24.2" resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.24.2.tgz#f5845bc91069dbd55ee89faf9822e1d885d16580" @@ -2920,7 +3267,12 @@ buffer-from@^1.0.0: resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5" integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ== -buffer@^5.5.0: +buffer-xor@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/buffer-xor/-/buffer-xor-1.0.3.tgz#26e61ed1422fb70dd42e6e36729ed51d855fe8d9" + integrity sha512-571s0T7nZWK6vB67HI5dyUF7wXiNcfaPPPTl6zYCNApANjIvYJTg7hlud/+cJpdAhS7dVzqMLmfhfHR3rAcOjQ== + +buffer@^5.5.0, buffer@^5.7.1: version "5.7.1" resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.7.1.tgz#ba62e7c13133053582197160851a8f648e99eed0" integrity sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ== @@ -2941,6 +3293,11 @@ buildcheck@~0.0.6: resolved "https://registry.yarnpkg.com/buildcheck/-/buildcheck-0.0.6.tgz#89aa6e417cfd1e2196e3f8fe915eb709d2fe4238" integrity sha512-8f9ZJCUXyT1M35Jx7MkBgmBMo3oHTTBIPLiY9xyL0pl3T5RwcPEY8cUHr5LBNfu/fk6c2T4DJZuVM/8ZZT2D2A== +builtin-status-codes@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/builtin-status-codes/-/builtin-status-codes-3.0.0.tgz#85982878e21b98e1c66425e03d0174788f569ee8" + integrity sha512-HpGFw18DgFWlncDfjTa2rcQ4W88O1mC8e8yZ2AvQY5KDaktSTwo+KRf6nHK6FRI5FyRyb/5T6+TSxfP7QyGsmQ== + busboy@^1.6.0: version "1.6.0" resolved "https://registry.yarnpkg.com/busboy/-/busboy-1.6.0.tgz#966ea36a9502e43cdb9146962523b92f531f6893" @@ -2973,6 +3330,24 @@ caching-transform@^4.0.0: package-hash "^4.0.0" write-file-atomic "^3.0.0" +call-bind-apply-helpers@^1.0.0, call-bind-apply-helpers@^1.0.1, call-bind-apply-helpers@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz#4b5428c222be985d79c3d82657479dbe0b59b2d6" + integrity sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ== + dependencies: + es-errors "^1.3.0" + function-bind "^1.1.2" + +call-bind@^1.0.0, call-bind@^1.0.8: + version "1.0.8" + resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.8.tgz#0736a9660f537e3388826f440d5ec45f744eaa4c" + integrity sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww== + dependencies: + call-bind-apply-helpers "^1.0.0" + es-define-property "^1.0.0" + get-intrinsic "^1.2.4" + set-function-length "^1.2.2" + call-bind@^1.0.2, call-bind@^1.0.4, call-bind@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.5.tgz#6fa2b7845ce0ea49bf4d8b9ef64727a2c2e2e513" @@ -2993,6 +3368,14 @@ call-bind@^1.0.6, call-bind@^1.0.7: get-intrinsic "^1.2.4" set-function-length "^1.2.1" +call-bound@^1.0.2, call-bound@^1.0.3, call-bound@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/call-bound/-/call-bound-1.0.4.tgz#238de935d2a2a692928c538c7ccfa91067fd062a" + integrity sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg== + dependencies: + call-bind-apply-helpers "^1.0.2" + get-intrinsic "^1.3.0" + callsites@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" @@ -3008,6 +3391,11 @@ camelcase@^6.0.0: resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.3.0.tgz#5685b95eb209ac9c0c177467778c9c84df58ba9a" integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA== +camelize@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/camelize/-/camelize-1.0.1.tgz#89b7e16884056331a35d6b5ad064332c91daa6c3" + integrity sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ== + caniuse-lite@^1.0.30001669: version "1.0.30001680" resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001680.tgz#5380ede637a33b9f9f1fc6045ea99bd142f3da5e" @@ -3094,6 +3482,14 @@ chownr@^1.1.1: resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.1.4.tgz#6fc9d7b42d32a583596337666e7d08084da2cc6b" integrity sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg== +cipher-base@^1.0.0, cipher-base@^1.0.1, cipher-base@^1.0.3: + version "1.0.6" + resolved "https://registry.yarnpkg.com/cipher-base/-/cipher-base-1.0.6.tgz#8fe672437d01cd6c4561af5334e0cc50ff1955f7" + integrity sha512-3Ek9H3X6pj5TgenXYtNWdaBon1tgYCaebd+XPg0keyjEbEfkD4KkmAxkQ/i1vYvxdcT5nscLBfq9VJRmCBcFSw== + dependencies: + inherits "^2.0.4" + safe-buffer "^5.2.1" + classnames@^2.5.1: version "2.5.1" resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.5.1.tgz#ba774c614be0f016da105c858e7159eae8e7687b" @@ -3240,6 +3636,16 @@ concat-map@0.0.1: resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg== +console-browserify@^1.1.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/console-browserify/-/console-browserify-1.2.0.tgz#67063cef57ceb6cf4993a2ab3a55840ae8c49336" + integrity sha512-ZMkYO/LkF17QvCPqM0gxw8yUzigAOZOSWSHg91FH6orS7vcEj5dVZTidN2fQ14yBSdg97RqhSNwLUXInd52OTA== + +constants-browserify@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/constants-browserify/-/constants-browserify-1.0.0.tgz#c20b96d8c617748aaf1c16021760cd27fcb8cb75" + integrity sha512-xFxOwqIzR/e1k1gLiWEophSCMqXcwVHIH7akf7b/vxcUeGunlj3hvZaaqxwHsTgn+IndtkQJgSztIDWeumWJDQ== + content-disposition@0.5.4: version "0.5.4" resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.4.tgz#8b82b4efac82512a02bb0b1dcec9d2c5e8eb5bfe" @@ -3316,7 +3722,38 @@ crc32-stream@^6.0.0: crc-32 "^1.2.0" readable-stream "^4.0.0" -create-require@^1.1.0: +create-ecdh@^4.0.4: + version "4.0.4" + resolved "https://registry.yarnpkg.com/create-ecdh/-/create-ecdh-4.0.4.tgz#d6e7f4bffa66736085a0762fd3a632684dabcc4e" + integrity sha512-mf+TCx8wWc9VpuxfP2ht0iSISLZnt0JgWlrOKZiNqyUZWnjIaCIVNQArMHnCZKfEYRg6IM7A+NeJoN8gf/Ws0A== + dependencies: + bn.js "^4.1.0" + elliptic "^6.5.3" + +create-hash@^1.1.0, create-hash@^1.1.2, create-hash@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/create-hash/-/create-hash-1.2.0.tgz#889078af11a63756bcfb59bd221996be3a9ef196" + integrity sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg== + dependencies: + cipher-base "^1.0.1" + inherits "^2.0.1" + md5.js "^1.3.4" + ripemd160 "^2.0.1" + sha.js "^2.4.0" + +create-hmac@^1.1.4, create-hmac@^1.1.7: + version "1.1.7" + resolved "https://registry.yarnpkg.com/create-hmac/-/create-hmac-1.1.7.tgz#69170c78b3ab957147b2b8b04572e47ead2243ff" + integrity sha512-MJG9liiZ+ogc4TzUwuvbER1JRdgvUFSB5+VR/g5h82fGaIRWMWddtKBHi7/sVhfjQZ6SehlyhvQYrcYkaUIpLg== + dependencies: + cipher-base "^1.0.3" + create-hash "^1.1.0" + inherits "^2.0.1" + ripemd160 "^2.0.0" + safe-buffer "^5.0.1" + sha.js "^2.4.8" + +create-require@^1.1.0, create-require@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333" integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ== @@ -3335,6 +3772,29 @@ cross-spawn@^7.0.0, cross-spawn@^7.0.3, cross-spawn@^7.0.5: shebang-command "^2.0.0" which "^2.0.1" +crypto-browserify@^3.12.1: + version "3.12.1" + resolved "https://registry.yarnpkg.com/crypto-browserify/-/crypto-browserify-3.12.1.tgz#bb8921bec9acc81633379aa8f52d69b0b69e0dac" + integrity sha512-r4ESw/IlusD17lgQi1O20Fa3qNnsckR126TdUuBgAu7GBYSIPvdNyONd3Zrxh0xCwA4+6w/TDArBPsMvhur+KQ== + dependencies: + browserify-cipher "^1.0.1" + browserify-sign "^4.2.3" + create-ecdh "^4.0.4" + create-hash "^1.2.0" + create-hmac "^1.1.7" + diffie-hellman "^5.0.3" + hash-base "~3.0.4" + inherits "^2.0.4" + pbkdf2 "^3.1.2" + public-encrypt "^4.0.3" + randombytes "^2.1.0" + randomfill "^1.0.4" + +css-color-keywords@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/css-color-keywords/-/css-color-keywords-1.0.0.tgz#fea2616dc676b2962686b3af8dbdbe180b244e05" + integrity sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg== + css-select@^5.1.0: version "5.1.0" resolved "https://registry.yarnpkg.com/css-select/-/css-select-5.1.0.tgz#b8ebd6554c3637ccc76688804ad3f6a6fdaea8a6" @@ -3346,6 +3806,15 @@ css-select@^5.1.0: domutils "^3.0.1" nth-check "^2.0.1" +css-to-react-native@3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/css-to-react-native/-/css-to-react-native-3.2.0.tgz#cdd8099f71024e149e4f6fe17a7d46ecd55f1e32" + integrity sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ== + dependencies: + camelize "^1.0.0" + css-color-keywords "^1.0.0" + postcss-value-parser "^4.0.2" + css-tree@^2.3.1: version "2.3.1" resolved "https://registry.yarnpkg.com/css-tree/-/css-tree-2.3.1.tgz#10264ce1e5442e8572fc82fbe490644ff54b5c20" @@ -3374,6 +3843,11 @@ csso@^5.0.5: dependencies: css-tree "~2.2.0" +csstype@3.1.3: + version "3.1.3" + resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.3.tgz#d80ff294d114fb0e6ac500fbf85b60137d7eff81" + integrity sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw== + dashdash@^1.12.0: version "1.14.1" resolved "https://registry.yarnpkg.com/dashdash/-/dashdash-1.14.1.tgz#853cfa0f7cbe2fed5de20326b8dd581035f6e2f0" @@ -3527,6 +4001,14 @@ deprecation@^2.0.0, deprecation@^2.3.1: resolved "https://registry.yarnpkg.com/deprecation/-/deprecation-2.3.1.tgz#6368cbdb40abf3373b525ac87e4a260c3a700919" integrity sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ== +des.js@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/des.js/-/des.js-1.1.0.tgz#1d37f5766f3bbff4ee9638e871a8768c173b81da" + integrity sha512-r17GxjhUCjSRy8aiJpr8/UadFIzMzJGexI3Nmz4ADi9LYSFx4gTBp80+NaX/YsXWWLhpZ7v/v/ubEc/bCNfKwg== + dependencies: + inherits "^2.0.1" + minimalistic-assert "^1.0.0" + destroy@1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.2.0.tgz#4803735509ad8be552934c67df614f94e66fa015" @@ -3552,6 +4034,15 @@ diff@^5.2.0: resolved "https://registry.yarnpkg.com/diff/-/diff-5.2.0.tgz#26ded047cd1179b78b9537d5ef725503ce1ae531" integrity sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A== +diffie-hellman@^5.0.3: + version "5.0.3" + resolved "https://registry.yarnpkg.com/diffie-hellman/-/diffie-hellman-5.0.3.tgz#40e8ee98f55a2149607146921c63e1ae5f3d2875" + integrity sha512-kqag/Nl+f3GwyK25fhUMYj81BUOrZ9IuJsjIcDE5icNM9FJHAVm3VcUDxdLPoQtTuUylWm6ZIknYJwwaPxsUzg== + dependencies: + bn.js "^4.1.0" + miller-rabin "^4.0.0" + randombytes "^2.0.0" + discontinuous-range@1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/discontinuous-range/-/discontinuous-range-1.0.0.tgz#e38331f0844bba49b9a9cb71c771585aab1bc65a" @@ -3612,6 +4103,11 @@ dom-serializer@^2.0.0: domhandler "^5.0.2" entities "^4.2.0" +domain-browser@4.22.0: + version "4.22.0" + resolved "https://registry.yarnpkg.com/domain-browser/-/domain-browser-4.22.0.tgz#6ddd34220ec281f9a65d3386d267ddd35c491f9f" + integrity sha512-IGBwjF7tNk3cwypFNH/7bfzBcgSCbaMOD3GsaY1AU/JRrnHnYgEM0+9kQt52iZxjNsjBtJYtao146V+f8jFZNw== + domelementtype@^2.0.1, domelementtype@^2.2.0, domelementtype@^2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.3.0.tgz#5c45e8e869952626331d7aab326d01daf65d589d" @@ -3649,6 +4145,15 @@ domutils@^3.0.1: domelementtype "^2.3.0" domhandler "^5.0.3" +dunder-proto@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/dunder-proto/-/dunder-proto-1.0.1.tgz#d7ae667e1dc83482f8b70fd0f6eefc50da30f58a" + integrity sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A== + dependencies: + call-bind-apply-helpers "^1.0.1" + es-errors "^1.3.0" + gopd "^1.2.0" + eastasianwidth@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz#696ce2ec0aa0e6ea93a397ffcf24aa7840c827cb" @@ -3679,6 +4184,19 @@ electron-to-chromium@^1.5.41: resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.63.tgz#69444d592fbbe628d129866c2355691ea93eda3e" integrity sha512-ddeXKuY9BHo/mw145axlyWjlJ1UBt4WK3AlvkT7W2AbqfRQoacVoRUCF6wL3uIx/8wT9oLKXzI+rFqHHscByaA== +elliptic@^6.5.3, elliptic@^6.5.5: + version "6.6.1" + resolved "https://registry.yarnpkg.com/elliptic/-/elliptic-6.6.1.tgz#3b8ffb02670bf69e382c7f65bf524c97c5405c06" + integrity sha512-RaddvvMatK2LJHqFJ+YA4WysVN5Ita9E35botqIYspQ4TkRAlCicdzKOjlyv/1Za5RyTNn7di//eEV0uTAfe3g== + dependencies: + bn.js "^4.11.9" + brorand "^1.1.0" + hash.js "^1.0.0" + hmac-drbg "^1.0.1" + inherits "^2.0.4" + minimalistic-assert "^1.0.1" + minimalistic-crypto-utils "^1.0.1" + emoji-regex@^8.0.0: version "8.0.0" resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" @@ -3830,6 +4348,11 @@ es-define-property@^1.0.0: dependencies: get-intrinsic "^1.2.4" +es-define-property@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/es-define-property/-/es-define-property-1.0.1.tgz#983eb2f9a6724e9303f61addf011c72e09e0b0fa" + integrity sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g== + es-errors@^1.2.1, es-errors@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f" @@ -3868,6 +4391,13 @@ es-object-atoms@^1.0.0: dependencies: es-errors "^1.3.0" +es-object-atoms@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz#1c4f2c4837327597ce69d2ca190a7fdd172338c1" + integrity sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA== + dependencies: + es-errors "^1.3.0" + es-set-tostringtag@^2.0.1: version "2.0.2" resolved "https://registry.yarnpkg.com/es-set-tostringtag/-/es-set-tostringtag-2.0.2.tgz#11f7cc9f63376930a5f20be4915834f4bc74f9c9" @@ -4123,7 +4653,7 @@ estraverse@^5.1.0, estraverse@^5.2.0, estraverse@^5.3.0: resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.3.0.tgz#2eea5290702f26ab8fe5370370ff86c965d21123" integrity sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA== -estree-walker@^2.0.1: +estree-walker@^2.0.1, estree-walker@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-2.0.2.tgz#52f010178c2a4c117a7757cfe942adb7d2da4cac" integrity sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w== @@ -4155,11 +4685,19 @@ eventemitter3@^4.0.4: resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.7.tgz#2de9b68f6528d5644ef5c59526a1b4a07306169f" integrity sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw== -events@^3.2.0, events@^3.3.0: +events@^3.0.0, events@^3.2.0, events@^3.3.0: version "3.3.0" resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400" integrity sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q== +evp_bytestokey@^1.0.0, evp_bytestokey@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/evp_bytestokey/-/evp_bytestokey-1.0.3.tgz#7fcbdb198dc71959432efe13842684e0525acb02" + integrity sha512-/f2Go4TognH/KvCISP7OUsHn85hT9nUkxxA9BEWxFn+Oj9o8ZNLm/40hdlgSLyuOimsrTKLUMEorQexp/aPQeA== + dependencies: + md5.js "^1.3.4" + safe-buffer "^5.1.1" + expect-type@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/expect-type/-/expect-type-1.2.1.tgz#af76d8b357cf5fa76c41c09dafb79c549e75f71f" @@ -4426,6 +4964,13 @@ for-each@^0.3.3: dependencies: is-callable "^1.1.3" +for-each@^0.3.5: + version "0.3.5" + resolved "https://registry.yarnpkg.com/for-each/-/for-each-0.3.5.tgz#d650688027826920feeb0af747ee7b9421a41d47" + integrity sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg== + dependencies: + is-callable "^1.2.7" + foreground-child@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/foreground-child/-/foreground-child-2.0.0.tgz#71b32800c9f15aa8f2f83f4a6bd9bff35d861a53" @@ -4582,6 +5127,22 @@ get-intrinsic@^1.1.3, get-intrinsic@^1.2.1, get-intrinsic@^1.2.2, get-intrinsic@ has-symbols "^1.0.3" hasown "^2.0.0" +get-intrinsic@^1.2.5, get-intrinsic@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz#743f0e3b6964a93a5491ed1bffaae054d7f98d01" + integrity sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ== + dependencies: + call-bind-apply-helpers "^1.0.2" + es-define-property "^1.0.1" + es-errors "^1.3.0" + es-object-atoms "^1.1.1" + function-bind "^1.1.2" + get-proto "^1.0.1" + gopd "^1.2.0" + has-symbols "^1.1.0" + hasown "^2.0.2" + math-intrinsics "^1.1.0" + get-nonce@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/get-nonce/-/get-nonce-1.0.1.tgz#fdf3f0278073820d2ce9426c18f07481b1e0cdf3" @@ -4597,6 +5158,14 @@ get-port@^7.1.0: resolved "https://registry.yarnpkg.com/get-port/-/get-port-7.1.0.tgz#d5a500ebfc7aa705294ec2b83cc38c5d0e364fec" integrity sha512-QB9NKEeDg3xxVwCCwJQ9+xycaz6pBB6iQ76wiWMl1927n0Kir6alPiP+yuiICLLU4jpMe08dXfpebuQppFA2zw== +get-proto@^1.0.0, get-proto@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/get-proto/-/get-proto-1.0.1.tgz#150b3f2743869ef3e851ec0c49d15b1d14d00ee1" + integrity sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g== + dependencies: + dunder-proto "^1.0.1" + es-object-atoms "^1.0.0" + get-symbol-description@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/get-symbol-description/-/get-symbol-description-1.0.0.tgz#7fdb81c900101fbd564dd5f1a30af5aadc1e58d6" @@ -4726,6 +5295,11 @@ gopd@^1.0.1: dependencies: get-intrinsic "^1.1.3" +gopd@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.2.0.tgz#89f56b8217bdbc8802bd299df6d7f1081d7e51a1" + integrity sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg== + graceful-fs@^4.1.15, graceful-fs@^4.1.3, graceful-fs@^4.2.0, graceful-fs@^4.2.4: version "4.2.11" resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3" @@ -4776,6 +5350,11 @@ has-symbols@^1.0.2, has-symbols@^1.0.3: resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.3.tgz#bb7b2c4349251dce87b125f7bdf874aa7c8b39f8" integrity sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A== +has-symbols@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.1.0.tgz#fc9c6a783a084951d0b971fe1018de813707a338" + integrity sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ== + has-tostringtag@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/has-tostringtag/-/has-tostringtag-1.0.0.tgz#7e133818a7d394734f941e73c3d3f9291e658b25" @@ -4790,7 +5369,24 @@ has-tostringtag@^1.0.2: dependencies: has-symbols "^1.0.3" -hash.js@^1.1.7: +hash-base@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/hash-base/-/hash-base-3.1.0.tgz#55c381d9e06e1d2997a883b4a3fddfe7f0d3af33" + integrity sha512-1nmYp/rhMDiE7AYkDw+lLwlAzz0AntGIe51F3RfFfEqyQ3feY2eI/NcwC6umIQVOASPMsWJLJScWKSSvzL9IVA== + dependencies: + inherits "^2.0.4" + readable-stream "^3.6.0" + safe-buffer "^5.2.0" + +hash-base@~3.0, hash-base@~3.0.4: + version "3.0.5" + resolved "https://registry.yarnpkg.com/hash-base/-/hash-base-3.0.5.tgz#52480e285395cf7fba17dc4c9e47acdc7f248a8a" + integrity sha512-vXm0l45VbcHEVlTCzs8M+s0VeYsB2lnlAaThoLKGXr3bE/VWDOelNUnycUPEhKEaXARL2TEFjBOyUiM6+55KBg== + dependencies: + inherits "^2.0.4" + safe-buffer "^5.2.1" + +hash.js@^1.0.0, hash.js@^1.0.3, hash.js@^1.1.7: version "1.1.7" resolved "https://registry.yarnpkg.com/hash.js/-/hash.js-1.1.7.tgz#0babca538e8d4ee4a0f8988d68866537a003cf42" integrity sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA== @@ -4823,6 +5419,15 @@ helmet@^7.1.0: resolved "https://registry.yarnpkg.com/helmet/-/helmet-7.2.0.tgz#8b2dcc425b4a46c88f6953481b40453cbe66b167" integrity sha512-ZRiwvN089JfMXokizgqEPXsl2Guk094yExfoDXR0cBYWxtBbaSww/w+vT4WEJsBW2iTUi1GgZ6swmoug3Oy4Xw== +hmac-drbg@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/hmac-drbg/-/hmac-drbg-1.0.1.tgz#d2745701025a6c775a6c545793ed502fc0c649a1" + integrity sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg== + dependencies: + hash.js "^1.0.3" + minimalistic-assert "^1.0.0" + minimalistic-crypto-utils "^1.0.1" + html-escaper@^2.0.0: version "2.0.2" resolved "https://registry.yarnpkg.com/html-escaper/-/html-escaper-2.0.2.tgz#dfd60027da36a36dfcbe236262c00a5822681453" @@ -4910,6 +5515,11 @@ http-status-codes@^2.2.0: resolved "https://registry.yarnpkg.com/http-status-codes/-/http-status-codes-2.3.0.tgz#987fefb28c69f92a43aecc77feec2866349a8bfc" integrity sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA== +https-browserify@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/https-browserify/-/https-browserify-1.0.0.tgz#ec06c10e0a34c0f2faf199f7fd7fc78fffd03c73" + integrity sha512-J+FkSdyD+0mA0N+81tMotaRMfSL9SGi+xpD3T6YApKsc3bGSXJlfXri3VyFOeYkfLRQisDk1W+jIFFKBeUBbBg== + https-proxy-agent@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz#c59ef224a04fe8b754f3db0063a25ea30d0005d6" @@ -4984,7 +5594,7 @@ inflight@^1.0.4: once "^1.3.0" wrappy "1" -inherits@2, inherits@2.0.4, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.3: +inherits@2, inherits@2.0.4, inherits@^2.0.1, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.3, inherits@~2.0.4: version "2.0.4" resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== @@ -5050,6 +5660,14 @@ ipaddr.js@1.9.1: resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3" integrity sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g== +is-arguments@^1.0.4: + version "1.2.0" + resolved "https://registry.yarnpkg.com/is-arguments/-/is-arguments-1.2.0.tgz#ad58c6aecf563b78ef2bf04df540da8f5d7d8e1b" + integrity sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA== + dependencies: + call-bound "^1.0.2" + has-tostringtag "^1.0.2" + is-array-buffer@^3.0.1, is-array-buffer@^3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/is-array-buffer/-/is-array-buffer-3.0.2.tgz#f2653ced8412081638ecb0ebbd0c41c6e0aecbbe" @@ -5113,6 +5731,13 @@ is-core-module@^2.13.0: dependencies: hasown "^2.0.0" +is-core-module@^2.16.0: + version "2.16.1" + resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.16.1.tgz#2a98801a849f43e2add644fbb6bc6229b19a4ef4" + integrity sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w== + dependencies: + hasown "^2.0.2" + is-data-view@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/is-data-view/-/is-data-view-1.0.1.tgz#4b4d3a511b70f3dc26d42c03ca9ca515d847759f" @@ -5158,6 +5783,16 @@ is-generator-function@^1.0.10: dependencies: has-tostringtag "^1.0.0" +is-generator-function@^1.0.7: + version "1.1.0" + resolved "https://registry.yarnpkg.com/is-generator-function/-/is-generator-function-1.1.0.tgz#bf3eeda931201394f57b5dba2800f91a238309ca" + integrity sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ== + dependencies: + call-bound "^1.0.3" + get-proto "^1.0.0" + has-tostringtag "^1.0.2" + safe-regex-test "^1.1.0" + is-glob@^4.0.0, is-glob@^4.0.1, is-glob@^4.0.3, is-glob@~4.0.1: version "4.0.3" resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084" @@ -5191,6 +5826,14 @@ is-my-json-valid@^2.20.5: jsonpointer "^5.0.0" xtend "^4.0.0" +is-nan@^1.3.2: + version "1.3.2" + resolved "https://registry.yarnpkg.com/is-nan/-/is-nan-1.3.2.tgz#043a54adea31748b55b6cd4e09aadafa69bd9e1d" + integrity sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w== + dependencies: + call-bind "^1.0.0" + define-properties "^1.1.3" + is-negative-zero@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/is-negative-zero/-/is-negative-zero-2.0.2.tgz#7bf6f03a28003b8b3965de3ac26f664d765f3150" @@ -5233,6 +5876,13 @@ is-property@^1.0.0, is-property@^1.0.2: resolved "https://registry.yarnpkg.com/is-property/-/is-property-1.0.2.tgz#57fe1c4e48474edd65b09911f26b1cd4095dda84" integrity sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g== +is-reference@^3.0.2: + version "3.0.3" + resolved "https://registry.yarnpkg.com/is-reference/-/is-reference-3.0.3.tgz#9ef7bf9029c70a67b2152da4adf57c23d718910f" + integrity sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw== + dependencies: + "@types/estree" "^1.0.6" + is-regex@^1.1.4: version "1.1.4" resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.1.4.tgz#eef5663cd59fa4c0ae339505323df6854bb15958" @@ -5241,6 +5891,16 @@ is-regex@^1.1.4: call-bind "^1.0.2" has-tostringtag "^1.0.0" +is-regex@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.2.1.tgz#76d70a3ed10ef9be48eb577887d74205bf0cad22" + integrity sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g== + dependencies: + call-bound "^1.0.2" + gopd "^1.2.0" + has-tostringtag "^1.0.2" + hasown "^2.0.2" + is-set@^2.0.1: version "2.0.2" resolved "https://registry.yarnpkg.com/is-set/-/is-set-2.0.2.tgz#90755fa4c2562dc1c5d4024760d6119b94ca18ec" @@ -5298,6 +5958,13 @@ is-typed-array@^1.1.13: dependencies: which-typed-array "^1.1.14" +is-typed-array@^1.1.3: + version "1.1.15" + resolved "https://registry.yarnpkg.com/is-typed-array/-/is-typed-array-1.1.15.tgz#4bfb4a45b61cee83a5a46fba778e4e8d59c0ce0b" + integrity sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ== + dependencies: + which-typed-array "^1.1.16" + is-typedarray@^1.0.0, is-typedarray@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a" @@ -5361,6 +6028,11 @@ isexe@^2.0.0: resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== +isomorphic-timers-promises@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/isomorphic-timers-promises/-/isomorphic-timers-promises-1.0.1.tgz#e4137c24dbc54892de8abae3a4b5c1ffff381598" + integrity sha512-u4sej9B1LPSxTGKB/HiuzvEQnXH0ECYkSVQU39koSwmFAxhlEAFl9RdTvLv4TOTQUgBS5O3O5fwUxk6byBZ+IQ== + isstream@~0.1.2: version "0.1.2" resolved "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a" @@ -5845,7 +6517,7 @@ magic-string@^0.30.10: dependencies: "@jridgewell/sourcemap-codec" "^1.5.0" -magic-string@^0.30.17: +magic-string@^0.30.17, magic-string@^0.30.3: version "0.30.17" resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.17.tgz#450a449673d2460e5bbcfba9a61916a1714c7453" integrity sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA== @@ -5883,6 +6555,11 @@ markdown-it@^14.0.0: punycode.js "^2.3.1" uc.micro "^2.1.0" +math-intrinsics@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9" + integrity sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g== + matrix-appservice-bridge@^9.0.1: version "9.0.1" resolved "https://registry.yarnpkg.com/matrix-appservice-bridge/-/matrix-appservice-bridge-9.0.1.tgz#01db1c821cdecc3f27844fa2452c9dd72a3a75da" @@ -5974,6 +6651,15 @@ matrix-widget-api@^1.10.0: "@types/events" "^3.0.0" events "^3.2.0" +md5.js@^1.3.4: + version "1.3.5" + resolved "https://registry.yarnpkg.com/md5.js/-/md5.js-1.3.5.tgz#b5d07b8e3216e3e27cd728d72f70d1e6a342005f" + integrity sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg== + dependencies: + hash-base "^3.0.0" + inherits "^2.0.1" + safe-buffer "^5.1.2" + mdn-data@2.0.28: version "2.0.28" resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.0.28.tgz#5ec48e7bef120654539069e1ae4ddc81ca490eba" @@ -6017,6 +6703,14 @@ micromatch@^4.0.4, micromatch@^4.0.5, micromatch@^4.0.8: braces "^3.0.3" picomatch "^2.3.1" +miller-rabin@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/miller-rabin/-/miller-rabin-4.0.1.tgz#f080351c865b0dc562a8462966daa53543c78a4d" + integrity sha512-115fLhvZVqWwHPbClyntxEVfVDfl9DLLTuJvq3g2O/Oxi8AiNouAHvDSzHS0viUJc+V5vm3eq91Xwqn9dp4jRA== + dependencies: + bn.js "^4.0.0" + brorand "^1.0.1" + mime-db@1.52.0: version "1.52.0" resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" @@ -6039,11 +6733,16 @@ mime@^4.0.4: resolved "https://registry.yarnpkg.com/mime/-/mime-4.0.4.tgz#9f851b0fc3c289d063b20a7a8055b3014b25664b" integrity sha512-v8yqInVjhXyqP6+Kw4fV3ZzeMRqEW6FotRsKXjRS5VMTNIuXsdRoAvklpoRgSqXm6o9VNH4/C0mgedko9DdLsQ== -minimalistic-assert@^1.0.1: +minimalistic-assert@^1.0.0, minimalistic-assert@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz#2e194de044626d4a10e7f7fbc00ce73e83e4d5c7" integrity sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A== +minimalistic-crypto-utils@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz#f6c00c1c0b082246e5c4d99dfb8c7c083b2b582a" + integrity sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg== + minimatch@^10.0.0: version "10.0.1" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.0.1.tgz#ce0521856b453c86e25f2c4c0d03e6ff7ddc440b" @@ -6171,7 +6870,7 @@ nan@^2.19.0, nan@^2.20.0: resolved "https://registry.yarnpkg.com/nan/-/nan-2.22.2.tgz#6b504fd029fb8f38c0990e52ad5c26772fdacfbb" integrity sha512-DANghxFkS1plDdRsX0X9pm0Z6SJNN6gBdtXfanwoZ8hooC5gosGFSBGRYHUVPz1asKA/kMRqDRdHrluZ61SpBQ== -nanoid@^3.3.8: +nanoid@^3.3.7, nanoid@^3.3.8: version "3.3.11" resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.11.tgz#4f4f112cefbe303202f2199838128936266d185b" integrity sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w== @@ -6247,6 +6946,39 @@ node-releases@^2.0.18: resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.18.tgz#f010e8d35e2fe8d6b2944f03f70213ecedc4ca3f" integrity sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g== +node-stdlib-browser@^1.2.0: + version "1.3.1" + resolved "https://registry.yarnpkg.com/node-stdlib-browser/-/node-stdlib-browser-1.3.1.tgz#f41fa554f720a3df951e40339f4d92ac512222ac" + integrity sha512-X75ZN8DCLftGM5iKwoYLA3rjnrAEs97MkzvSd4q2746Tgpg8b8XWiBGiBG4ZpgcAqBgtgPHTiAc8ZMCvZuikDw== + dependencies: + assert "^2.0.0" + browser-resolve "^2.0.0" + browserify-zlib "^0.2.0" + buffer "^5.7.1" + console-browserify "^1.1.0" + constants-browserify "^1.0.0" + create-require "^1.1.1" + crypto-browserify "^3.12.1" + domain-browser "4.22.0" + events "^3.0.0" + https-browserify "^1.0.0" + isomorphic-timers-promises "^1.0.1" + os-browserify "^0.3.0" + path-browserify "^1.0.1" + pkg-dir "^5.0.0" + process "^0.11.10" + punycode "^1.4.1" + querystring-es3 "^0.2.1" + readable-stream "^3.6.0" + stream-browserify "^3.0.0" + stream-http "^3.2.0" + string_decoder "^1.0.0" + timers-browserify "^2.0.4" + tty-browserify "0.0.1" + url "^0.11.4" + util "^0.12.4" + vm-browserify "^1.0.1" + nopt@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/nopt/-/nopt-5.0.0.tgz#530942bb58a512fccafe53fe210f13a25355dc88" @@ -6319,6 +7051,14 @@ object-inspect@^1.13.1, object-inspect@^1.13.3: resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.3.tgz#f14c183de51130243d6d18ae149375ff50ea488a" integrity sha512-kDCGIbxkDSXE3euJZZXzc6to7fCrKHNI/hSRQnRuQ+BWjFNzZwiFF8fj/6o2t2G9/jTj8PSIYTfCLelLZEeRpA== +object-is@^1.1.5: + version "1.1.6" + resolved "https://registry.yarnpkg.com/object-is/-/object-is-1.1.6.tgz#1a6a53aed2dd8f7e6775ff870bea58545956ab07" + integrity sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q== + dependencies: + call-bind "^1.0.7" + define-properties "^1.2.1" + object-keys@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e" @@ -6416,6 +7156,11 @@ optionator@^0.9.3: prelude-ls "^1.2.1" type-check "^0.4.0" +os-browserify@^0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/os-browserify/-/os-browserify-0.3.0.tgz#854373c7f5c2315914fc9bfc6bd8238fdda1ec27" + integrity sha512-gjcpUc3clBf9+210TRaDWbf+rZZZEshZ+DlXMRCeAjp0xhTrnQsKHypIy1J3d5hKdUzj69t708EHtU8P6bUn0A== + p-finally@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/p-finally/-/p-finally-1.0.0.tgz#3fbcfb15b899a44123b34b6dcc18b724336a2cae" @@ -6491,6 +7236,11 @@ package-json-from-dist@^1.0.0: resolved "https://registry.yarnpkg.com/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz#4f1471a010827a86f94cfd9b0727e36d267de505" integrity sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw== +pako@~1.0.5: + version "1.0.11" + resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.11.tgz#6c9599d340d54dfd3946380252a35705a6b992bf" + integrity sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw== + parent-module@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/parent-module/-/parent-module-1.0.1.tgz#691d2709e78c79fae3a156622452d00762caaaa2" @@ -6498,6 +7248,18 @@ parent-module@^1.0.0: dependencies: callsites "^3.0.0" +parse-asn1@^5.0.0, parse-asn1@^5.1.7: + version "5.1.7" + resolved "https://registry.yarnpkg.com/parse-asn1/-/parse-asn1-5.1.7.tgz#73cdaaa822125f9647165625eb45f8a051d2df06" + integrity sha512-CTM5kuWR3sx9IFamcl5ErfPl6ea/N8IYwiJ+vpeB2g+1iknv7zBl5uPwbMbRVznRVbrNY6lGuDoE5b30grmbqg== + dependencies: + asn1.js "^4.10.1" + browserify-aes "^1.2.0" + evp_bytestokey "^1.0.3" + hash-base "~3.0" + pbkdf2 "^3.1.2" + safe-buffer "^5.2.1" + parse-duration@^2.1.3: version "2.1.3" resolved "https://registry.yarnpkg.com/parse-duration/-/parse-duration-2.1.3.tgz#79a61a3ae224a5f4d1b71a8fa79e53d5aa90c902" @@ -6529,6 +7291,11 @@ parseurl@~1.3.3: resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4" integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ== +path-browserify@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/path-browserify/-/path-browserify-1.0.1.tgz#d98454a9c3753d5790860f16f68867b9e46be1fd" + integrity sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g== + path-exists@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3" @@ -6585,6 +7352,17 @@ pathval@^2.0.0: resolved "https://registry.yarnpkg.com/pathval/-/pathval-2.0.0.tgz#7e2550b422601d4f6b8e26f1301bc8f15a741a25" integrity sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA== +pbkdf2@^3.1.2: + version "3.1.2" + resolved "https://registry.yarnpkg.com/pbkdf2/-/pbkdf2-3.1.2.tgz#dd822aa0887580e52f1a039dc3eda108efae3075" + integrity sha512-iuh7L6jA7JEGu2WxDwtQP1ddOpaJNC4KlDEFfdQajSGgGPNi4OyDc2R7QnbY2bR9QjBVGwgvTdNJZoE7RaxUMA== + dependencies: + create-hash "^1.1.2" + create-hmac "^1.1.4" + ripemd160 "^2.0.1" + safe-buffer "^5.0.1" + sha.js "^2.4.8" + peberminta@^0.9.0: version "0.9.0" resolved "https://registry.yarnpkg.com/peberminta/-/peberminta-0.9.0.tgz#8ec9bc0eb84b7d368126e71ce9033501dca2a352" @@ -6627,6 +7405,13 @@ pkg-dir@^4.1.0: dependencies: find-up "^4.0.0" +pkg-dir@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-5.0.0.tgz#a02d6aebe6ba133a928f74aec20bafdfe6b8e760" + integrity sha512-NPE8TDbzl/3YQYY7CSS228s3g2ollTFnc+Qi3tqmqJp9Vg2ovUpixcJEo2HJScN2Ez+kEaal6y70c0ehqJBJeA== + dependencies: + find-up "^5.0.0" + pkginfo@^0.4.1: version "0.4.1" resolved "https://registry.yarnpkg.com/pkginfo/-/pkginfo-0.4.1.tgz#b5418ef0439de5425fc4995042dced14fb2a84ff" @@ -6637,6 +7422,20 @@ possible-typed-array-names@^1.0.0: resolved "https://registry.yarnpkg.com/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz#89bb63c6fada2c3e90adc4a647beeeb39cc7bf8f" integrity sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q== +postcss-value-parser@^4.0.2: + version "4.2.0" + resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514" + integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ== + +postcss@8.4.49: + version "8.4.49" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.49.tgz#4ea479048ab059ab3ae61d082190fabfd994fe19" + integrity sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA== + dependencies: + nanoid "^3.3.7" + picocolors "^1.1.1" + source-map-js "^1.2.1" + postcss@^8.3.11, postcss@^8.4.43, postcss@^8.5.3: version "8.5.3" resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.3.tgz#1463b6f1c7fb16fe258736cba29a2de35237eafb" @@ -6792,6 +7591,18 @@ psl@^1.1.28, psl@^1.1.33: resolved "https://registry.yarnpkg.com/psl/-/psl-1.9.0.tgz#d0df2a137f00794565fcaf3b2c00cd09f8d5a5a7" integrity sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag== +public-encrypt@^4.0.3: + version "4.0.3" + resolved "https://registry.yarnpkg.com/public-encrypt/-/public-encrypt-4.0.3.tgz#4fcc9d77a07e48ba7527e7cbe0de33d0701331e0" + integrity sha512-zVpa8oKZSz5bTMTFClc1fQOnyyEzpl5ozpi1B5YcvBrdohMjH2rfsBtyXcuNuwjsDIXmBYlF2N5FlJYhR29t8Q== + dependencies: + bn.js "^4.1.0" + browserify-rsa "^4.0.0" + create-hash "^1.1.0" + parse-asn1 "^5.0.0" + randombytes "^2.0.1" + safe-buffer "^5.1.2" + pump@^3.0.0: version "3.0.2" resolved "https://registry.yarnpkg.com/pump/-/pump-3.0.2.tgz#836f3edd6bc2ee599256c924ffe0d88573ddcbf8" @@ -6805,6 +7616,11 @@ punycode.js@^2.3.1: resolved "https://registry.yarnpkg.com/punycode.js/-/punycode.js-2.3.1.tgz#6b53e56ad75588234e79f4affa90972c7dd8cdb7" integrity sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA== +punycode@^1.4.1: + version "1.4.1" + resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.4.1.tgz#c0d5a63b2718800ad8e1eb0fa5269c84dd41845e" + integrity sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ== + punycode@^2.1.0, punycode@^2.1.1: version "2.3.1" resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5" @@ -6824,11 +7640,23 @@ qs@6.13.0: dependencies: side-channel "^1.0.6" +qs@^6.12.3: + version "6.14.0" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.14.0.tgz#c63fa40680d2c5c941412a0e899c89af60c0a930" + integrity sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w== + dependencies: + side-channel "^1.1.0" + qs@~6.5.2, qs@~6.5.3: version "6.5.3" resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.3.tgz#3aeeffc91967ef6e35c0e488ef46fb296ab76aad" integrity sha512-qxXIEh4pCGfHICj1mAJQ2/2XVZkjCDTcEgfoSQxc/fYivUZxTkk7L3bDBJSoNrEzXI17oUO5Dp07ktqE5KzczA== +querystring-es3@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/querystring-es3/-/querystring-es3-0.2.1.tgz#9ec61f79049875707d69414596fd907a4d711e73" + integrity sha512-773xhDQnZBMFobEiztv8LIl70ch5MSF/jUQVlhwFyBILqq96anmoctVIYz+ZRp0qbCKATTn6ev02M3r7Ga5vqA== + querystringify@^2.1.1: version "2.2.0" resolved "https://registry.yarnpkg.com/querystringify/-/querystringify-2.2.0.tgz#3345941b4153cb9d082d8eee4cda2016a9aef7f6" @@ -6875,13 +7703,21 @@ randexp@0.4.6: discontinuous-range "1.0.0" ret "~0.1.10" -randombytes@^2.1.0: +randombytes@^2.0.0, randombytes@^2.0.1, randombytes@^2.0.5, randombytes@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a" integrity sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ== dependencies: safe-buffer "^5.1.0" +randomfill@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/randomfill/-/randomfill-1.0.4.tgz#c92196fc86ab42be983f1bf31778224931d61458" + integrity sha512-87lcbR8+MhcWcUiQ+9e+Rwx8MyR2P7qnt15ynUlbm3TU/fjbgz4GsvfSUDTemtCCtVCqb4ZcEFlyPNTh9bBTLw== + dependencies: + randombytes "^2.0.5" + safe-buffer "^5.1.0" + range-parser@~1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031" @@ -6902,6 +7738,11 @@ react-is@^16.13.1: resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== +react-refresh@^0.17.0: + version "0.17.0" + resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.17.0.tgz#b7e579c3657f23d04eccbe4ad2e58a8ed51e7e53" + integrity sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ== + react-remove-scroll-bar@^2.3.6: version "2.3.6" resolved "https://registry.yarnpkg.com/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.6.tgz#3e585e9d163be84a010180b18721e851ac81a29c" @@ -6930,7 +7771,7 @@ react-style-singleton@^2.2.1: invariant "^2.2.4" tslib "^2.0.0" -readable-stream@^2.0.5: +readable-stream@^2.0.5, readable-stream@^2.3.8: version "2.3.8" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.8.tgz#91125e8042bba1b9887f49345f6277027ce8be9b" integrity sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA== @@ -7128,6 +7969,15 @@ resolve-from@^5.0.0: resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-5.0.0.tgz#c35225843df8f776df21c57557bc087e9dfdfc69" integrity sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw== +resolve@^1.17.0: + version "1.22.10" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.10.tgz#b663e83ffb09bbf2386944736baae803029b8b39" + integrity sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w== + dependencies: + is-core-module "^2.16.0" + path-parse "^1.0.7" + supports-preserve-symlinks-flag "^1.0.0" + resolve@^2.0.0-next.5: version "2.0.0-next.5" resolved "https://registry.yarnpkg.com/resolve/-/resolve-2.0.0-next.5.tgz#6b0ec3107e671e52b68cd068ef327173b90dc03c" @@ -7167,6 +8017,24 @@ rimraf@^3.0.0: dependencies: glob "^7.1.3" +ripemd160@^2.0.0, ripemd160@^2.0.1: + version "2.0.2" + resolved "https://registry.yarnpkg.com/ripemd160/-/ripemd160-2.0.2.tgz#a1c1a6f624751577ba5d07914cbc92850585890c" + integrity sha512-ii4iagi25WusVoiC4B4lq7pbXfAp3D9v5CwfkY33vffw2+pkDjY1D8GaN7spsxvCSx8dkPqOZCEZyfxcmJG2IA== + dependencies: + hash-base "^3.0.0" + inherits "^2.0.1" + +rollup-plugin-external-globals@^0.13.0: + version "0.13.0" + resolved "https://registry.yarnpkg.com/rollup-plugin-external-globals/-/rollup-plugin-external-globals-0.13.0.tgz#fb7aeff2704f09c43603b222fc53d3cda0eb0132" + integrity sha512-wBS3hmoF0OtEnA0lWsmTC6Nhnkk2zjZbfhaX2gLo8VnfNGFdGhiYKwMpIPQPrYbAw+mAYUYmoHYktAl1eZHgVw== + dependencies: + "@rollup/pluginutils" "^5.1.0" + estree-walker "^3.0.3" + is-reference "^3.0.2" + magic-string "^0.30.10" + rollup@^4.20.0, rollup@^4.34.9: version "4.40.2" resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.40.2.tgz#778e88b7a197542682b3e318581f7697f55f0619" @@ -7228,7 +8096,7 @@ safe-buffer@5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1: resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== -safe-buffer@5.2.1, safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.2, safe-buffer@~5.2.0: +safe-buffer@5.2.1, safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1, safe-buffer@^5.1.2, safe-buffer@^5.2.0, safe-buffer@^5.2.1, safe-buffer@~5.2.0: version "5.2.1" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== @@ -7251,6 +8119,15 @@ safe-regex-test@^1.0.3: es-errors "^1.3.0" is-regex "^1.1.4" +safe-regex-test@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/safe-regex-test/-/safe-regex-test-1.1.0.tgz#7f87dfb67a3150782eaaf18583ff5d1711ac10c1" + integrity sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw== + dependencies: + call-bound "^1.0.2" + es-errors "^1.3.0" + is-regex "^1.2.1" + safe-stable-stringify@^2.3.1: version "2.5.0" resolved "https://registry.yarnpkg.com/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz#4ca2f8e385f2831c432a719b108a3bf7af42a1dd" @@ -7390,7 +8267,7 @@ set-blocking@^2.0.0: resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7" integrity sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw== -set-function-length@^1.1.1, set-function-length@^1.2.1: +set-function-length@^1.1.1, set-function-length@^1.2.1, set-function-length@^1.2.2: version "1.2.2" resolved "https://registry.yarnpkg.com/set-function-length/-/set-function-length-1.2.2.tgz#aac72314198eaed975cf77b2c3b6b880695e5449" integrity sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg== @@ -7421,11 +8298,29 @@ set-function-name@^2.0.2: functions-have-names "^1.2.3" has-property-descriptors "^1.0.2" +setimmediate@^1.0.4: + version "1.0.5" + resolved "https://registry.yarnpkg.com/setimmediate/-/setimmediate-1.0.5.tgz#290cbb232e306942d7d7ea9b83732ab7856f8285" + integrity sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA== + setprototypeof@1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424" integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw== +sha.js@^2.4.0, sha.js@^2.4.8: + version "2.4.11" + resolved "https://registry.yarnpkg.com/sha.js/-/sha.js-2.4.11.tgz#37a5cf0b81ecbc6943de109ba2960d1b26584ae7" + integrity sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ== + dependencies: + inherits "^2.0.1" + safe-buffer "^5.0.1" + +shallowequal@1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/shallowequal/-/shallowequal-1.1.0.tgz#188d521de95b9087404fd4dcb68b13df0ae4e7f8" + integrity sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ== + shebang-command@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea" @@ -7438,6 +8333,35 @@ shebang-regex@^3.0.0: resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== +side-channel-list@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/side-channel-list/-/side-channel-list-1.0.0.tgz#10cb5984263115d3b7a0e336591e290a830af8ad" + integrity sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA== + dependencies: + es-errors "^1.3.0" + object-inspect "^1.13.3" + +side-channel-map@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/side-channel-map/-/side-channel-map-1.0.1.tgz#d6bb6b37902c6fef5174e5f533fab4c732a26f42" + integrity sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA== + dependencies: + call-bound "^1.0.2" + es-errors "^1.3.0" + get-intrinsic "^1.2.5" + object-inspect "^1.13.3" + +side-channel-weakmap@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz#11dda19d5368e40ce9ec2bdc1fb0ecbc0790ecea" + integrity sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A== + dependencies: + call-bound "^1.0.2" + es-errors "^1.3.0" + get-intrinsic "^1.2.5" + object-inspect "^1.13.3" + side-channel-map "^1.0.1" + side-channel@^1.0.4, side-channel@^1.0.6: version "1.0.6" resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.6.tgz#abd25fb7cd24baf45466406b1096b7831c9215f2" @@ -7448,6 +8372,17 @@ side-channel@^1.0.4, side-channel@^1.0.6: get-intrinsic "^1.2.4" object-inspect "^1.13.1" +side-channel@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.1.0.tgz#c3fcff9c4da932784873335ec9765fa94ff66bc9" + integrity sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw== + dependencies: + es-errors "^1.3.0" + object-inspect "^1.13.3" + side-channel-list "^1.0.0" + side-channel-map "^1.0.1" + side-channel-weakmap "^1.0.2" + siginfo@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/siginfo/-/siginfo-2.0.0.tgz#32e76c70b79724e3bb567cb9d543eb858ccfaf30" @@ -7608,6 +8543,24 @@ steno@^0.4.1: dependencies: graceful-fs "^4.1.3" +stream-browserify@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/stream-browserify/-/stream-browserify-3.0.0.tgz#22b0a2850cdf6503e73085da1fc7b7d0c2122f2f" + integrity sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA== + dependencies: + inherits "~2.0.4" + readable-stream "^3.5.0" + +stream-http@^3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/stream-http/-/stream-http-3.2.0.tgz#1872dfcf24cb15752677e40e5c3f9cc1926028b5" + integrity sha512-Oq1bLqisTyK3TSCXpPbT4sdeYNdmyZJv1LxpEm2vu1ZhK89kSE5YXwZc3cWk0MagGaKriBh9mCFbVGtO+vY29A== + dependencies: + builtin-status-codes "^3.0.0" + inherits "^2.0.4" + readable-stream "^3.6.0" + xtend "^4.0.2" + stream-length@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/stream-length/-/stream-length-1.0.2.tgz#8277f3cbee49a4daabcfdb4e2f4a9b5e9f2c9f00" @@ -7743,7 +8696,7 @@ string.prototype.trimstart@^1.0.8: define-properties "^1.2.1" es-object-atoms "^1.0.0" -string_decoder@^1.1.1, string_decoder@^1.3.0: +string_decoder@^1.0.0, string_decoder@^1.1.1, string_decoder@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e" integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA== @@ -7793,6 +8746,26 @@ style-mod@^4.0.0, style-mod@^4.1.0: resolved "https://registry.yarnpkg.com/style-mod/-/style-mod-4.1.2.tgz#ca238a1ad4786520f7515a8539d5a63691d7bf67" integrity sha512-wnD1HyVqpJUI2+eKZ+eo1UwghftP6yuFheBqqe+bWCotBjC2K1YnteJILRMs3SM4V/0dLEW1SC27MWP5y+mwmw== +styled-components@^6.1.18: + version "6.1.18" + resolved "https://registry.yarnpkg.com/styled-components/-/styled-components-6.1.18.tgz#9647497a92326ba9d758051c914f15004d524bb9" + integrity sha512-Mvf3gJFzZCkhjY2Y/Fx9z1m3dxbza0uI9H1CbNZm/jSHCojzJhQ0R7bByrlFJINnMzz/gPulpoFFGymNwrsMcw== + dependencies: + "@emotion/is-prop-valid" "1.2.2" + "@emotion/unitless" "0.8.1" + "@types/stylis" "4.2.5" + css-to-react-native "3.2.0" + csstype "3.1.3" + postcss "8.4.49" + shallowequal "1.1.0" + stylis "4.3.2" + tslib "2.6.2" + +stylis@4.3.2: + version "4.3.2" + resolved "https://registry.yarnpkg.com/stylis/-/stylis-4.3.2.tgz#8f76b70777dd53eb669c6f58c997bf0a9972e444" + integrity sha512-bhtUjWd/z6ltJiQwg0dUfxEJ+W+jdqQd8TbWLWyeIJHlnsqmGLRFFd8e5mA0AZi/zx90smXRlN66YMTcaSFifg== + supports-color@^7.1.0: version "7.2.0" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da" @@ -7920,6 +8893,13 @@ text-hex@1.0.x: resolved "https://registry.yarnpkg.com/text-hex/-/text-hex-1.0.0.tgz#69dc9c1b17446ee79a92bf5b884bb4b9127506f5" integrity sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg== +timers-browserify@^2.0.4: + version "2.0.12" + resolved "https://registry.yarnpkg.com/timers-browserify/-/timers-browserify-2.0.12.tgz#44a45c11fbf407f34f97bccd1577c652361b00ee" + integrity sha512-9phl76Cqm6FhSX9Xe1ZUAMLtm1BLkKj2Qd5ApyWkXzsMRaA7dgr81kf4wJmQf/hAvg8EEyJxDo3du/0KlhPiKQ== + dependencies: + setimmediate "^1.0.4" + tiny-typed-emitter@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/tiny-typed-emitter/-/tiny-typed-emitter-2.1.0.tgz#b3b027fdd389ff81a152c8e847ee2f5be9fad7b5" @@ -8017,11 +8997,21 @@ ts-xor@^1.3.0: resolved "https://registry.yarnpkg.com/ts-xor/-/ts-xor-1.3.0.tgz#3e59f24f0321f9f10f350e0cee3b534b89a2c70b" integrity sha512-RLXVjliCzc1gfKQFLRpfeD0rrWmjnSTgj7+RFhoq3KRkUYa8LE/TIidYOzM5h+IdFBDSjjSgk9Lto9sdMfDFEA== +tslib@2.6.2: + version "2.6.2" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.2.tgz#703ac29425e7b37cd6fd456e92404d46d1f3e4ae" + integrity sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q== + tslib@^2.0.0, tslib@^2.1.0: version "2.8.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== +tty-browserify@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/tty-browserify/-/tty-browserify-0.0.1.tgz#3f05251ee17904dfd0677546670db9651682b811" + integrity sha512-C3TaO7K81YvjCgQH9Q1S3R3P3BtN3RIM8n+OvX4il1K1zgE8ZhI0op7kClgkxtutIE8hQrcrHBXvIheqKUUCxw== + tunnel-agent@^0.6.0: version "0.6.0" resolved "https://registry.yarnpkg.com/tunnel-agent/-/tunnel-agent-0.6.0.tgz#27a5dea06b36b04a0a9966774b290868f0fc40fd" @@ -8267,6 +9257,14 @@ url-parse@^1.5.3: querystringify "^2.1.1" requires-port "^1.0.0" +url@^0.11.4: + version "0.11.4" + resolved "https://registry.yarnpkg.com/url/-/url-0.11.4.tgz#adca77b3562d56b72746e76b330b7f27b6721f3c" + integrity sha512-oCwdVC7mTuWiPyjLUz/COz5TLk6wgp0RCsN+wHZ2Ekneac9w8uuV0njcbbie2ME+Vs+d6duwmYuR3HgQXs1fOg== + dependencies: + punycode "^1.4.1" + qs "^6.12.3" + use-callback-ref@^1.3.0: version "1.3.2" resolved "https://registry.yarnpkg.com/use-callback-ref/-/use-callback-ref-1.3.2.tgz#6134c7f6ff76e2be0b56c809b17a650c942b1693" @@ -8287,6 +9285,17 @@ util-deprecate@^1.0.1, util-deprecate@~1.0.1: resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== +util@^0.12.4, util@^0.12.5: + version "0.12.5" + resolved "https://registry.yarnpkg.com/util/-/util-0.12.5.tgz#5f17a6059b73db61a875668781a1c2b136bd6fbc" + integrity sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA== + dependencies: + inherits "^2.0.3" + is-arguments "^1.0.4" + is-generator-function "^1.0.7" + is-typed-array "^1.1.3" + which-typed-array "^1.1.2" + utils-merge@1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" @@ -8353,6 +9362,14 @@ vite-plugin-magical-svg@^1.1.1: svgo "^3.3.2" xml2js "^0.6.2" +vite-plugin-node-polyfills@^0.23.0: + version "0.23.0" + resolved "https://registry.yarnpkg.com/vite-plugin-node-polyfills/-/vite-plugin-node-polyfills-0.23.0.tgz#99d0d1524fa75ce5c7bb1fc8af30283379e9c684" + integrity sha512-4n+Ys+2bKHQohPBKigFlndwWQ5fFKwaGY6muNDMTb0fSQLyBzS+jjUNRZG9sKF0S/Go4ApG6LFnUGopjkILg3w== + dependencies: + "@rollup/plugin-inject" "^5.0.5" + node-stdlib-browser "^1.2.0" + "vite@^5.0.0 || ^6.0.0": version "6.3.5" resolved "https://registry.yarnpkg.com/vite/-/vite-6.3.5.tgz#fec73879013c9c0128c8d284504c6d19410d12a3" @@ -8405,6 +9422,11 @@ vitest@^3.1.3: vite-node "3.1.3" why-is-node-running "^2.3.0" +vm-browserify@^1.0.1: + version "1.1.2" + resolved "https://registry.yarnpkg.com/vm-browserify/-/vm-browserify-1.1.2.tgz#78641c488b8e6ca91a75f511e7a3b32a86e5dda0" + integrity sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ== + w3c-keyname@^2.2.4: version "2.2.8" resolved "https://registry.yarnpkg.com/w3c-keyname/-/w3c-keyname-2.2.8.tgz#7b17c8c6883d4e8b86ac8aba79d39e880f8869c5" @@ -8505,6 +9527,19 @@ which-typed-array@^1.1.14, which-typed-array@^1.1.15: gopd "^1.0.1" has-tostringtag "^1.0.2" +which-typed-array@^1.1.16, which-typed-array@^1.1.2: + version "1.1.19" + resolved "https://registry.yarnpkg.com/which-typed-array/-/which-typed-array-1.1.19.tgz#df03842e870b6b88e117524a4b364b6fc689f956" + integrity sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw== + dependencies: + available-typed-arrays "^1.0.7" + call-bind "^1.0.8" + call-bound "^1.0.4" + for-each "^0.3.5" + get-proto "^1.0.1" + gopd "^1.2.0" + has-tostringtag "^1.0.2" + which@^2.0.1: version "2.0.2" resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1" @@ -8651,7 +9686,7 @@ xmlbuilder@~11.0.0: resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-11.0.1.tgz#be9bae1c8a046e76b31127726347d0ad7002beb3" integrity sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA== -xtend@^4.0.0: +xtend@^4.0.0, xtend@^4.0.2: version "4.0.2" resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54" integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ== From 1d641e59072d0cbb31ed84cd226fa283e92cbebe Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Thu, 29 May 2025 10:58:53 +0100 Subject: [PATCH 22/22] fix comparison --- src/Connections/OpenProjectConnection.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/Connections/OpenProjectConnection.ts b/src/Connections/OpenProjectConnection.ts index 70299cd8e..5d6c0dcec 100644 --- a/src/Connections/OpenProjectConnection.ts +++ b/src/Connections/OpenProjectConnection.ts @@ -397,10 +397,6 @@ export class OpenProjectConnection log.info( `onWorkPackageUpdated ${this.roomId} ${this.projectId} ${data.work_package.id}`, ); - await this.storage.setOpenProjectWorkPackageState( - workPackageToCacheState(data.work_package), - data.work_package.id, - ); const creator = data.work_package._embedded.author; if (!creator) { @@ -415,6 +411,10 @@ export class OpenProjectConnection data.work_package._embedded.project.id, data.work_package.id, ); + await this.storage.setOpenProjectWorkPackageState( + workPackageToCacheState(data.work_package), + data.work_package.id, + ); // Detect what changed. let changeStatement = "updated work package"; @@ -435,9 +435,11 @@ export class OpenProjectConnection return; } } + if (!this.isInterestedInHookEvent(hookEvent ?? "work_package:updated")) { return; } + const content = `**${creator.name}** ${changeStatement} for [${data.work_package.id}](${extraData["org.matrix.matrix-hookshot.openproject.work_package"].url}): "${data.work_package.subject}"`; await this.intent.sendEvent(this.roomId, {