├── .dockerignore ├── .editorconfig ├── .github ├── ISSUE_TEMPLATE │ ├── config.yml │ └── issue.md └── workflows │ └── go.yaml ├── .gitignore ├── .pre-commit-config.yaml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── api ├── beeperapi │ ├── login.go │ └── whoami.go ├── gitlab │ ├── build.go │ └── graphql.go └── hungryapi │ └── appservice.go ├── bridgeconfig ├── bluesky.tpl.yaml ├── bridgeconfig.go ├── bridgev2.tpl.yaml ├── discord.tpl.yaml ├── gmessages.tpl.yaml ├── googlechat.tpl.yaml ├── gvoice.tpl.yaml ├── heisenbridge.tpl.yaml ├── imessage.tpl.yaml ├── imessagego.tpl.yaml ├── linkedin.tpl.yaml ├── meta.tpl.yaml ├── signal.tpl.yaml ├── slack.tpl.yaml ├── telegram.tpl.yaml ├── twitter.tpl.yaml └── whatsapp.tpl.yaml ├── build.sh ├── ci-build-all.sh ├── cli ├── hyper │ └── link.go └── interactive │ └── flag.go ├── cmd └── bbctl │ ├── authconfig.go │ ├── bridgeutil.go │ ├── config.go │ ├── context.go │ ├── delete.go │ ├── login-email.go │ ├── login-password.go │ ├── logout.go │ ├── main.go │ ├── proxy.go │ ├── register.go │ ├── run.go │ └── whoami.go ├── docker ├── Dockerfile ├── README.md └── run-bridge.sh ├── go.mod ├── go.sum ├── log └── log.go └── run.sh /.dockerignore: -------------------------------------------------------------------------------- 1 | docker/Dockerfile 2 | docker/README.md 3 | ci-build-all.sh 4 | run.sh 5 | LICENSE 6 | README.md 7 | .editorconfig 8 | .pre-commit-config.yaml 9 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | indent_size = 4 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.yaml] 12 | indent_style = space 13 | 14 | [{.pre-commit-config.yaml,.github/workflows/*.yaml}] 15 | indent_size = 2 16 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/issue.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Issue 3 | about: Submit a bug report or feature request related to bbctl. 4 | 5 | --- 6 | 7 | 22 | -------------------------------------------------------------------------------- /.github/workflows/go.yaml: -------------------------------------------------------------------------------- 1 | name: Go 2 | 3 | on: [push, pull_request] 4 | 5 | env: 6 | GO_VERSION: "1.24" 7 | GHCR_REGISTRY: ghcr.io 8 | GHCR_REGISTRY_IMAGE: "ghcr.io/${{ github.repository }}" 9 | GOTOOLCHAIN: local 10 | 11 | jobs: 12 | lint: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | 17 | - name: Set up Go ${{ env.GO_VERSION }} 18 | uses: actions/setup-go@v5 19 | with: 20 | go-version: ${{ env.GO_VERSION }} 21 | cache: true 22 | 23 | - name: Install dependencies 24 | run: | 25 | go install golang.org/x/tools/cmd/goimports@latest 26 | go install honnef.co/go/tools/cmd/staticcheck@latest 27 | export PATH="$HOME/go/bin:$PATH" 28 | 29 | - name: Run pre-commit 30 | uses: pre-commit/action@v3.0.1 31 | 32 | build: 33 | runs-on: ubuntu-latest 34 | env: 35 | CGO_ENABLED: "0" 36 | steps: 37 | - uses: actions/checkout@v4 38 | 39 | - name: Set up Go ${{ env.GO_VERSION }} 40 | uses: actions/setup-go@v5 41 | with: 42 | go-version: ${{ env.GO_VERSION }} 43 | cache: true 44 | 45 | - name: Build binaries 46 | run: ./ci-build-all.sh 47 | 48 | - name: Upload linux/amd64 artifact 49 | uses: actions/upload-artifact@v4 50 | with: 51 | name: bbctl-linux-amd64 52 | path: bbctl-linux-amd64 53 | if-no-files-found: error 54 | 55 | - name: Upload linux/arm64 artifact 56 | uses: actions/upload-artifact@v4 57 | with: 58 | name: bbctl-linux-arm64 59 | path: bbctl-linux-arm64 60 | if-no-files-found: error 61 | 62 | - name: Upload macos/amd64 artifact 63 | uses: actions/upload-artifact@v4 64 | with: 65 | name: bbctl-macos-amd64 66 | path: bbctl-macos-amd64 67 | if-no-files-found: error 68 | 69 | - name: Upload macos/arm64 artifact 70 | uses: actions/upload-artifact@v4 71 | with: 72 | name: bbctl-macos-arm64 73 | path: bbctl-macos-arm64 74 | if-no-files-found: error 75 | 76 | build-docker: 77 | runs-on: ${{ matrix.runs-on }} 78 | strategy: 79 | matrix: 80 | include: 81 | - runs-on: ubuntu-latest 82 | target: amd64 83 | - runs-on: ubuntu-arm64 84 | target: arm64 85 | name: build-docker (${{ matrix.target }}) 86 | steps: 87 | - name: Set up Docker Buildx 88 | uses: docker/setup-buildx-action@v3 89 | 90 | - name: Login to registry 91 | uses: docker/login-action@v3 92 | with: 93 | registry: ${{ env.GHCR_REGISTRY }} 94 | username: ${{ github.actor }} 95 | password: ${{ secrets.GITHUB_TOKEN }} 96 | 97 | - name: Docker Build 98 | uses: docker/build-push-action@v5 99 | with: 100 | cache-from: ${{ env.GHCR_REGISTRY_IMAGE }}:latest 101 | pull: true 102 | file: docker/Dockerfile 103 | tags: ${{ env.GHCR_REGISTRY_IMAGE }}:${{ github.sha }}-${{ matrix.target }} 104 | push: true 105 | build-args: | 106 | COMMIT_HASH=${{ github.sha }} 107 | # These will apparently disable making a manifest 108 | provenance: false 109 | sbom: false 110 | 111 | deploy-docker: 112 | runs-on: ubuntu-latest 113 | needs: 114 | - build-docker 115 | steps: 116 | - name: Login to registry 117 | uses: docker/login-action@v3 118 | with: 119 | registry: ${{ env.GHCR_REGISTRY }} 120 | username: ${{ github.actor }} 121 | password: ${{ secrets.GITHUB_TOKEN }} 122 | 123 | - name: Create commit manifest 124 | run: | 125 | docker pull ${{ env.GHCR_REGISTRY_IMAGE }}:${{ github.sha }}-amd64 126 | docker pull ${{ env.GHCR_REGISTRY_IMAGE }}:${{ github.sha }}-arm64 127 | docker manifest create ${{ env.GHCR_REGISTRY_IMAGE }}:${{ github.sha }} ${{ env.GHCR_REGISTRY_IMAGE }}:${{ github.sha }}-amd64 ${{ env.GHCR_REGISTRY_IMAGE }}:${{ github.sha }}-arm64 128 | docker manifest push ${{ env.GHCR_REGISTRY_IMAGE }}:${{ github.sha }} 129 | 130 | - name: Create :latest manifest 131 | if: github.ref == 'refs/heads/main' 132 | run: | 133 | docker manifest create ${{ env.GHCR_REGISTRY_IMAGE }}:latest ${{ env.GHCR_REGISTRY_IMAGE }}:${{ github.sha }}-amd64 ${{ env.GHCR_REGISTRY_IMAGE }}:${{ github.sha }}-arm64 134 | docker manifest push ${{ env.GHCR_REGISTRY_IMAGE }}:latest 135 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /bbctl 2 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v5.0.0 4 | hooks: 5 | - id: trailing-whitespace 6 | exclude_types: [markdown] 7 | - id: end-of-file-fixer 8 | - id: check-yaml 9 | exclude: ^.+\.tpl\.yaml$ 10 | - id: check-added-large-files 11 | 12 | - repo: https://github.com/tekwizely/pre-commit-golang 13 | rev: v1.0.0-rc.1 14 | hooks: 15 | - id: go-imports-repo 16 | args: 17 | - "-local" 18 | - "github.com/beeper/bridge-manager" 19 | - "-w" 20 | - id: go-vet-repo-mod 21 | - id: go-staticcheck-repo-mod 22 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # v0.13.0 (2024-12-15) 2 | 3 | * Added support for Bluesky DM bridge. 4 | * Updated WhatsApp and Twitter bridge configs to v2. 5 | * Switched Python bridges to be installed from PyPI instead of GitHub. 6 | 7 | # v0.12.2 (2024-08-26) 8 | 9 | * Added support for Google Voice bridge. 10 | * Fixed running Meta bridge without specifying platform. 11 | 12 | # v0.12.1 (2024-08-17) 13 | 14 | * Bumped minimum Go version to 1.22. 15 | * Removed separate v2 versions of Signal and Slack. The normal bridges default to v2 now. 16 | * Switched Google Messages and Meta to v2. 17 | 18 | # v0.12.0 (2024-07-12) 19 | 20 | * Added support for generating generic bridgev2/megabridge configs. 21 | * Added support for signalv2 and slackv2. 22 | * Updated hungryserv URL template to work with megahungry. 23 | 24 | # v0.11.0 (2024-04-17) 25 | 26 | * Fixed mautrix-imessage media viewer config. 27 | * Updated main branch name for mautrix-whatsapp. 28 | * Updated Meta config to allow choosing messenger and facebook-tor modes. 29 | * Dropped support for legacy Facebook and Instagram bridges. 30 | * Removed "Work in progress" warning from iMessage BlueBubbles connector. 31 | 32 | # v0.10.1 (2024-02-28) 33 | 34 | * Bumped minimum Go version to 1.21. 35 | * Updated Meta and Signal bridge configs. 36 | 37 | # v0.10.0 (2024-02-17) 38 | 39 | * Added option to configure the device name that bridges expose to the remote 40 | network using `--param device_name="..."` 41 | * Added support for new Meta bridge (Instagram/Facebook). 42 | * Added support for the new BlueBubbles connector on the old iMessage bridge. 43 | * Enabled Matrix spaces by default in all bridges that support them. 44 | * Changed all bridge configs to set room name/avatar explicitly in DM rooms. 45 | * Fixed quoting issue in Signal bridge config template. 46 | 47 | # v0.9.1 (2023-12-21) 48 | 49 | * Added support for new iMessage bridge. 50 | * Fixed `bbctl run`ning bridges with websocket proxy on macOS. 51 | * Updated bridge downloader to pull from main mautrix/signal repo instead of 52 | the signalgo fork. 53 | 54 | # v0.9.0 (2023-12-15) 55 | 56 | * Added support for the LinkedIn bridge. 57 | * Added `--compile` flag to `bbctl run` for automatically cloning the bridge 58 | repo and compiling it locally. 59 | * This is meant for architectures which the CI does not build binaries for, 60 | `--local-dev` is better for actually modifying the bridge code. 61 | * Marked `darwin/amd64` as unsupported for downloading bridge CI binaries. 62 | * Fixed downloading Signal bridge binaries from CI. 63 | * Fixed CI binary downloading not checking HTTP status code and trying to 64 | execute HTML error pages instead. 65 | 66 | # v0.8.0 (2023-11-03) 67 | 68 | * Added `--local-dev` flag to `bbctl run` for running a local git cloned bridge, 69 | instead of downloading a CI binary or using pip install. 70 | * Added config template for the new Signal bridge written in Go. 71 | * Switched bridges to use `as_token` double puppeting (the new method mentioned 72 | in [the docs](https://docs.mau.fi/bridges/general/double-puppeting.html#appservice-method-new)). 73 | * Fixed bugs in Slack and Google Messages config templates. 74 | 75 | # v0.7.1 (2023-08-26) 76 | 77 | * Updated to use new hungryserv URL field in whoami response. 78 | * Stopped using `setpgid` when running bridges on macOS as it causes weird issues. 79 | * Changed docker image to create `DATA_DIR` if it doesn't exist instead of failing. 80 | 81 | # v0.7.0 (2023-08-20) 82 | 83 | * Added support for running official Python bridges (`telegram`, `facebook`, 84 | `instagram`, `googlechat`, `twitter`) and the remaining Go bridge (`slack`). 85 | * The legacy Signal bridge will not be supported as it requires signald as an 86 | external component. Once the Go rewrite is ready, a config template will be 87 | added for it. 88 | * Added `bbctl proxy` command for connecting to the appservice transaction 89 | websocket and proxying all transactions to a local HTTP server. This enables 90 | using any 3rd party bridge in websocket mode (removing the need for 91 | port-forwarding). 92 | * Added [experimental Docker image] for wrapping `bbctl run`. 93 | * Updated minimum Go version to 1.20 when compiling bbctl from source. 94 | 95 | [experimental Docker image]: https://github.com/beeper/bridge-manager/tree/main/docker 96 | 97 | # v0.6.1 (2023-08-06) 98 | 99 | * Added config option to store bridge databases in custom directory. 100 | * Fixed running official Go bridges on macOS when libolm isn't installed 101 | system-wide. 102 | * Fixed 30 second timeout when downloading bridge binaries. 103 | * Fixed creating config directory if it doesn't exist. 104 | * Changed default config path from `~/.config/bbctl.json` 105 | to `~/.config/bbctl/config.json`. 106 | * Existing configs should be moved automatically on startup. 107 | 108 | # v0.6.0 (2023-08-01) 109 | 110 | * Added support for fully managed installation of supported official bridges 111 | using `bbctl run`. 112 | * Moved `register` and `delete` commands to top level `bbctl` instead of being 113 | nested inside `bbctl bridge`. 114 | * Merged `bbctl get` into `bbctl register --get` 115 | 116 | # v0.5.0 (2023-07-24) 117 | 118 | * Added bridge config template for Google Messages. 119 | * Added bridge type in bridge state info when setting up bridges with config 120 | templates. 121 | * This is preparation for integrating self-hosted official bridges into the 122 | Beeper apps, like login via the Chat Networks dialog and Start New Chat 123 | functionality. 124 | * Fixed typo in WhatsApp config template. 125 | * Updated config templates to enable websocket pinging so the websockets would 126 | stay alive. 127 | * Moved `isSelfHosted` flag to top-level bridge state info. 128 | 129 | # v0.4.0 (2023-07-04) 130 | 131 | * Added email login support. 132 | * Added link to bridge installation instructions after generating config file. 133 | * Fixed WhatsApp and Discord bridge config templates. 134 | 135 | # v0.3.1 (2023-06-27) 136 | 137 | * Fixed logging in, which broke in v0.3.0 138 | 139 | # v0.3.0 (2023-06-22) 140 | 141 | * Fixed hungryserv address being incorrect for users on new bridge cluster. 142 | * Added support for generating configs for the Discord bridge. 143 | * Added option to pass config generation parameters as CLI flags 144 | (like `imessage_platform` and `barcelona_path`). 145 | 146 | # v0.2.0 (2023-05-28) 147 | 148 | * Added experimental support for generating configs for official Beeper bridges. 149 | WhatsApp, iMessage and Heisenbridge are currently supported, more to come in 150 | the future. 151 | * Changed register commands to recommend starting bridge names with `sh-` prefix. 152 | 153 | # v0.1.1 (2023-02-07) 154 | 155 | * Fixed registering bridges in websocket mode. 156 | * Fixed validating bridge names client-side to have a prettier error message. 157 | 158 | # v0.1.0 (2023-02-06) 159 | 160 | Initial release 161 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Beeper Bridge Manager 2 | A tool for running self-hosted bridges with the Beeper Matrix server. 3 | 4 | The primary use case is running custom/3rd-party bridges with Beeper. You can 5 | connect any spec-compliant Matrix application service to your Beeper 6 | account without having to self-host a whole Matrix homeserver. Note that if you 7 | run 3rd party bridges that don't support end-to-bridge encryption, message 8 | contents will be visible to Beeper servers. 9 | 10 | †caveat: hungryserv does not implement the entire Matrix client-server API, so 11 | it's possible some bridges won't work - you can report such cases in the 12 | self-hosting support room linked below or in GitHub issues here 13 | 14 | You can also self-host the official bridges for maximum security using this 15 | tool (so that message re-encryption happens on a machine you control rather 16 | than on Beeper servers). 17 | 18 | This tool can not be used with any other Matrix homeserver, like self-hosted 19 | Synapse instances. It is only for connecting self-hosted bridges to the 20 | beeper.com server. For self-hosting the entire stack, refer to the official 21 | documentation of the various projects 22 | ([Synapse](https://element-hq.github.io/synapse/latest/), 23 | [mautrix bridges](https://docs.mau.fi/bridges/)). 24 | 25 | > [!NOTE] 26 | > Self-hosted bridges are not entitled to the usual level of customer support 27 | > on Beeper. If you need help with self-hosting bridges using this tool, please 28 | > join [#self-hosting:beeper.com] instead of asking in your support room. 29 | 30 | [#self-hosting:beeper.com]: https://matrix.to/#/#self-hosting:beeper.com 31 | 32 | ## Usage 33 | 1. Download the latest binary from [GitHub releases](https://github.com/beeper/bridge-manager/releases) 34 | or [actions](https://nightly.link/beeper/bridge-manager/workflows/go.yaml/main). 35 | * Alternatively, you can build it yourself by cloning the repo and running 36 | `./build.sh`. Building requires Go 1.23 or higher. 37 | * bbctl supports amd64 and arm64 on Linux and macOS. 38 | Windows is not supported natively, please use WSL. 39 | 2. Log into your Beeper account with `bbctl login`. 40 | 41 | Then continue with one of the sections below, depending on whether you want to 42 | run an official Beeper bridge or a 3rd party bridge. 43 | 44 | ### Official bridges 45 | For Python bridges, you must install Python 3 with the `venv` module with your 46 | OS package manager. For example, `sudo apt install python3 python3-venv` on 47 | Debian-based distros. The Python version built into macOS may be new enough, or 48 | you can get the latest version via brew. The minimum Python version varies by 49 | bridge, but if you use the latest Debian or Ubuntu LTS, it should be new enough. 50 | 51 | Some bridges require ffmpeg for converting media (e.g. when sending gifs), so 52 | you should also install that with your OS package manager (`sudo apt install ffmpeg` 53 | on Debian or `brew install ffmpeg` on macOS). 54 | 55 | After installing relevant dependencies: 56 | 57 | 3. Run `bbctl run ` to run the bridge. 58 | * `` should start with `sh-` and consist of a-z, 0-9 and -. 59 | * If `` contains the bridge type, it will be automatically detected. 60 | Otherwise pass the type with `--type `. 61 | * See the table below for supported official bridges. 62 | * The bridge will be installed to `~/.local/share/bbctl`. You can change the 63 | directory in the config file at `~/.config/bbctl.json`. 64 | 4. For now, you'll have to configure the bridge by sending a DM to the bridge 65 | bot (`@bot:beeper.local`). Configuring self-hosted bridges through the 66 | chat networks dialog will be available in the future. Spaces and starting 67 | chats are also not yet available, although you can start chats using the 68 | `pm` command with the bridge bot. 69 | 70 | There is currently a bug in Beeper Desktop that causes it to create encrypted 71 | DMs even if the recipient doesn't support it. This means that for non-e2ee- 72 | capable bridges like Heisenbridge, you'll have to create the DM with the bridge 73 | bot in another Matrix client, or using the create group chat button in Beeper 74 | Desktop. 75 | 76 | Currently the bridge will run in foreground, so you'll have to keep `bbctl run` 77 | active somewhere (tmux is a good option). In the future, a service mode will be 78 | added where the bridge is registered as a systemd or launchd service to be 79 | started automatically by the OS. 80 | 81 | #### Official bridge list 82 | When using `bbctl run` or `bbctl config` and the provided `` contains one 83 | of the identifiers (second column) listed below, bbctl will automatically guess 84 | that type. A substring match is sufficient, e.g. `sh-mywhatsappbridge` will 85 | match `whatsapp`. The first listed identifier is the "primary" one that can be 86 | used with the `--type` flag. 87 | 88 | | Bridge | Identifier | 89 | |----------------------|--------------------------------------| 90 | | [mautrix-telegram] | telegram | 91 | | [mautrix-whatsapp] | whatsapp | 92 | | [mautrix-signal] | signal | 93 | | [mautrix-discord] | discord | 94 | | [mautrix-slack] | slack | 95 | | [mautrix-gmessages] | gmessages, googlemessages, rcs, sms | 96 | | [mautrix-gvoice] | gvoice, googlevoice | 97 | | [mautrix-meta] | meta, instagram, facebook | 98 | | [mautrix-googlechat] | googlechat, gchat | 99 | | [mautrix-twitter] | twitter | 100 | | [mautrix-bluesky] | bluesky, bsky | 101 | | [mautrix-imessage] | imessage | 102 | | [beeper-imessage] | imessagego | 103 | | [mautrix-linkedin] | linkedin | 104 | | [heisenbridge] | heisenbridge, irc | 105 | 106 | [mautrix-telegram]: https://github.com/mautrix/telegram 107 | [mautrix-whatsapp]: https://github.com/mautrix/whatsapp 108 | [mautrix-signal]: https://github.com/mautrix/signal 109 | [mautrix-discord]: https://github.com/mautrix/discord 110 | [mautrix-slack]: https://github.com/mautrix/slack 111 | [mautrix-gmessages]: https://github.com/mautrix/gmessages 112 | [mautrix-gvoice]: https://github.com/mautrix/gvoice 113 | [mautrix-meta]: https://github.com/mautrix/meta 114 | [mautrix-googlechat]: https://github.com/mautrix/googlechat 115 | [mautrix-twitter]: https://github.com/mautrix/twitter 116 | [mautrix-bluesky]: https://github.com/mautrix/bluesky 117 | [mautrix-imessage]: https://github.com/mautrix/imessage 118 | [beeper-imessage]: https://github.com/beeper/imessage 119 | [mautrix-linkedin]: https://github.com/mautrix/linkedin 120 | [heisenbridge]: https://github.com/hifi/heisenbridge 121 | 122 | ### 3rd party bridges 123 | 3. Run `bbctl register ` to generate an appservice registration file. 124 | * `` is a short name for the bridge (a-z, 0-9, -). The name should 125 | start with `sh-`. The bridge user ID namespace will be `@_.+:beeper.local` 126 | and the bridge bot will be `@bot:beeper.local`. 127 | 4. Now you can configure and run the bridge by following the bridge's own 128 | documentation. 129 | 5. Modify the registration file to point at where the bridge will listen locally 130 | (e.g. `url: http://localhost:8080`), then run `bbctl proxy -r registration.yaml` 131 | to start the proxy. 132 | * The proxy will connect to the Beeper server using a websocket and push 133 | received events to the bridge via HTTP. Since the HTTP requests are all on 134 | localhost, you don't need port forwarding or TLS certificates. 135 | 136 | Note that the homeserver URL is not guaranteed to be stable forever, it has 137 | changed in the past, and it may change again in the future. 138 | 139 | You can use `--json` with `register` to get the whole response as JSON instead 140 | of registration YAML and pretty-printed extra details. This may be useful if 141 | you want to automate fetching the homeserver URL. 142 | 143 | ### Deleting bridges 144 | If you don't want a self-hosted bridge anymore, you can delete it using 145 | `bbctl delete `. Deleting a bridge will permanently erase all traces of 146 | it from the Beeper servers (e.g. any rooms and ghost users it created). 147 | For official bridges, it will also delete the local data directory with the 148 | bridge config, database and python virtualenv (if applicable). 149 | -------------------------------------------------------------------------------- /api/beeperapi/login.go: -------------------------------------------------------------------------------- 1 | package beeperapi 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "time" 9 | ) 10 | 11 | type RespStartLogin struct { 12 | RequestID string `json:"request"` 13 | Type []string `json:"type"` 14 | Expires time.Time `json:"expires"` 15 | } 16 | 17 | type ReqSendLoginEmail struct { 18 | RequestID string `json:"request"` 19 | Email string `json:"email"` 20 | } 21 | 22 | type ReqSendLoginCode struct { 23 | RequestID string `json:"request"` 24 | Code string `json:"response"` 25 | } 26 | 27 | type RespSendLoginCode struct { 28 | LoginToken string `json:"token"` 29 | Whoami *RespWhoami `json:"whoami"` 30 | } 31 | 32 | var ErrInvalidLoginCode = fmt.Errorf("invalid login code") 33 | 34 | const loginAuth = "BEEPER-PRIVATE-API-PLEASE-DONT-USE" 35 | 36 | func StartLogin(baseDomain string) (resp *RespStartLogin, err error) { 37 | req := newRequest(baseDomain, loginAuth, http.MethodPost, "/user/login") 38 | req.Body = io.NopCloser(bytes.NewReader([]byte("{}"))) 39 | err = doRequest(req, nil, &resp) 40 | return 41 | } 42 | 43 | func SendLoginEmail(baseDomain, request, email string) error { 44 | req := newRequest(baseDomain, loginAuth, http.MethodPost, "/user/login/email") 45 | reqData := &ReqSendLoginEmail{ 46 | RequestID: request, 47 | Email: email, 48 | } 49 | return doRequest(req, reqData, nil) 50 | } 51 | 52 | func SendLoginCode(baseDomain, request, code string) (resp *RespSendLoginCode, err error) { 53 | req := newRequest(baseDomain, loginAuth, http.MethodPost, "/user/login/response") 54 | reqData := &ReqSendLoginCode{ 55 | RequestID: request, 56 | Code: code, 57 | } 58 | err = doRequest(req, reqData, &resp) 59 | return 60 | } 61 | -------------------------------------------------------------------------------- /api/beeperapi/whoami.go: -------------------------------------------------------------------------------- 1 | package beeperapi 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | "net/url" 10 | "time" 11 | 12 | "maunium.net/go/mautrix" 13 | "maunium.net/go/mautrix/bridge/status" 14 | "maunium.net/go/mautrix/id" 15 | ) 16 | 17 | type BridgeState struct { 18 | Username string `json:"username"` 19 | Bridge string `json:"bridge"` 20 | StateEvent status.BridgeStateEvent `json:"stateEvent"` 21 | Source string `json:"source"` 22 | CreatedAt time.Time `json:"createdAt"` 23 | Reason string `json:"reason"` 24 | Info map[string]any `json:"info"` 25 | IsSelfHosted bool `json:"isSelfHosted"` 26 | BridgeType string `json:"bridgeType"` 27 | } 28 | 29 | type WhoamiBridge struct { 30 | Version string `json:"version"` 31 | ConfigHash string `json:"configHash"` 32 | OtherVersions []struct { 33 | Name string `json:"name"` 34 | Version string `json:"version"` 35 | } `json:"otherVersions"` 36 | BridgeState BridgeState `json:"bridgeState"` 37 | RemoteState map[string]status.BridgeState `json:"remoteState"` 38 | } 39 | 40 | type WhoamiAsmuxData struct { 41 | LoginToken string `json:"login_token"` 42 | } 43 | 44 | type WhoamiUser struct { 45 | Bridges map[string]WhoamiBridge `json:"bridges"` 46 | Hungryserv WhoamiBridge `json:"hungryserv"` 47 | AsmuxData WhoamiAsmuxData `json:"asmuxData"` 48 | } 49 | 50 | type WhoamiUserInfo struct { 51 | CreatedAt time.Time `json:"createdAt"` 52 | Username string `json:"username"` 53 | Email string `json:"email"` 54 | FullName string `json:"fullName"` 55 | Channel string `json:"channel"` 56 | Admin bool `json:"isAdmin"` 57 | BridgeChangesLocked bool `json:"isUserBridgeChangesLocked"` 58 | Free bool `json:"isFree"` 59 | DeletedAt time.Time `json:"deletedAt"` 60 | SupportRoomID id.RoomID `json:"supportRoomId"` 61 | UseHungryserv bool `json:"useHungryserv"` 62 | BridgeClusterID string `json:"bridgeClusterId"` 63 | AnalyticsID string `json:"analyticsId"` 64 | FakeHungryURL string `json:"hungryUrl"` 65 | HungryURL string `json:"hungryUrlDirect"` 66 | } 67 | 68 | type RespWhoami struct { 69 | User WhoamiUser `json:"user"` 70 | UserInfo WhoamiUserInfo `json:"userInfo"` 71 | } 72 | 73 | var cli = &http.Client{Timeout: 30 * time.Second} 74 | 75 | func newRequest(baseDomain, token, method, path string) *http.Request { 76 | req := &http.Request{ 77 | URL: &url.URL{ 78 | Scheme: "https", 79 | Host: fmt.Sprintf("api.%s", baseDomain), 80 | Path: path, 81 | }, 82 | Method: method, 83 | Header: http.Header{ 84 | "Authorization": {fmt.Sprintf("Bearer %s", token)}, 85 | "User-Agent": {mautrix.DefaultUserAgent}, 86 | }, 87 | } 88 | if method == http.MethodPut || method == http.MethodPost { 89 | req.Header.Set("Content-Type", "application/json") 90 | } 91 | return req 92 | } 93 | 94 | func encodeContent(into *http.Request, body any) error { 95 | var buf bytes.Buffer 96 | err := json.NewEncoder(&buf).Encode(body) 97 | if err != nil { 98 | return fmt.Errorf("failed to encode request: %w", err) 99 | } 100 | into.Body = io.NopCloser(&buf) 101 | return nil 102 | } 103 | 104 | func doRequest(req *http.Request, reqData, resp any) (err error) { 105 | if reqData != nil { 106 | err = encodeContent(req, reqData) 107 | if err != nil { 108 | return 109 | } 110 | } 111 | r, err := cli.Do(req) 112 | if err != nil { 113 | return fmt.Errorf("failed to send request: %w", err) 114 | } 115 | defer r.Body.Close() 116 | if r.StatusCode < 200 || r.StatusCode >= 300 { 117 | var body map[string]any 118 | _ = json.NewDecoder(r.Body).Decode(&body) 119 | if body != nil { 120 | retryCount, ok := body["retries"].(float64) 121 | if ok && retryCount > 0 && r.StatusCode == 403 && req.URL.Path == "/user/login/response" { 122 | return fmt.Errorf("%w (%d retries left)", ErrInvalidLoginCode, int(retryCount)) 123 | } 124 | errorMsg, ok := body["error"].(string) 125 | if ok { 126 | return fmt.Errorf("server returned error (HTTP %d): %s", r.StatusCode, errorMsg) 127 | } 128 | } 129 | return fmt.Errorf("unexpected status code %d", r.StatusCode) 130 | } 131 | if resp != nil { 132 | err = json.NewDecoder(r.Body).Decode(resp) 133 | if err != nil { 134 | return fmt.Errorf("error decoding response: %w", err) 135 | } 136 | } 137 | return nil 138 | } 139 | 140 | type ReqPostBridgeState struct { 141 | StateEvent status.BridgeStateEvent `json:"stateEvent"` 142 | Reason string `json:"reason"` 143 | Info map[string]any `json:"info"` 144 | IsSelfHosted bool `json:"isSelfHosted"` 145 | BridgeType string `json:"bridgeType,omitempty"` 146 | } 147 | 148 | func DeleteBridge(domain, bridgeName, token string) error { 149 | req := newRequest(domain, token, http.MethodDelete, fmt.Sprintf("/bridge/%s", bridgeName)) 150 | return doRequest(req, nil, nil) 151 | } 152 | 153 | func PostBridgeState(domain, username, bridgeName, asToken string, data ReqPostBridgeState) error { 154 | req := newRequest(domain, asToken, http.MethodPost, fmt.Sprintf("/bridgebox/%s/bridge/%s/bridge_state", username, bridgeName)) 155 | return doRequest(req, &data, nil) 156 | } 157 | 158 | func Whoami(baseDomain, token string) (resp *RespWhoami, err error) { 159 | req := newRequest(baseDomain, token, http.MethodGet, "/whoami") 160 | err = doRequest(req, nil, &resp) 161 | return 162 | } 163 | -------------------------------------------------------------------------------- /api/gitlab/build.go: -------------------------------------------------------------------------------- 1 | package gitlab 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | "net/url" 10 | "os" 11 | "path/filepath" 12 | "runtime" 13 | "strings" 14 | 15 | "github.com/fatih/color" 16 | "github.com/schollz/progressbar/v3" 17 | "github.com/tidwall/gjson" 18 | 19 | "github.com/beeper/bridge-manager/cli/hyper" 20 | "github.com/beeper/bridge-manager/log" 21 | ) 22 | 23 | // language=graphql 24 | const getLastSuccessfulJobQuery = ` 25 | query($repo: ID!, $ref: String!, $job: String!) { 26 | project(fullPath: $repo) { 27 | pipelines(status: SUCCESS, ref: $ref, first: 1) { 28 | nodes { 29 | sha 30 | job(name: $job) { 31 | webPath 32 | } 33 | } 34 | } 35 | } 36 | } 37 | ` 38 | 39 | type lastSuccessfulJobQueryVariables struct { 40 | Repo string `json:"repo"` 41 | Ref string `json:"ref"` 42 | Job string `json:"job"` 43 | } 44 | 45 | type LastBuild struct { 46 | Commit string 47 | JobURL string 48 | } 49 | 50 | func GetLastBuild(domain, repo, mainBranch, job string) (*LastBuild, error) { 51 | resp, err := graphqlQuery(domain, getLastSuccessfulJobQuery, lastSuccessfulJobQueryVariables{ 52 | Repo: repo, 53 | Ref: mainBranch, 54 | Job: job, 55 | }) 56 | if err != nil { 57 | return nil, err 58 | } 59 | res := gjson.GetBytes(resp, "project.pipelines.nodes.0") 60 | if !res.Exists() { 61 | return nil, fmt.Errorf("didn't get pipeline info in response") 62 | } 63 | return &LastBuild{ 64 | Commit: gjson.Get(res.Raw, "sha").Str, 65 | JobURL: gjson.Get(res.Raw, "job.webPath").Str, 66 | }, nil 67 | } 68 | 69 | func getRefFromBridge(bridge string) (string, error) { 70 | switch bridge { 71 | case "imessage": 72 | return "master", nil 73 | case "whatsapp", "discord", "slack", "gmessages", "gvoice", "signal", "imessagego", "meta", "twitter", "bluesky", "linkedin": 74 | return "main", nil 75 | default: 76 | return "", fmt.Errorf("unknown bridge %s", bridge) 77 | } 78 | } 79 | 80 | var ErrNotBuiltInCI = errors.New("not built in the CI") 81 | 82 | func getJobFromBridge(bridge string) (string, error) { 83 | osAndArch := fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH) 84 | switch osAndArch { 85 | case "linux/amd64": 86 | return "build amd64", nil 87 | case "linux/arm64": 88 | return "build arm64", nil 89 | case "linux/arm": 90 | if bridge == "signal" { 91 | return "", fmt.Errorf("mautrix-signal binaries for 32-bit arm are %w", ErrNotBuiltInCI) 92 | } 93 | return "build arm", nil 94 | case "darwin/arm64": 95 | if bridge == "imessage" { 96 | return "build universal", nil 97 | } 98 | return "build macos arm64", nil 99 | default: 100 | if bridge == "imessage" { 101 | return "build universal", nil 102 | } 103 | return "", fmt.Errorf("binaries for %s are %w", osAndArch, ErrNotBuiltInCI) 104 | } 105 | } 106 | 107 | func linkifyCommit(repo, commit string) string { 108 | return hyper.Link(commit[:8], fmt.Sprintf("https://github.com/%s/commit/%s", repo, commit), false) 109 | } 110 | 111 | func linkifyDiff(repo, fromCommit, toCommit string) string { 112 | formattedDiff := fmt.Sprintf("%s...%s", fromCommit[:8], toCommit[:8]) 113 | return hyper.Link(formattedDiff, fmt.Sprintf("https://github.com/%s/compare/%s...%s", repo, fromCommit, toCommit), false) 114 | } 115 | 116 | func makeArtifactURL(domain, jobURL, fileName string) string { 117 | return (&url.URL{ 118 | Scheme: "https", 119 | Host: domain, 120 | Path: filepath.Join(jobURL, "artifacts", "raw", fileName), 121 | }).String() 122 | } 123 | 124 | func downloadFile(ctx context.Context, artifactURL, path string) error { 125 | fileName := filepath.Base(path) 126 | file, err := os.CreateTemp(filepath.Dir(path), "tmp-"+fileName+"-*") 127 | if err != nil { 128 | return fmt.Errorf("failed to open temp file: %w", err) 129 | } 130 | defer func() { 131 | _ = file.Close() 132 | _ = os.Remove(file.Name()) 133 | }() 134 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, artifactURL, nil) 135 | if err != nil { 136 | return fmt.Errorf("failed to prepare download request: %w", err) 137 | } 138 | resp, err := noTimeoutCli.Do(req) 139 | if err != nil { 140 | return fmt.Errorf("failed to download artifact: %w", err) 141 | } 142 | defer resp.Body.Close() 143 | if resp.StatusCode != http.StatusOK { 144 | return fmt.Errorf("failed to download artifact: unexpected response status %d", resp.StatusCode) 145 | } 146 | bar := progressbar.DefaultBytes( 147 | resp.ContentLength, 148 | fmt.Sprintf("Downloading %s", color.CyanString(fileName)), 149 | ) 150 | _, err = io.Copy(io.MultiWriter(file, bar), resp.Body) 151 | if err != nil { 152 | return fmt.Errorf("failed to write file: %w", err) 153 | } 154 | _ = file.Close() 155 | err = os.Rename(file.Name(), path) 156 | if err != nil { 157 | return fmt.Errorf("failed to move temp file: %w", err) 158 | } 159 | err = os.Chmod(path, 0755) 160 | if err != nil { 161 | return fmt.Errorf("failed to chmod binary: %w", err) 162 | } 163 | return nil 164 | } 165 | 166 | func needsLibolmDylib(bridge string) bool { 167 | switch bridge { 168 | case "imessage", "whatsapp", "discord", "slack", "gmessages", "gvoice", "signal", "imessagego", "meta", "twitter", "bluesky", "linkedin": 169 | return runtime.GOOS == "darwin" 170 | default: 171 | return false 172 | } 173 | } 174 | 175 | func DownloadMautrixBridgeBinary(ctx context.Context, bridge, path string, v2, noUpdate bool, branchOverride, currentCommit string) error { 176 | domain := "mau.dev" 177 | bridge = strings.TrimSuffix(bridge, "v2") 178 | repo := fmt.Sprintf("mautrix/%s", bridge) 179 | fileName := filepath.Base(path) 180 | ref, err := getRefFromBridge(bridge) 181 | if err != nil { 182 | return err 183 | } 184 | if branchOverride != "" { 185 | ref = branchOverride 186 | } 187 | job, err := getJobFromBridge(bridge) 188 | if err != nil { 189 | return err 190 | } 191 | if v2 { 192 | job += " v2" 193 | } 194 | 195 | if currentCommit == "" { 196 | log.Printf("Finding latest version of [cyan]%s[reset] from [cyan]%s[reset]", fileName, domain) 197 | } else { 198 | log.Printf("Checking for updates to [cyan]%s[reset] from [cyan]%s[reset]", fileName, domain) 199 | } 200 | build, err := GetLastBuild(domain, repo, ref, job) 201 | if err != nil { 202 | return fmt.Errorf("failed to get last build info: %w", err) 203 | } 204 | if build.Commit == currentCommit { 205 | log.Printf("[cyan]%s[reset] is up to date (commit: %s)", fileName, linkifyCommit(repo, currentCommit)) 206 | return nil 207 | } else if currentCommit != "" && noUpdate { 208 | log.Printf("[cyan]%s[reset] [yellow]is out of date, latest commit is %s (diff: %s)[reset]", fileName, linkifyCommit(repo, build.Commit), linkifyDiff(repo, currentCommit, build.Commit)) 209 | return nil 210 | } else if build.JobURL == "" { 211 | return fmt.Errorf("failed to find URL for job %q on branch %s of %s", job, ref, repo) 212 | } 213 | if currentCommit == "" { 214 | log.Printf("Installing [cyan]%s[reset] (commit: %s)", fileName, linkifyCommit(repo, build.Commit)) 215 | } else { 216 | log.Printf("Updating [cyan]%s[reset] (diff: %s)", fileName, linkifyDiff(repo, currentCommit, build.Commit)) 217 | } 218 | artifactURL := makeArtifactURL(domain, build.JobURL, fileName) 219 | err = downloadFile(ctx, artifactURL, path) 220 | if err != nil { 221 | return err 222 | } 223 | if needsLibolmDylib(bridge) { 224 | libolmPath := filepath.Join(filepath.Dir(path), "libolm.3.dylib") 225 | // TODO redownload libolm if it's outdated? 226 | if _, err = os.Stat(libolmPath); err != nil { 227 | err = downloadFile(ctx, makeArtifactURL(domain, build.JobURL, "libolm.3.dylib"), libolmPath) 228 | if err != nil { 229 | return fmt.Errorf("failed to download libolm: %w", err) 230 | } 231 | } 232 | } 233 | 234 | log.Printf("Successfully installed [cyan]%s[reset] commit %s", fileName, linkifyCommit(domain, build.Commit)) 235 | return nil 236 | } 237 | -------------------------------------------------------------------------------- /api/gitlab/graphql.go: -------------------------------------------------------------------------------- 1 | package gitlab 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | "net/url" 10 | "time" 11 | 12 | "maunium.net/go/mautrix" 13 | ) 14 | 15 | var noTimeoutCli = &http.Client{} 16 | var cli = &http.Client{Timeout: 30 * time.Second} 17 | 18 | type queryRequestBody struct { 19 | Query string `json:"query"` 20 | Variables any `json:"variables"` 21 | } 22 | 23 | type QueryErrorLocation struct { 24 | Line int `json:"line"` 25 | Column int `json:"column"` 26 | } 27 | 28 | type QueryErrorItem struct { 29 | Message string `json:"message"` 30 | Locations []QueryErrorLocation `json:"locations"` 31 | } 32 | 33 | type QueryError []QueryErrorItem 34 | 35 | func (qe QueryError) Error() string { 36 | if len(qe) == 1 { 37 | return qe[0].Message 38 | } 39 | plural := "s" 40 | if len(qe) == 2 { 41 | plural = "" 42 | } 43 | return fmt.Sprintf("%s (and %d other error%s)", qe[0].Message, len(qe)-1, plural) 44 | } 45 | 46 | type queryResponse struct { 47 | Data json.RawMessage `json:"data"` 48 | Errors QueryError `json:"errors"` 49 | } 50 | 51 | func graphqlQuery(domain, query string, args any) (json.RawMessage, error) { 52 | req := &http.Request{ 53 | URL: &url.URL{ 54 | Scheme: "https", 55 | Host: domain, 56 | Path: "/api/graphql", 57 | }, 58 | Method: http.MethodPost, 59 | Header: http.Header{ 60 | "User-Agent": {mautrix.DefaultUserAgent}, 61 | "Content-Type": {"application/json"}, 62 | "Accept": {"application/json"}, 63 | }, 64 | } 65 | var buf bytes.Buffer 66 | err := json.NewEncoder(&buf).Encode(queryRequestBody{ 67 | Query: query, 68 | Variables: args, 69 | }) 70 | if err != nil { 71 | return nil, fmt.Errorf("failed to encode request body: %w", err) 72 | } 73 | req.Body = io.NopCloser(&buf) 74 | resp, err := cli.Do(req) 75 | if err != nil { 76 | return nil, fmt.Errorf("failed to send request: %w", err) 77 | } 78 | defer resp.Body.Close() 79 | var respData queryResponse 80 | err = json.NewDecoder(resp.Body).Decode(&respData) 81 | if err != nil { 82 | return nil, fmt.Errorf("failed to decode response body: %w", err) 83 | } 84 | if len(respData.Errors) > 0 { 85 | return nil, respData.Errors 86 | } 87 | return respData.Data, nil 88 | } 89 | -------------------------------------------------------------------------------- /api/hungryapi/appservice.go: -------------------------------------------------------------------------------- 1 | package hungryapi 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "net/url" 7 | "time" 8 | 9 | "go.mau.fi/util/jsontime" 10 | "maunium.net/go/mautrix" 11 | "maunium.net/go/mautrix/appservice" 12 | "maunium.net/go/mautrix/id" 13 | ) 14 | 15 | type Client struct { 16 | *mautrix.Client 17 | Username string 18 | } 19 | 20 | func NewClient(baseDomain, username, accessToken string) *Client { 21 | hungryURL := url.URL{ 22 | Scheme: "https", 23 | Host: "matrix." + baseDomain, 24 | Path: "/_hungryserv/" + username, 25 | } 26 | client, err := mautrix.NewClient(hungryURL.String(), id.NewUserID(username, baseDomain), accessToken) 27 | if err != nil { 28 | panic(err) 29 | } 30 | return &Client{Client: client, Username: username} 31 | } 32 | 33 | type ReqRegisterAppService struct { 34 | Address string `json:"address,omitempty"` 35 | Push bool `json:"push"` 36 | SelfHosted bool `json:"self_hosted"` 37 | } 38 | 39 | func (cli *Client) RegisterAppService( 40 | ctx context.Context, 41 | bridge string, 42 | req ReqRegisterAppService, 43 | ) (resp appservice.Registration, err error) { 44 | url := cli.BuildURL(mautrix.BaseURLPath{"_matrix", "asmux", "mxauth", "appservice", cli.Username, bridge}) 45 | _, err = cli.MakeRequest(ctx, http.MethodPut, url, &req, &resp) 46 | return 47 | } 48 | 49 | func (cli *Client) GetAppService(ctx context.Context, bridge string) (resp appservice.Registration, err error) { 50 | url := cli.BuildURL(mautrix.BaseURLPath{"_matrix", "asmux", "mxauth", "appservice", cli.Username, bridge}) 51 | _, err = cli.MakeRequest(ctx, http.MethodGet, url, nil, &resp) 52 | return 53 | } 54 | 55 | func (cli *Client) DeleteAppService(ctx context.Context, bridge string) (err error) { 56 | url := cli.BuildURL(mautrix.BaseURLPath{"_matrix", "asmux", "mxauth", "appservice", cli.Username, bridge}) 57 | _, err = cli.MakeRequest(ctx, http.MethodDelete, url, nil, nil) 58 | return 59 | } 60 | 61 | type respGetSystemTime struct { 62 | Time jsontime.UnixMilli `json:"time_ms"` 63 | } 64 | 65 | func (cli *Client) GetServerTime(ctx context.Context) (resp time.Time, precision time.Duration, err error) { 66 | var respData respGetSystemTime 67 | start := time.Now() 68 | _, err = cli.MakeFullRequest(ctx, mautrix.FullRequest{ 69 | Method: http.MethodGet, 70 | URL: cli.BuildURL(mautrix.BaseURLPath{"_matrix", "client", "unstable", "com.beeper.timesync"}), 71 | ResponseJSON: &respData, 72 | MaxAttempts: 1, 73 | }) 74 | precision = time.Since(start) 75 | resp = respData.Time.Time 76 | return 77 | } 78 | -------------------------------------------------------------------------------- /bridgeconfig/bluesky.tpl.yaml: -------------------------------------------------------------------------------- 1 | # Network-specific config options 2 | network: 3 | # Displayname template for Bluesky users. Available variables: 4 | # .DisplayName - displayname set by the user. Not required, may be empty. 5 | # .Handle - username (domain) of the user. Always present. 6 | # .DID - internal user ID starting with `did:`. Always present. 7 | displayname_template: {{ `"{{or .DisplayName .Handle}}"` }} 8 | 9 | {{ setfield . "CommandPrefix" "!bsky" -}} 10 | {{ setfield . "DatabaseFileName" "mautrix-bluesky" -}} 11 | {{ setfield . "BridgeTypeName" "Bluesky" -}} 12 | {{ setfield . "BridgeTypeIcon" "mxc://maunium.net/ezAjjDxhiJWGEohmhkpfeHYf" -}} 13 | {{ setfield . "DefaultPickleKey" "go.mau.fi/mautrix-bluesky" -}} 14 | {{ template "bridgev2.tpl.yaml" . }} 15 | -------------------------------------------------------------------------------- /bridgeconfig/bridgeconfig.go: -------------------------------------------------------------------------------- 1 | package bridgeconfig 2 | 3 | import ( 4 | "embed" 5 | "fmt" 6 | "reflect" 7 | "strings" 8 | "text/template" 9 | 10 | "maunium.net/go/mautrix/id" 11 | ) 12 | 13 | type BridgeV2Name struct { 14 | DatabaseFileName string 15 | CommandPrefix string 16 | BridgeTypeName string 17 | BridgeTypeIcon string 18 | DefaultPickleKey string 19 | 20 | MaxInitialMessages int 21 | MaxBackwardMessages int 22 | } 23 | 24 | type Params struct { 25 | HungryAddress string 26 | BeeperDomain string 27 | 28 | Websocket bool 29 | ListenAddr string 30 | ListenPort uint16 31 | 32 | AppserviceID string 33 | ASToken string 34 | HSToken string 35 | BridgeName string 36 | Username string 37 | UserID id.UserID 38 | 39 | ProvisioningSecret string 40 | 41 | DatabasePrefix string 42 | 43 | BridgeV2Name 44 | 45 | Params map[string]string 46 | } 47 | 48 | //go:embed *.tpl.yaml 49 | var configs embed.FS 50 | var tpl *template.Template 51 | var SupportedBridges []string 52 | 53 | var tplFuncs = template.FuncMap{ 54 | "replace": strings.ReplaceAll, 55 | "setfield": func(obj any, field string, value any) any { 56 | val := reflect.ValueOf(obj) 57 | for val.Kind() == reflect.Pointer { 58 | val = val.Elem() 59 | } 60 | val.FieldByName(field).Set(reflect.ValueOf(value)) 61 | return "" 62 | }, 63 | } 64 | 65 | func init() { 66 | var err error 67 | tpl, err = template.New("configs").Funcs(tplFuncs).ParseFS(configs, "*") 68 | if err != nil { 69 | panic(fmt.Errorf("failed to parse bridge config templates: %w", err)) 70 | } 71 | for _, sub := range tpl.Templates() { 72 | SupportedBridges = append(SupportedBridges, strings.TrimSuffix(sub.Name(), ".tpl.yaml")) 73 | } 74 | } 75 | 76 | func templateName(bridgeName string) string { 77 | return fmt.Sprintf("%s.tpl.yaml", bridgeName) 78 | } 79 | 80 | func IsSupported(bridgeName string) bool { 81 | return tpl.Lookup(templateName(bridgeName)) != nil 82 | } 83 | 84 | func Generate(bridgeName string, params Params) (string, error) { 85 | var out strings.Builder 86 | err := tpl.ExecuteTemplate(&out, templateName(bridgeName), ¶ms) 87 | return out.String(), err 88 | } 89 | -------------------------------------------------------------------------------- /bridgeconfig/gmessages.tpl.yaml: -------------------------------------------------------------------------------- 1 | # Network-specific config options 2 | network: 3 | # Displayname template for SMS users. 4 | displayname_template: {{ `"{{or .FullName .PhoneNumber}}"` }} 5 | # Settings for how the bridge appears to the phone. 6 | device_meta: 7 | # OS name to tell the phone. This is the name that shows up in the paired devices list. 8 | os: Beeper (self-hosted) 9 | # Browser type to tell the phone. This decides which icon is shown. 10 | # Valid types: OTHER, CHROME, FIREFOX, SAFARI, OPERA, IE, EDGE 11 | browser: OTHER 12 | # Device type to tell the phone. This also affects the icon, as well as how many sessions are allowed simultaneously. 13 | # One web, two tablets and one PWA should be able to connect at the same time. 14 | # Valid types: WEB, TABLET, PWA 15 | type: TABLET 16 | # Should the bridge aggressively set itself as the active device if the user opens Google Messages in a browser? 17 | # If this is disabled, the user must manually use the `set-active` command to reactivate the bridge. 18 | aggressive_reconnect: true 19 | # Number of chats to sync when connecting to Google Messages. 20 | initial_chat_sync_count: 25 21 | 22 | {{ setfield . "CommandPrefix" "!gm" -}} 23 | {{ setfield . "DatabaseFileName" "mautrix-gmessages" -}} 24 | {{ setfield . "BridgeTypeName" "Google Messages" -}} 25 | {{ setfield . "BridgeTypeIcon" "mxc://maunium.net/yGOdcrJcwqARZqdzbfuxfhzb" -}} 26 | {{ setfield . "DefaultPickleKey" "go.mau.fi/mautrix-gmessages" -}} 27 | {{ template "bridgev2.tpl.yaml" . }} 28 | -------------------------------------------------------------------------------- /bridgeconfig/googlechat.tpl.yaml: -------------------------------------------------------------------------------- 1 | # Homeserver details 2 | homeserver: 3 | # The address that this appservice can use to connect to the homeserver. 4 | address: {{ .HungryAddress }} 5 | # The domain of the homeserver (for MXIDs, etc). 6 | domain: beeper.local 7 | # Whether or not to verify the SSL certificate of the homeserver. 8 | # Only applies if address starts with https:// 9 | verify_ssl: true 10 | # What software is the homeserver running? 11 | # Standard Matrix homeservers like Synapse, Dendrite and Conduit should just use "standard" here. 12 | software: hungry 13 | # Number of retries for all HTTP requests if the homeserver isn't reachable. 14 | http_retry_count: 4 15 | # The URL to push real-time bridge status to. 16 | # If set, the bridge will make POST requests to this URL whenever a user's Google Chat connection state changes. 17 | # The bridge will use the appservice as_token to authorize requests. 18 | status_endpoint: null 19 | # Endpoint for reporting per-message status. 20 | message_send_checkpoint_endpoint: null 21 | # Whether asynchronous uploads via MSC2246 should be enabled for media. 22 | # Requires a media repo that supports MSC2246. 23 | async_media: true 24 | 25 | # Application service host/registration related details 26 | # Changing these values requires regeneration of the registration. 27 | appservice: 28 | # The address that the homeserver can use to connect to this appservice. 29 | address: "http://{{ .ListenAddr }}:{{ .ListenPort }}" 30 | 31 | # The hostname and port where this appservice should listen. 32 | hostname: {{ .ListenAddr }} 33 | port: {{ .ListenPort }} 34 | # The maximum body size of appservice API requests (from the homeserver) in mebibytes 35 | # Usually 1 is enough, but on high-traffic bridges you might need to increase this to avoid 413s 36 | max_body_size: 1 37 | 38 | # The full URI to the database. SQLite and Postgres are supported. 39 | # Format examples: 40 | # SQLite: sqlite:filename.db 41 | # Postgres: postgres://username:password@hostname/dbname 42 | database: sqlite:{{.DatabasePrefix}}mautrix-googlechat.db 43 | # Additional arguments for asyncpg.create_pool() or sqlite3.connect() 44 | # https://magicstack.github.io/asyncpg/current/api/index.html#asyncpg.pool.create_pool 45 | # https://docs.python.org/3/library/sqlite3.html#sqlite3.connect 46 | # For sqlite, min_size is used as the connection thread pool size and max_size is ignored. 47 | # Additionally, SQLite supports init_commands as an array of SQL queries to run on connect (e.g. to set PRAGMAs). 48 | database_opts: 49 | min_size: 1 50 | max_size: 1 51 | 52 | # The unique ID of this appservice. 53 | id: {{ .AppserviceID }} 54 | # Username of the appservice bot. 55 | bot_username: {{ .BridgeName}}bot 56 | # Display name and avatar for bot. Set to "remove" to remove display name/avatar, leave empty 57 | # to leave display name/avatar as-is. 58 | bot_displayname: Google Chat bridge bot 59 | bot_avatar: mxc://maunium.net/BDIWAQcbpPGASPUUBuEGWXnQ 60 | 61 | # Whether or not to receive ephemeral events via appservice transactions. 62 | # Requires MSC2409 support (i.e. Synapse 1.22+). 63 | # You should disable bridge -> sync_with_custom_puppets when this is enabled. 64 | ephemeral_events: true 65 | 66 | # Authentication tokens for AS <-> HS communication. Autogenerated; do not modify. 67 | as_token: {{ .ASToken }} 68 | hs_token: {{ .HSToken }} 69 | 70 | # Prometheus telemetry config. Requires prometheus-client to be installed. 71 | metrics: 72 | enabled: false 73 | listen_port: 8000 74 | 75 | # Manhole config. 76 | manhole: 77 | # Whether or not opening the manhole is allowed. 78 | enabled: false 79 | # The path for the unix socket. 80 | path: /var/tmp/mautrix-googlechat.manhole 81 | # The list of UIDs who can be added to the whitelist. 82 | # If empty, any UIDs can be specified in the open-manhole command. 83 | whitelist: 84 | - 0 85 | 86 | # Bridge config 87 | bridge: 88 | # Localpart template of MXIDs for Google Chat users. 89 | # {userid} is replaced with the user ID of the Google Chat user. 90 | username_template: "{{ .BridgeName }}_{userid}" 91 | # Displayname template for Google Chat users. 92 | # {full_name}, {first_name}, {last_name} and {email} are replaced with names. 93 | displayname_template: "{full_name}" 94 | 95 | # The prefix for commands. Only required in non-management rooms. 96 | command_prefix: "!gc" 97 | 98 | # Number of chats to sync (and create portals for) on startup/login. 99 | # Set 0 to disable automatic syncing. 100 | initial_chat_sync: 10 101 | # Whether or not the Google Chat users of logged in Matrix users should be 102 | # invited to private chats when the user sends a message from another client. 103 | invite_own_puppet_to_pm: false 104 | # Whether or not to use /sync to get presence, read receipts and typing notifications 105 | # when double puppeting is enabled 106 | sync_with_custom_puppets: false 107 | # Whether or not to update the m.direct account data event when double puppeting is enabled. 108 | # Note that updating the m.direct event is not atomic (except with mautrix-asmux) 109 | # and is therefore prone to race conditions. 110 | sync_direct_chat_list: false 111 | # Servers to always allow double puppeting from 112 | double_puppet_server_map: 113 | {{ .BeeperDomain }}: {{ .HungryAddress }} 114 | # Allow using double puppeting from any server with a valid client .well-known file. 115 | double_puppet_allow_discovery: false 116 | # Shared secret for https://github.com/devture/matrix-synapse-shared-secret-auth 117 | # 118 | # If set, custom puppets will be enabled automatically for local users 119 | # instead of users having to find an access token and run `login-matrix` 120 | # manually. 121 | # If using this for other servers than the bridge's server, 122 | # you must also set the URL in the double_puppet_server_map. 123 | login_shared_secret_map: 124 | {{ .BeeperDomain }}: "as_token:{{ .ASToken }}" 125 | # Whether or not to update avatars when syncing all contacts at startup. 126 | update_avatar_initial_sync: true 127 | # End-to-bridge encryption support options. 128 | # 129 | # See https://docs.mau.fi/bridges/general/end-to-bridge-encryption.html for more info. 130 | encryption: 131 | # Allow encryption, work in group chat rooms with e2ee enabled 132 | allow: true 133 | # Default to encryption, force-enable encryption in all portals the bridge creates 134 | # This will cause the bridge bot to be in private chats for the encryption to work properly. 135 | default: true 136 | # Whether to use MSC2409/MSC3202 instead of /sync long polling for receiving encryption-related data. 137 | appservice: true 138 | # Require encryption, drop any unencrypted messages. 139 | require: true 140 | # Enable key sharing? If enabled, key requests for rooms where users are in will be fulfilled. 141 | # You must use a client that supports requesting keys from other users to use this feature. 142 | allow_key_sharing: true 143 | # Options for deleting megolm sessions from the bridge. 144 | delete_keys: 145 | # Beeper-specific: delete outbound sessions when hungryserv confirms 146 | # that the user has uploaded the key to key backup. 147 | delete_outbound_on_ack: true 148 | # Don't store outbound sessions in the inbound table. 149 | dont_store_outbound: false 150 | # Ratchet megolm sessions forward after decrypting messages. 151 | ratchet_on_decrypt: true 152 | # Delete fully used keys (index >= max_messages) after decrypting messages. 153 | delete_fully_used_on_decrypt: true 154 | # Delete previous megolm sessions from same device when receiving a new one. 155 | delete_prev_on_new_session: true 156 | # Delete megolm sessions received from a device when the device is deleted. 157 | delete_on_device_delete: true 158 | # Periodically delete megolm sessions when 2x max_age has passed since receiving the session. 159 | periodically_delete_expired: true 160 | # Delete inbound megolm sessions that don't have the received_at field used for 161 | # automatic ratcheting and expired session deletion. This is meant as a migration 162 | # to delete old keys prior to the bridge update. 163 | delete_outdated_inbound: true 164 | # What level of device verification should be required from users? 165 | # 166 | # Valid levels: 167 | # unverified - Send keys to all device in the room. 168 | # cross-signed-untrusted - Require valid cross-signing, but trust all cross-signing keys. 169 | # cross-signed-tofu - Require valid cross-signing, trust cross-signing keys on first use (and reject changes). 170 | # cross-signed-verified - Require valid cross-signing, plus a valid user signature from the bridge bot. 171 | # Note that creating user signatures from the bridge bot is not currently possible. 172 | # verified - Require manual per-device verification 173 | # (currently only possible by modifying the `trust` column in the `crypto_device` database table). 174 | verification_levels: 175 | # Minimum level for which the bridge should send keys to when bridging messages from Telegram to Matrix. 176 | receive: cross-signed-tofu 177 | # Minimum level that the bridge should accept for incoming Matrix messages. 178 | send: cross-signed-tofu 179 | # Minimum level that the bridge should require for accepting key requests. 180 | share: cross-signed-tofu 181 | # Options for Megolm room key rotation. These options allow you to 182 | # configure the m.room.encryption event content. See: 183 | # https://spec.matrix.org/v1.3/client-server-api/#mroomencryption for 184 | # more information about that event. 185 | rotation: 186 | # Enable custom Megolm room key rotation settings. Note that these 187 | # settings will only apply to rooms created after this option is 188 | # set. 189 | enable_custom: true 190 | # The maximum number of milliseconds a session should be used 191 | # before changing it. The Matrix spec recommends 604800000 (a week) 192 | # as the default. 193 | milliseconds: 2592000000 194 | # The maximum number of messages that should be sent with a given a 195 | # session before changing it. The Matrix spec recommends 100 as the 196 | # default. 197 | messages: 10000 198 | 199 | # Disable rotating keys when a user's devices change? 200 | # You should not enable this option unless you understand all the implications. 201 | disable_device_change_key_rotation: true 202 | 203 | # Whether or not the bridge should send a read receipt from the bridge bot when a message has 204 | # been sent to Google Chat. 205 | delivery_receipts: false 206 | # Whether or not delivery errors should be reported as messages in the Matrix room. 207 | delivery_error_reports: false 208 | # Whether the bridge should send the message status as a custom com.beeper.message_send_status event. 209 | message_status_events: true 210 | # Whether or not created rooms should have federation enabled. 211 | # If false, created portal rooms will never be federated. 212 | federate_rooms: false 213 | # Settings for backfilling messages from Google Chat. 214 | backfill: 215 | # Whether or not the Google Chat users of logged in Matrix users should be 216 | # invited to private chats when backfilling history from Google Chat. This is 217 | # usually needed to prevent rate limits and to allow timestamp massaging. 218 | invite_own_puppet: false 219 | # Number of threads to backfill in threaded spaces in initial backfill. 220 | initial_thread_limit: 0 221 | # Number of replies to backfill in each thread in initial backfill. 222 | initial_thread_reply_limit: 500 223 | # Number of messages to backfill in non-threaded spaces and DMs in initial backfill. 224 | initial_nonthread_limit: 1 225 | # Number of events to backfill in catchup backfill. 226 | missed_event_limit: 200 227 | # How many events to request from Google Chat at once in catchup backfill? 228 | missed_event_page_size: 100 229 | # If using double puppeting, should notifications be disabled 230 | # while the initial backfill is in progress? 231 | disable_notifications: true 232 | 233 | # Set this to true to tell the bridge to re-send m.bridge events to all rooms on the next run. 234 | # This field will automatically be changed back to false after it, 235 | # except if the config file is not writable. 236 | resend_bridge_info: false 237 | # Whether or not unimportant bridge notices should be sent to the bridge notice room. 238 | unimportant_bridge_notices: false 239 | # Whether or not bridge notices should be disabled entirely. 240 | disable_bridge_notices: true 241 | # Whether to explicitly set the avatar and room name for private chat portal rooms. 242 | # If set to `default`, this will be enabled in encrypted rooms and disabled in unencrypted rooms. 243 | # If set to `always`, all DM rooms will have explicit names and avatars set. 244 | # If set to `never`, DM rooms will never have names and avatars set. 245 | private_chat_portal_meta: never 246 | 247 | provisioning: 248 | # Internal prefix in the appservice web server for the login endpoints. 249 | prefix: /_matrix/provision 250 | # Shared secret for integration managers such as mautrix-manager. 251 | # If set to "generate", a random string will be generated on the next startup. 252 | # If null, integration manager access to the API will not be possible. 253 | shared_secret: {{ .ProvisioningSecret }} 254 | 255 | # Permissions for using the bridge. 256 | # Permitted values: 257 | # user - Use the bridge with puppeting. 258 | # admin - Use and administrate the bridge. 259 | # Permitted keys: 260 | # * - All Matrix users 261 | # domain - All users on that homeserver 262 | # mxid - Specific user 263 | permissions: 264 | "{{ .UserID }}": "admin" 265 | 266 | # Python logging configuration. 267 | # 268 | # See section 16.7.2 of the Python documentation for more info: 269 | # https://docs.python.org/3.6/library/logging.config.html#configuration-dictionary-schema 270 | logging: 271 | version: 1 272 | formatters: 273 | colored: 274 | (): mautrix_googlechat.util.ColorFormatter 275 | format: "[%(asctime)s] [%(levelname)s@%(name)s] %(message)s" 276 | normal: 277 | format: "[%(asctime)s] [%(levelname)s@%(name)s] %(message)s" 278 | handlers: 279 | file: 280 | class: logging.handlers.RotatingFileHandler 281 | formatter: normal 282 | filename: ./logs/mautrix-googlechat.log 283 | maxBytes: 10485760 284 | backupCount: 10 285 | console: 286 | class: logging.StreamHandler 287 | formatter: colored 288 | loggers: 289 | mau: 290 | level: DEBUG 291 | maugclib: 292 | level: INFO 293 | aiohttp: 294 | level: INFO 295 | root: 296 | level: DEBUG 297 | handlers: [file, console] 298 | -------------------------------------------------------------------------------- /bridgeconfig/gvoice.tpl.yaml: -------------------------------------------------------------------------------- 1 | # Network-specific config options 2 | network: 3 | # Displayname template for SMS users. Available variables: 4 | # .Name - same as phone number in most cases 5 | # .Contact.Name - name from contact list 6 | # .Contact.FirstName - first name from contact list 7 | # .PhoneNumber 8 | displayname_template: {{ `"{{ or .Contact.Name .Name }}"` }} 9 | 10 | {{ setfield . "CommandPrefix" "!gv" -}} 11 | {{ setfield . "DatabaseFileName" "mautrix-gvoice" -}} 12 | {{ setfield . "BridgeTypeName" "Google Voice" -}} 13 | {{ setfield . "BridgeTypeIcon" "mxc://maunium.net/VOPtYGBzHLRfPTEzGgNMpeKo" -}} 14 | {{ setfield . "DefaultPickleKey" "go.mau.fi/mautrix-gvoice" -}} 15 | {{ setfield . "MaxInitialMessages" 10 -}} 16 | {{ setfield . "MaxBackwardMessages" 100 -}} 17 | {{ template "bridgev2.tpl.yaml" . }} 18 | -------------------------------------------------------------------------------- /bridgeconfig/heisenbridge.tpl.yaml: -------------------------------------------------------------------------------- 1 | id: {{ .AppserviceID }} 2 | url: {{ if .Websocket }}websocket{{ else }}http://{{ .ListenAddr }}:{{ .ListenPort }}{{ end }} 3 | as_token: {{ .ASToken }} 4 | hs_token: {{ .HSToken }} 5 | sender_localpart: {{ .BridgeName }}bot 6 | namespaces: 7 | users: 8 | - regex: '@{{ .BridgeName }}_.+:beeper\.local' 9 | exclusive: true 10 | push_ephemeral: true 11 | heisenbridge: 12 | media_url: https://matrix.{{ .BeeperDomain }} 13 | displayname: Heisenbridge 14 | -------------------------------------------------------------------------------- /bridgeconfig/imessage.tpl.yaml: -------------------------------------------------------------------------------- 1 | # Homeserver details. 2 | homeserver: 3 | # The address that this appservice can use to connect to the homeserver. 4 | address: {{ .HungryAddress }} 5 | # The address to mautrix-wsproxy (which should usually be next to the homeserver behind a reverse proxy). 6 | # Only the /_matrix/client/unstable/fi.mau.as_sync websocket endpoint is used on this address. 7 | # 8 | # Set to null to disable using the websocket. When not using the websocket, make sure hostname and port are set in the appservice section. 9 | websocket_proxy: {{ if .Websocket }}{{ replace .HungryAddress "https" "wss" }}{{ else }}null{{ end }} 10 | # How often should the websocket be pinged? Pinging will be disabled if this is zero. 11 | ping_interval_seconds: 180 12 | # The domain of the homeserver (also known as server_name, used for MXIDs, etc). 13 | domain: beeper.local 14 | 15 | # What software is the homeserver running? 16 | # Standard Matrix homeservers like Synapse, Dendrite and Conduit should just use "standard" here. 17 | software: hungry 18 | # Does the homeserver support https://github.com/matrix-org/matrix-spec-proposals/pull/2246? 19 | async_media: true 20 | 21 | # Application service host/registration related details. 22 | # Changing these values requires regeneration of the registration. 23 | appservice: 24 | # The hostname and port where this appservice should listen. 25 | # The default method of deploying mautrix-imessage is using a websocket proxy, so it doesn't need a http server 26 | # To use a http server instead of a websocket, set websocket_proxy to null in the homeserver section, 27 | # and set the port below to a real port. 28 | hostname: {{ if .Websocket }}null{{ else }}{{ .ListenAddr }}{{ end }} 29 | port: {{ if .Websocket }}null{{ else }}{{ .ListenPort }}{{ end }} 30 | # Optional TLS certificates to listen for https instead of http connections. 31 | tls_key: null 32 | tls_cert: null 33 | 34 | # Database config. 35 | database: 36 | # The database type. Only "sqlite3-fk-wal" is supported. 37 | type: sqlite3-fk-wal 38 | # SQLite database path. A raw file path is supported, but `file:?_txlock=immediate` is recommended. 39 | uri: file:{{.DatabasePrefix}}mautrix-imessage.db?_txlock=immediate 40 | 41 | # The unique ID of this appservice. 42 | id: {{ .AppserviceID }} 43 | # Appservice bot details. 44 | bot: 45 | # Username of the appservice bot. 46 | username: {{ .BridgeName }}bot 47 | # Display name and avatar for bot. Set to "remove" to remove display name/avatar, leave empty 48 | # to leave display name/avatar as-is. 49 | displayname: iMessage bridge bot 50 | avatar: mxc://maunium.net/tManJEpANASZvDVzvRvhILdX 51 | 52 | # Whether or not to receive ephemeral events via appservice transactions. 53 | # Requires MSC2409 support (i.e. Synapse 1.22+). 54 | # You should disable bridge -> sync_with_custom_puppets when this is enabled. 55 | ephemeral_events: true 56 | 57 | # Authentication tokens for AS <-> HS communication. Autogenerated; do not modify. 58 | as_token: {{ .ASToken }} 59 | hs_token: {{ .HSToken }} 60 | 61 | # iMessage connection config 62 | imessage: 63 | # Available platforms: 64 | # * mac: Standard Mac connector, requires full disk access and will ask for AppleScript and contacts permission. 65 | # * ios: Jailbreak iOS connector when using with Brooklyn. 66 | # * android: Equivalent to ios, but for use with the Android SMS wrapper app. 67 | # * mac-nosip: Mac without SIP connector, runs Barcelona as a subprocess. 68 | platform: {{ .Params.imessage_platform }} 69 | # Path to the Barcelona executable for the mac-nosip connector 70 | imessage_rest_path: "{{ or .Params.barcelona_path "darwin-barcelona-mautrix" }}" 71 | # Additional arguments to pass to the mac-nosip connector 72 | imessage_rest_args: [] 73 | # The mode for fetching contacts in the no-SIP connector. 74 | # The default mode is `ipc` which will ask Barcelona. However, recent versions of Barcelona have removed contact support. 75 | # You can specify `mac` to use Contacts.framework directly instead of through Barcelona. 76 | # You can also specify `disable` to not try to use contacts at all. 77 | contacts_mode: mac 78 | # Whether to log the contents of IPC payloads 79 | log_ipc_payloads: false 80 | # For the no-SIP connector, hackily set the user account locale before starting Barcelona. 81 | hacky_set_locale: null 82 | # A list of environment variables to add for the Barcelona process (as NAME=value strings) 83 | environment: [] 84 | # Path to unix socket for Barcelona communication. 85 | unix_socket: mautrix-imessage.sock 86 | # Interval to ping Barcelona at. The process will exit if Barcelona doesn't respond in time. 87 | ping_interval_seconds: 15 88 | 89 | bluebubbles_url: {{ .Params.bluebubbles_url }} 90 | bluebubbles_password: {{ .Params.bluebubbles_password }} 91 | 92 | # Segment settings for collecting some debug data. 93 | segment: 94 | key: null 95 | user_id: null 96 | 97 | hacky_startup_test: 98 | identifier: null 99 | message: null 100 | response_message: null 101 | key: null 102 | echo_mode: false 103 | 104 | # Bridge config 105 | bridge: 106 | # The user of the bridge. 107 | user: "{{ .UserID }}" 108 | 109 | # Localpart template of MXIDs for iMessage users. 110 | # {{ "{{.}}" }} is replaced with the phone number or email of the iMessage user. 111 | username_template: {{ .BridgeName }}_{{ "{{.}}" }} 112 | # Displayname template for iMessage users. 113 | # {{ "{{.}}" }} is replaced with the contact list name (if available) or username (phone number or email) of the iMessage user. 114 | displayname_template: "{{ "{{.}}" }}" 115 | # Should the bridge create a space and add bridged rooms to it? 116 | personal_filtering_spaces: true 117 | 118 | # Whether or not the bridge should send a read receipt from the bridge bot when a message has been 119 | # sent to iMessage. 120 | delivery_receipts: false 121 | # Whether or not the bridge should send the message status as a custom 122 | # com.beeper.message_send_status event. 123 | message_status_events: true 124 | # Whether or not the bridge should send error notices via m.notice events 125 | # when a message fails to bridge. 126 | send_error_notices: false 127 | # The maximum number of seconds between the message arriving at the 128 | # homeserver and the bridge attempting to send the message. This can help 129 | # prevent messages from being bridged a long time after arriving at the 130 | # homeserver which could cause confusion in the chat history on the remote 131 | # network. Set to 0 to disable. 132 | max_handle_seconds: 60 133 | # Device ID to include in m.bridge data, read by client-integrated Android SMS. 134 | # Not relevant for standalone bridges nor iMessage. 135 | device_id: null 136 | # Whether or not to sync with custom puppets to receive EDUs that are not normally sent to appservices. 137 | sync_with_custom_puppets: false 138 | # Whether or not to update the m.direct account data event when double puppeting is enabled. 139 | # Note that updating the m.direct event is not atomic (except with mautrix-asmux) 140 | # and is therefore prone to race conditions. 141 | sync_direct_chat_list: false 142 | # Shared secret for https://github.com/devture/matrix-synapse-shared-secret-auth 143 | # 144 | # If set, double puppeting will be enabled automatically instead of the user 145 | # having to find an access token and run `login-matrix` manually. 146 | login_shared_secret: appservice 147 | # Homeserver URL for the double puppet. If null, will use the URL set in homeserver -> address 148 | double_puppet_server_url: null 149 | # Backfill settings 150 | backfill: 151 | # Should backfilling be enabled at all? 152 | enable: true 153 | # Maximum number of messages to backfill for new portal rooms. 154 | initial_limit: 100 155 | # Maximum age of chats to sync in days. 156 | initial_sync_max_age: 7 157 | # If a backfilled chat is older than this number of hours, mark it as read even if it's unread on iMessage. 158 | # Set to -1 to let any chat be unread. 159 | unread_hours_threshold: 720 160 | # Use MSC2716 for backfilling? 161 | # 162 | # This requires a server with MSC2716 support, which is currently an experimental feature in Synapse. 163 | # It can be enabled by setting experimental_features -> msc2716_enabled to true in homeserver.yaml. 164 | msc2716: true 165 | # Whether or not the bridge should periodically resync chat and contact info. 166 | periodic_sync: true 167 | # Should the bridge look through joined rooms to find existing portals if the database has none? 168 | # This can be used to recover from bridge database loss. 169 | find_portals_if_db_empty: false 170 | # Media viewer settings. See https://gitlab.com/beeper/media-viewer for more info. 171 | # Used to send media viewer links instead of full files for attachments that are too big for MMS. 172 | media_viewer: 173 | # The address to the media viewer. If null, media viewer links will not be used. 174 | url: https://media.beeper.com 175 | # The homeserver domain to pass to the media viewer to use for downloading media. 176 | # If null, will use the server name configured in the homeserver section. 177 | homeserver: {{ .BeeperDomain }} 178 | # The minimum number of bytes in a file before the bridge switches to using the media viewer when sending MMS. 179 | # Note that for unencrypted files, this will use a direct link to the homeserver rather than the media viewer. 180 | sms_min_size: 409600 181 | # Same as above, but for iMessages. 182 | imessage_min_size: 52428800 183 | # Template text when inserting media viewer URLs. 184 | # %s is replaced with the actual URL. 185 | template: "Full size attachment: %s" 186 | # Should we convert heif images to jpeg before re-uploading? This increases 187 | # compatibility, but adds generation loss (reduces quality). 188 | convert_heif: false 189 | # Should we convert tiff images to jpeg before re-uploading? This increases 190 | # compatibility, but adds generation loss (reduces quality). 191 | convert_tiff: true 192 | # Modern Apple devices tend to use h265 encoding for video, which is a licensed standard and therefore not 193 | # supported by most major browsers. If enabled, all video attachments will be converted according to the 194 | # ffmpeg args. 195 | convert_video: 196 | enabled: false 197 | # Convert to h264 format (supported by all major browsers) at decent quality while retaining original 198 | # audio. Modify these args to do whatever encoding/quality you want. 199 | ffmpeg_args: ["-c:v", "libx264", "-preset", "faster", "-crf", "22", "-c:a", "copy"] 200 | extension: "mp4" 201 | mime_type: "video/mp4" 202 | # The prefix for commands. 203 | command_prefix: "!im" 204 | # Should we rewrite the sender in a DM to match the chat GUID? 205 | # This is helpful when the sender ID shifts depending on the device they use, since 206 | # the bridge is unable to add participants to the chat post-creation. 207 | force_uniform_dm_senders: true 208 | # Should SMS chats always be in the same room as iMessage chats with the same phone number? 209 | disable_sms_portals: false 210 | # iMessage has weird IDs for group chats, so getting all messages in the same MMS group chat into the same Matrix room 211 | # may require rerouting some messages based on the fake ReplyToGUID that iMessage adds. 212 | reroute_mms_group_replies: false 213 | # Whether or not created rooms should have federation enabled. 214 | # If false, created portal rooms will never be federated. 215 | federate_rooms: false 216 | # Send captions in the same message as images using MSC2530? 217 | # This is currently not supported in most clients. 218 | caption_in_message: true 219 | # Should the bridge explicitly set the avatar and room name for private chat portal rooms? 220 | # This is implicitly enabled in encrypted rooms. 221 | private_chat_portal_meta: never 222 | 223 | # End-to-bridge encryption support options. 224 | # See https://docs.mau.fi/bridges/general/end-to-bridge-encryption.html 225 | encryption: 226 | # Allow encryption, work in group chat rooms with e2ee enabled 227 | allow: true 228 | # Default to encryption, force-enable encryption in all portals the bridge creates 229 | # This will cause the bridge bot to be in private chats for the encryption to work properly. 230 | default: true 231 | # Whether or not to use MSC2409/MSC3202 instead of /sync long polling for receiving encryption-related data. 232 | appservice: true 233 | # Require encryption, drop any unencrypted messages. 234 | require: true 235 | # Enable key sharing? If enabled, key requests for rooms where users are in will be fulfilled. 236 | # You must use a client that supports requesting keys from other users to use this feature. 237 | allow_key_sharing: true 238 | # Options for deleting megolm sessions from the bridge. 239 | delete_keys: 240 | # Beeper-specific: delete outbound sessions when hungryserv confirms 241 | # that the user has uploaded the key to key backup. 242 | delete_outbound_on_ack: true 243 | # Don't store outbound sessions in the inbound table. 244 | dont_store_outbound: false 245 | # Ratchet megolm sessions forward after decrypting messages. 246 | ratchet_on_decrypt: true 247 | # Delete fully used keys (index >= max_messages) after decrypting messages. 248 | delete_fully_used_on_decrypt: true 249 | # Delete previous megolm sessions from same device when receiving a new one. 250 | delete_prev_on_new_session: true 251 | # Delete megolm sessions received from a device when the device is deleted. 252 | delete_on_device_delete: true 253 | # Periodically delete megolm sessions when 2x max_age has passed since receiving the session. 254 | periodically_delete_expired: true 255 | # What level of device verification should be required from users? 256 | # 257 | # Valid levels: 258 | # unverified - Send keys to all device in the room. 259 | # cross-signed-untrusted - Require valid cross-signing, but trust all cross-signing keys. 260 | # cross-signed-tofu - Require valid cross-signing, trust cross-signing keys on first use (and reject changes). 261 | # cross-signed-verified - Require valid cross-signing, plus a valid user signature from the bridge bot. 262 | # Note that creating user signatures from the bridge bot is not currently possible. 263 | # verified - Require manual per-device verification 264 | # (currently only possible by modifying the `trust` column in the `crypto_device` database table). 265 | verification_levels: 266 | # Minimum level for which the bridge should send keys to when bridging messages from WhatsApp to Matrix. 267 | receive: cross-signed-tofu 268 | # Minimum level that the bridge should accept for incoming Matrix messages. 269 | send: cross-signed-tofu 270 | # Minimum level that the bridge should require for accepting key requests. 271 | share: cross-signed-tofu 272 | # Options for Megolm room key rotation. These options allow you to 273 | # configure the m.room.encryption event content. See: 274 | # https://spec.matrix.org/v1.3/client-server-api/#mroomencryption for 275 | # more information about that event. 276 | rotation: 277 | # Enable custom Megolm room key rotation settings. Note that these 278 | # settings will only apply to rooms created after this option is 279 | # set. 280 | enable_custom: true 281 | # The maximum number of milliseconds a session should be used 282 | # before changing it. The Matrix spec recommends 604800000 (a week) 283 | # as the default. 284 | milliseconds: 2592000000 285 | # The maximum number of messages that should be sent with a given a 286 | # session before changing it. The Matrix spec recommends 100 as the 287 | # default. 288 | messages: 10000 289 | 290 | # Disable rotating keys when a user's devices change? 291 | # You should not enable this option unless you understand all the implications. 292 | disable_device_change_key_rotation: true 293 | 294 | # Settings for relay mode 295 | relay: 296 | # Whether relay mode should be allowed. 297 | enabled: false 298 | # A list of user IDs and server names who are allowed to be relayed through this bridge. Use * to allow everyone. 299 | whitelist: [] 300 | 301 | # Logging config. See https://github.com/tulir/zeroconfig for details. 302 | logging: 303 | min_level: debug 304 | writers: 305 | - type: stdout 306 | format: pretty-colored 307 | - type: file 308 | format: json 309 | filename: ./logs/mautrix-imessage.log 310 | max_size: 100 311 | max_backups: 10 312 | compress: false 313 | 314 | # This may be used by external config managers. mautrix-imessage does not read it, but will carry it across configuration migrations. 315 | revision: 0 316 | -------------------------------------------------------------------------------- /bridgeconfig/imessagego.tpl.yaml: -------------------------------------------------------------------------------- 1 | # Homeserver details. 2 | homeserver: 3 | # The address that this appservice can use to connect to the homeserver. 4 | address: {{ .HungryAddress }} 5 | # The domain of the homeserver (also known as server_name, used for MXIDs, etc). 6 | domain: beeper.local 7 | 8 | # What software is the homeserver running? 9 | # Standard Matrix homeservers like Synapse, Dendrite and Conduit should just use "standard" here. 10 | software: hungry 11 | # The URL to push real-time bridge status to. 12 | # If set, the bridge will make POST requests to this URL whenever a user's discord connection state changes. 13 | # The bridge will use the appservice as_token to authorize requests. 14 | status_endpoint: null 15 | # Endpoint for reporting per-message status. 16 | message_send_checkpoint_endpoint: null 17 | # Does the homeserver support https://github.com/matrix-org/matrix-spec-proposals/pull/2246? 18 | async_media: true 19 | 20 | # Should the bridge use a websocket for connecting to the homeserver? 21 | # The server side is currently not documented anywhere and is only implemented by mautrix-wsproxy, 22 | # mautrix-asmux (deprecated), and hungryserv (proprietary). 23 | websocket: {{ .Websocket }} 24 | # How often should the websocket be pinged? Pinging will be disabled if this is zero. 25 | ping_interval_seconds: 180 26 | 27 | # Application service host/registration related details. 28 | # Changing these values requires regeneration of the registration. 29 | appservice: 30 | # The address that the homeserver can use to connect to this appservice. 31 | address: null 32 | 33 | # The hostname and port where this appservice should listen. 34 | hostname: {{ if .Websocket }}null{{ else }}{{ .ListenAddr }}{{ end }} 35 | port: {{ if .Websocket }}null{{ else }}{{ .ListenPort }}{{ end }} 36 | 37 | # Database config. 38 | database: 39 | # The database type. Only "sqlite3-fk-wal" is supported. 40 | type: sqlite3-fk-wal 41 | # SQLite database path. A raw file path is supported, but `file:?_txlock=immediate` is recommended. 42 | uri: file:{{.DatabasePrefix}}beeper-imessage.db?_txlock=immediate 43 | 44 | # The unique ID of this appservice. 45 | id: {{ .AppserviceID }} 46 | # Appservice bot details. 47 | bot: 48 | # Username of the appservice bot. 49 | username: {{ .BridgeName }}bot 50 | # Display name and avatar for bot. Set to "remove" to remove display name/avatar, leave empty 51 | # to leave display name/avatar as-is. 52 | displayname: iMessage bridge bot 53 | avatar: mxc://maunium.net/tManJEpANASZvDVzvRvhILdX 54 | 55 | # Whether or not to receive ephemeral events via appservice transactions. 56 | # Requires MSC2409 support (i.e. Synapse 1.22+). 57 | # You should disable bridge -> sync_with_custom_puppets when this is enabled. 58 | ephemeral_events: true 59 | 60 | # Authentication tokens for AS <-> HS communication. Autogenerated; do not modify. 61 | as_token: {{ .ASToken }} 62 | hs_token: {{ .HSToken }} 63 | 64 | # Segment-compatible analytics endpoint for tracking some events, like provisioning API login and encryption errors. 65 | analytics: 66 | # Hostname of the tracking server. The path is hardcoded to /v1/track 67 | host: api.segment.io 68 | # API key to send with tracking requests. Tracking is disabled if this is null. 69 | token: null 70 | # Optional user ID for tracking events. If null, defaults to using Matrix user ID. 71 | user_id: null 72 | 73 | imessage: 74 | device_name: {{ or .Params.device_name "Beeper (self-hosted)" }} 75 | 76 | # Bridge config 77 | bridge: 78 | # Localpart template of MXIDs for iMessage users. 79 | username_template: {{ .BridgeName }}_{{ "{{.}}" }} 80 | # Displayname template for iMessage users. 81 | displayname_template: "{{ "{{.}}" }}" 82 | 83 | # A URL to fetch validation data from. Use this option or the nac_plist option 84 | nac_validation_data_url: {{ or .Params.nac_url "https://registration-relay.beeper.com" }} 85 | # Optional auth token to use when fetching validation data. If null, defaults to passing the as_token. 86 | nac_validation_data_token: {{ .Params.nac_token }} 87 | nac_validation_is_relay: true 88 | 89 | # Servers to always allow double puppeting from 90 | double_puppet_server_map: 91 | {{ .BeeperDomain }}: {{ .HungryAddress }} 92 | # Allow using double puppeting from any server with a valid client .well-known file. 93 | double_puppet_allow_discovery: false 94 | # Shared secrets for https://github.com/devture/matrix-synapse-shared-secret-auth 95 | # 96 | # If set, double puppeting will be enabled automatically for local users 97 | # instead of users having to find an access token and run `login-matrix` 98 | # manually. 99 | login_shared_secret_map: 100 | {{ .BeeperDomain }}: "as_token:{{ .ASToken }}" 101 | 102 | # Should the bridge create a space and add bridged rooms to it? 103 | personal_filtering_spaces: true 104 | # Whether or not the bridge should send a read receipt from the bridge bot when a message has been 105 | # sent to iMessage. 106 | delivery_receipts: false 107 | # Whether or not the bridge should send the message status as a custom 108 | # com.beeper.message_send_status event. 109 | message_status_events: true 110 | # Whether or not the bridge should send error notices via m.notice events 111 | # when a message fails to bridge. 112 | send_error_notices: false 113 | # Enable notices about various things in the bridge management room? 114 | enable_bridge_notices: true 115 | # Enable less important notices (sent with m.notice) in the bridge management room? 116 | unimportant_bridge_notices: true 117 | # The maximum number of seconds between the message arriving at the 118 | # homeserver and the bridge attempting to send the message. This can help 119 | # prevent messages from being bridged a long time after arriving at the 120 | # homeserver which could cause confusion in the chat history on the remote 121 | # network. Set to 0 to disable. 122 | max_handle_seconds: 0 123 | # Should we convert heif images to jpeg before re-uploading? This increases 124 | # compatibility, but adds generation loss (reduces quality). 125 | convert_heif: false 126 | # Should we convert tiff images to jpeg before re-uploading? This increases 127 | # compatibility, but adds generation loss (reduces quality). 128 | convert_tiff: true 129 | # Modern Apple devices tend to use h265 encoding for video, which is a licensed standard and therefore not 130 | # supported by most major browsers. If enabled, all video attachments will be converted according to the 131 | # ffmpeg args. 132 | convert_mov: true 133 | # The prefix for commands. 134 | command_prefix: "!im" 135 | # Whether or not created rooms should have federation enabled. 136 | # If false, created portal rooms will never be federated. 137 | federate_rooms: false 138 | # Whether to explicitly set the avatar and room name for private chat portal rooms. 139 | # If set to `default`, this will be enabled in encrypted rooms and disabled in unencrypted rooms. 140 | # If set to `always`, all DM rooms will have explicit names and avatars set. 141 | # If set to `never`, DM rooms will never have names and avatars set. 142 | private_chat_portal_meta: never 143 | # Should iMessage reply threads be mapped to Matrix threads? If false, iMessage reply threads will be bridged 144 | # as replies to the previous message in the thread. 145 | matrix_threads: false 146 | 147 | # End-to-bridge encryption support options. 148 | # See https://docs.mau.fi/bridges/general/end-to-bridge-encryption.html 149 | encryption: 150 | # Allow encryption, work in group chat rooms with e2ee enabled 151 | allow: true 152 | # Default to encryption, force-enable encryption in all portals the bridge creates 153 | # This will cause the bridge bot to be in private chats for the encryption to work properly. 154 | default: true 155 | # Whether or not to use MSC2409/MSC3202 instead of /sync long polling for receiving encryption-related data. 156 | appservice: true 157 | # Require encryption, drop any unencrypted messages. 158 | require: true 159 | # Enable key sharing? If enabled, key requests for rooms where users are in will be fulfilled. 160 | # You must use a client that supports requesting keys from other users to use this feature. 161 | allow_key_sharing: true 162 | # Options for deleting megolm sessions from the bridge. 163 | delete_keys: 164 | # Beeper-specific: delete outbound sessions when hungryserv confirms 165 | # that the user has uploaded the key to key backup. 166 | delete_outbound_on_ack: true 167 | # Don't store outbound sessions in the inbound table. 168 | dont_store_outbound: false 169 | # Ratchet megolm sessions forward after decrypting messages. 170 | ratchet_on_decrypt: true 171 | # Delete fully used keys (index >= max_messages) after decrypting messages. 172 | delete_fully_used_on_decrypt: true 173 | # Delete previous megolm sessions from same device when receiving a new one. 174 | delete_prev_on_new_session: true 175 | # Delete megolm sessions received from a device when the device is deleted. 176 | delete_on_device_delete: true 177 | # Periodically delete megolm sessions when 2x max_age has passed since receiving the session. 178 | periodically_delete_expired: true 179 | # What level of device verification should be required from users? 180 | # 181 | # Valid levels: 182 | # unverified - Send keys to all device in the room. 183 | # cross-signed-untrusted - Require valid cross-signing, but trust all cross-signing keys. 184 | # cross-signed-tofu - Require valid cross-signing, trust cross-signing keys on first use (and reject changes). 185 | # cross-signed-verified - Require valid cross-signing, plus a valid user signature from the bridge bot. 186 | # Note that creating user signatures from the bridge bot is not currently possible. 187 | # verified - Require manual per-device verification 188 | # (currently only possible by modifying the `trust` column in the `crypto_device` database table). 189 | verification_levels: 190 | # Minimum level for which the bridge should send keys to when bridging messages from iMessage to Matrix. 191 | receive: cross-signed-tofu 192 | # Minimum level that the bridge should accept for incoming Matrix messages. 193 | send: cross-signed-tofu 194 | # Minimum level that the bridge should require for accepting key requests. 195 | share: cross-signed-tofu 196 | # Options for Megolm room key rotation. These options allow you to 197 | # configure the m.room.encryption event content. See: 198 | # https://spec.matrix.org/v1.3/client-server-api/#mroomencryption for 199 | # more information about that event. 200 | rotation: 201 | # Enable custom Megolm room key rotation settings. Note that these 202 | # settings will only apply to rooms created after this option is 203 | # set. 204 | enable_custom: true 205 | # The maximum number of milliseconds a session should be used 206 | # before changing it. The Matrix spec recommends 604800000 (a week) 207 | # as the default. 208 | milliseconds: 2592000000 209 | # The maximum number of messages that should be sent with a given a 210 | # session before changing it. The Matrix spec recommends 100 as the 211 | # default. 212 | messages: 10000 213 | 214 | # Disable rotating keys when a user's devices change? 215 | # You should not enable this option unless you understand all the implications. 216 | disable_device_change_key_rotation: true 217 | 218 | # Settings for provisioning API 219 | provisioning: 220 | # Prefix for the provisioning API paths. 221 | prefix: /_matrix/provision 222 | # Shared secret for authentication. If set to "generate", a random secret will be generated, 223 | # or if set to "disable", the provisioning API will be disabled. 224 | shared_secret: {{ .ProvisioningSecret }} 225 | 226 | # Permissions for using the bridge. 227 | # Permitted values: 228 | # user - Access to use the bridge to chat with a WhatsApp account. 229 | # admin - User level and some additional administration tools 230 | # Permitted keys: 231 | # * - All Matrix users 232 | # domain - All users on that homeserver 233 | # mxid - Specific user 234 | permissions: 235 | "{{ .UserID }}": admin 236 | 237 | # Logging config. See https://github.com/tulir/zeroconfig for details. 238 | logging: 239 | min_level: debug 240 | writers: 241 | - type: stdout 242 | format: pretty-colored 243 | - type: file 244 | format: json 245 | filename: ./logs/beeper-imessage.log 246 | max_size: 100 247 | max_backups: 10 248 | compress: false 249 | -------------------------------------------------------------------------------- /bridgeconfig/linkedin.tpl.yaml: -------------------------------------------------------------------------------- 1 | # Network-specific config options 2 | network: 3 | # Displayname template for LinkedIn users. 4 | # .FirstName is replaced with the first name 5 | # .LastName is replaced with the last name 6 | # .Organization is replaced with the organization name 7 | displayname_template: {{ `"{{ with .Organization }}{{ . }}{{ else }}{{ .FirstName }} {{ .LastName }}{{ end }}"` }} 8 | 9 | sync: 10 | # Number of most recently active dialogs to check when syncing chats. 11 | # Set to 0 to remove limit. 12 | update_limit: 0 13 | # Number of most recently active dialogs to create portals for when syncing 14 | # chats. 15 | # Set to 0 to remove limit. 16 | create_limit: 10 17 | 18 | {{ setfield . "CommandPrefix" "!linkedin" -}} 19 | {{ setfield . "DatabaseFileName" "mautrix-linkedin" -}} 20 | {{ setfield . "BridgeTypeName" "LinkedIn" -}} 21 | {{ setfield . "BridgeTypeIcon" "mxc://nevarro.space/cwsWnmeMpWSMZLUNblJHaIvP" -}} 22 | {{ setfield . "DefaultPickleKey" "mautrix.bridge.e2ee" -}} 23 | {{ template "bridgev2.tpl.yaml" . }} 24 | -------------------------------------------------------------------------------- /bridgeconfig/meta.tpl.yaml: -------------------------------------------------------------------------------- 1 | # Network-specific config options 2 | network: 3 | # Which service is this bridge for? Available options: 4 | # * unset - allow users to pick any service when logging in (except facebook-tor) 5 | # * facebook - connect to FB Messenger via facebook.com 6 | # * facebook-tor - connect to FB Messenger via facebookwkhpilnemxj7asaniu7vnjjbiltxjqhye3mhbshg7kx5tfyd.onion 7 | # (note: does not currently proxy media downloads) 8 | # * messenger - connect to FB Messenger via messenger.com (can be used with the facebook side deactivated) 9 | # * instagram - connect to Instagram DMs via instagram.com 10 | # 11 | # Remember to change the appservice id, bot profile info, bridge username_template and management_room_text too. 12 | mode: {{ .Params.meta_platform }} 13 | # When in Instagram mode, should the bridge connect to WhatsApp servers for encrypted chats? 14 | # In FB/Messenger mode encryption is always enabled, this option only affects Instagram mode. 15 | ig_e2ee: false 16 | # Displayname template for FB/IG users. Available variables: 17 | # .DisplayName - The display name set by the user. 18 | # .Username - The username set by the user. 19 | # .ID - The internal user ID of the user. 20 | displayname_template: {{ `'{{or .DisplayName .Username "Unknown user"}}'` }} 21 | # Static proxy address (HTTP or SOCKS5) for connecting to Meta. 22 | proxy: 23 | # HTTP endpoint to request new proxy address from, for dynamically assigned proxies. 24 | # The endpoint must return a JSON body with a string field called proxy_url. 25 | get_proxy_from: 26 | # Minimum interval between full reconnects in seconds, default is 1 hour 27 | min_full_reconnect_interval_seconds: 3600 28 | # Interval to force refresh the connection (full reconnect), default is 20 hours. Set 0 to disable force refreshes. 29 | force_refresh_interval_seconds: 72000 30 | # Disable fetching XMA media (reels, stories, etc) when backfilling. 31 | disable_xma_backfill: true 32 | # Disable fetching XMA media entirely. 33 | disable_xma_always: false 34 | 35 | {{ setfield . "DatabaseFileName" "mautrix-meta" -}} 36 | {{ setfield . "DefaultPickleKey" "mautrix.bridge.e2ee" -}} 37 | {{ if eq .Params.meta_platform "facebook" "facebook-tor" "messenger" -}} 38 | {{ setfield . "CommandPrefix" "!fb" -}} 39 | {{ setfield . "BridgeTypeName" "Facebook" -}} 40 | {{ setfield . "BridgeTypeIcon" "mxc://maunium.net/ygtkteZsXnGJLJHRchUwYWak" -}} 41 | {{ else if eq .Params.meta_platform "instagram" -}} 42 | {{ setfield . "CommandPrefix" "!ig" -}} 43 | {{ setfield . "BridgeTypeName" "Instagram" -}} 44 | {{ setfield . "BridgeTypeIcon" "mxc://maunium.net/JxjlbZUlCPULEeHZSwleUXQv" -}} 45 | {{ else -}} 46 | {{ setfield . "CommandPrefix" "!meta" -}} 47 | {{ setfield . "BridgeTypeName" "Meta" -}} 48 | {{ setfield . "BridgeTypeIcon" "mxc://maunium.net/DxpVrwwzPUwaUSazpsjXgcKB" -}} 49 | {{ end -}} 50 | {{ template "bridgev2.tpl.yaml" . }} 51 | -------------------------------------------------------------------------------- /bridgeconfig/signal.tpl.yaml: -------------------------------------------------------------------------------- 1 | # Network-specific config options 2 | network: 3 | # Displayname template for Signal users. 4 | displayname_template: {{ `'{{or .ContactName .ProfileName .PhoneNumber "Unknown user" }}'` }} 5 | # Should avatars from the user's contact list be used? This is not safe on multi-user instances. 6 | use_contact_avatars: true 7 | # Should the bridge sync ghost user info even if profile fetching fails? This is not safe on multi-user instances. 8 | use_outdated_profiles: true 9 | # Should the Signal user's phone number be included in the room topic in private chat portal rooms? 10 | number_in_topic: true 11 | # Default device name that shows up in the Signal app. 12 | device_name: {{ or .Params.device_name "Beeper (self-hosted, v2)" }} 13 | # Avatar image for the Note to Self room. 14 | note_to_self_avatar: mxc://maunium.net/REBIVrqjZwmaWpssCZpBlmlL 15 | # Format for generating URLs from location messages for sending to Signal. 16 | # Google Maps: 'https://www.google.com/maps/place/%[1]s,%[2]s' 17 | # OpenStreetMap: 'https://www.openstreetmap.org/?mlat=%[1]s&mlon=%[2]s' 18 | location_format: 'https://www.google.com/maps/place/%[1]s,%[2]s' 19 | 20 | {{ setfield . "CommandPrefix" "!signal" -}} 21 | {{ setfield . "DatabaseFileName" "mautrix-signal" -}} 22 | {{ setfield . "BridgeTypeName" "Signal" -}} 23 | {{ setfield . "BridgeTypeIcon" "mxc://maunium.net/wPJgTQbZOtpBFmDNkiNEMDUp" -}} 24 | {{ setfield . "DefaultPickleKey" "mautrix.bridge.e2ee" -}} 25 | {{ template "bridgev2.tpl.yaml" . }} 26 | -------------------------------------------------------------------------------- /bridgeconfig/slack.tpl.yaml: -------------------------------------------------------------------------------- 1 | network: 2 | # Displayname template for Slack users. Available variables: 3 | # .Name - The username of the user 4 | # .ID - The internal ID of the user 5 | # .IsBot - Whether the user is a bot 6 | # .Profile.DisplayName - The username or real name of the user (depending on settings) 7 | # Variables only available for users (not bots): 8 | # .TeamID - The internal ID of the workspace the user is in 9 | # .TZ - The timezone region of the user (e.g. Europe/London) 10 | # .TZLabel - The label of the timezone of the user (e.g. Greenwich Mean Time) 11 | # .TZOffset - The UTC offset of the timezone of the user (e.g. 0) 12 | # .Profile.RealName - The real name of the user 13 | # .Profile.FirstName - The first name of the user 14 | # .Profile.LastName - The last name of the user 15 | # .Profile.Title - The job title of the user 16 | # .Profile.Pronouns - The pronouns of the user 17 | # .Profile.Email - The email address of the user 18 | # .Profile.Phone - The formatted phone number of the user 19 | displayname_template: '{{ `{{.Profile.DisplayName}}{{if .IsBot}} (bot){{end}}` }}' 20 | # Channel name template for Slack channels (all types). Available variables: 21 | # .Name - The name of the channel 22 | # .TeamName - The name of the team the channel is in 23 | # .TeamDomain - The Slack subdomain of the team the channel is in 24 | # .ID - The internal ID of the channel 25 | # .IsNoteToSelf - Whether the channel is a DM with yourself 26 | # .IsGeneral - Whether the channel is the #general channel 27 | # .IsChannel - Whether the channel is a channel (rather than a DM) 28 | # .IsPrivate - Whether the channel is private 29 | # .IsIM - Whether the channel is a one-to-one DM 30 | # .IsMpIM - Whether the channel is a group DM 31 | # .IsShared - Whether the channel is shared with another workspace. 32 | # .IsExtShared - Whether the channel is shared with an external organization. 33 | # .IsOrgShared - Whether the channel is shared with an organization in the same enterprise grid. 34 | channel_name_template: '{{ `{{if or .IsNoteToSelf (not .IsIM)}}{{if and .IsChannel (not .IsPrivate)}}#{{end}}{{.Name}}{{if .IsNoteToSelf}} (you){{end}}{{end}}` }}' 35 | # Displayname template for Slack workspaces. Available variables: 36 | # .Name - The name of the team 37 | # .Domain - The Slack subdomain of the team 38 | # .ID - The internal ID of the team 39 | team_name_template: '{{ `{{.Name}}` }}' 40 | # Should incoming custom emoji reactions be bridged as mxc:// URIs? 41 | # If set to false, custom emoji reactions will be bridged as the shortcode instead, and the image won't be available. 42 | custom_emoji_reactions: true 43 | # Should channels and group DMs have the workspace icon as the Matrix room avatar? 44 | workspace_avatar_in_rooms: false 45 | # Number of participants to sync in channels (doesn't affect group DMs) 46 | participant_sync_count: 5 47 | # Should channel participants only be synced when creating the room? 48 | # If you want participants to always be accurately synced, set participant_sync_count to a high value and this to false. 49 | participant_sync_only_on_create: true 50 | # Options for backfilling messages from Slack. 51 | backfill: 52 | # Number of conversations to fetch from Slack when syncing workspace. 53 | # This option applies even if message backfill is disabled below. 54 | # If set to -1, all chats in the client.boot response will be bridged, and nothing will be fetched separately. 55 | conversation_count: -1 56 | 57 | {{ setfield . "CommandPrefix" "!slack" -}} 58 | {{ setfield . "DatabaseFileName" "mautrix-slack" -}} 59 | {{ setfield . "BridgeTypeName" "Slack" -}} 60 | {{ setfield . "BridgeTypeIcon" "mxc://maunium.net/pVtzLmChZejGxLqmXtQjFxem" -}} 61 | {{ template "bridgev2.tpl.yaml" . }} 62 | -------------------------------------------------------------------------------- /bridgeconfig/twitter.tpl.yaml: -------------------------------------------------------------------------------- 1 | # Network-specific config options 2 | network: 3 | # Displayname template for Twitter users. 4 | # .DisplayName is replaced with the display name of the Twitter user. 5 | # .Username is replaced with the username of the Twitter user. 6 | displayname_template: {{ `"{{ .DisplayName }}"` }} 7 | 8 | {{ setfield . "CommandPrefix" "!tw" -}} 9 | {{ setfield . "DatabaseFileName" "mautrix-twitter" -}} 10 | {{ setfield . "BridgeTypeName" "Twitter" -}} 11 | {{ setfield . "BridgeTypeIcon" "mxc://maunium.net/HVHcnusJkQcpVcsVGZRELLCn" -}} 12 | {{ setfield . "DefaultPickleKey" "mautrix.bridge.e2ee" -}} 13 | {{ template "bridgev2.tpl.yaml" . }} 14 | -------------------------------------------------------------------------------- /bridgeconfig/whatsapp.tpl.yaml: -------------------------------------------------------------------------------- 1 | # Network-specific config options 2 | network: 3 | # Device name that's shown in the "WhatsApp Web" section in the mobile app. 4 | os_name: Beeper (self-hosted) 5 | # Browser name that determines the logo shown in the mobile app. 6 | # Must be "unknown" for a generic icon or a valid browser name if you want a specific icon. 7 | # List of valid browser names: https://github.com/tulir/whatsmeow/blob/efc632c008604016ddde63bfcfca8de4e5304da9/binary/proto/def.proto#L43-L64 8 | browser_name: unknown 9 | 10 | # Proxy to use for all WhatsApp connections. 11 | proxy: null 12 | # Alternative to proxy: an HTTP endpoint that returns the proxy URL to use for WhatsApp connections. 13 | get_proxy_url: null 14 | # Whether the proxy options should only apply to the login websocket and not to authenticated connections. 15 | proxy_only_login: false 16 | 17 | # Displayname template for WhatsApp users. 18 | # .PushName - nickname set by the WhatsApp user 19 | # .BusinessName - validated WhatsApp business name 20 | # .Phone - phone number (international format) 21 | # .FullName - Name you set in the contacts list 22 | displayname_template: {{ `"{{or .FullName .BusinessName .PushName .JID}}"` }} 23 | 24 | # Should incoming calls send a message to the Matrix room? 25 | call_start_notices: true 26 | # Should another user's cryptographic identity changing send a message to Matrix? 27 | identity_change_notices: false 28 | # Send the presence as "available" to whatsapp when users start typing on a portal. 29 | # This works as a workaround for homeservers that do not support presence, and allows 30 | # users to see when the whatsapp user on the other side is typing during a conversation. 31 | send_presence_on_typing: false 32 | # Should WhatsApp status messages be bridged into a Matrix room? 33 | # Disabling this won't affect already created status broadcast rooms. 34 | enable_status_broadcast: true 35 | # Should sending WhatsApp status messages be allowed? 36 | # This can cause issues if the user has lots of contacts, so it's disabled by default. 37 | disable_status_broadcast_send: true 38 | # Should the status broadcast room be muted and moved into low priority by default? 39 | # This is only applied when creating the room, the user can unmute it later. 40 | mute_status_broadcast: true 41 | # Tag to apply to the status broadcast room. 42 | status_broadcast_tag: m.lowpriority 43 | # Should the bridge use thumbnails from WhatsApp? 44 | # They're disabled by default due to very low resolution. 45 | whatsapp_thumbnail: false 46 | # Should the bridge detect URLs in outgoing messages, ask the homeserver to generate a preview, 47 | # and send it to WhatsApp? URL previews can always be sent using the `com.beeper.linkpreviews` 48 | # key in the event content even if this is disabled. 49 | url_previews: false 50 | # Should the bridge always send "active" delivery receipts (two gray ticks on WhatsApp) 51 | # even if the user isn't marked as online (e.g. when presence bridging isn't enabled)? 52 | # 53 | # By default, the bridge acts like WhatsApp web, which only sends active delivery 54 | # receipts when it's in the foreground. 55 | force_active_delivery_receipts: false 56 | # Settings for converting animated stickers. 57 | animated_sticker: 58 | # Format to which animated stickers should be converted. 59 | # disable - No conversion, just unzip and send raw lottie JSON 60 | # png - converts to non-animated png (fastest) 61 | # gif - converts to animated gif 62 | # webm - converts to webm video, requires ffmpeg executable with vp9 codec and webm container support 63 | # webp - converts to animated webp, requires ffmpeg executable with webp codec/container support 64 | target: webp 65 | # Arguments for converter. All converters take width and height. 66 | args: 67 | width: 320 68 | height: 320 69 | fps: 25 # only for webm, webp and gif (2, 5, 10, 20 or 25 recommended) 70 | 71 | # Settings for handling history sync payloads. 72 | history_sync: 73 | # How many conversations should the bridge create after login? 74 | # If -1, all conversations received from history sync will be bridged. 75 | # Other conversations will be backfilled on demand when receiving a message. 76 | max_initial_conversations: -1 77 | # Should the bridge request a full sync from the phone when logging in? 78 | # This bumps the size of history syncs from 3 months to 1 year. 79 | request_full_sync: true 80 | # Configuration parameters that are sent to the phone along with the request full sync flag. 81 | # By default, (when the values are null or 0), the config isn't sent at all. 82 | full_sync_config: 83 | # Number of days of history to request. 84 | # The limit seems to be around 3 years, but using higher values doesn't break. 85 | days_limit: 1825 86 | # This is presumably the maximum size of the transferred history sync blob, which may affect what the phone includes in the blob. 87 | size_mb_limit: 512 88 | # This is presumably the local storage quota, which may affect what the phone includes in the history sync blob. 89 | storage_quota_mb: 16384 90 | # Settings for media requests. If the media expired, then it will not be on the WA servers. 91 | # Media can always be requested by reacting with the ♻️ (recycle) emoji. 92 | # These settings determine if the media requests should be done automatically during or after backfill. 93 | media_requests: 94 | # Should the expired media be automatically requested from the server as part of the backfill process? 95 | auto_request_media: true 96 | # Whether to request the media immediately after the media message is backfilled ("immediate") 97 | # or at a specific time of the day ("local_time"). 98 | request_method: immediate 99 | # If request_method is "local_time", what time should the requests be sent (in minutes after midnight)? 100 | request_local_time: 120 101 | # Maximum number of media request responses to handle in parallel per user. 102 | max_async_handle: 2 103 | 104 | {{ setfield . "CommandPrefix" "!wa" -}} 105 | {{ setfield . "DatabaseFileName" "mautrix-whatsapp" -}} 106 | {{ setfield . "BridgeTypeName" "WhatsApp" -}} 107 | {{ setfield . "BridgeTypeIcon" "mxc://maunium.net/NeXNQarUbrlYBiPCpprYsRqr" -}} 108 | {{ setfield . "DefaultPickleKey" "maunium.net/go/mautrix-whatsapp" -}} 109 | {{ template "bridgev2.tpl.yaml" . }} 110 | -------------------------------------------------------------------------------- /build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | go build -ldflags "-X main.Tag=$(git describe --exact-match --tags 2>/dev/null) -X main.Commit=$(git rev-parse HEAD) -X 'main.BuildTime=`date -Iseconds`'" "$@" github.com/beeper/bridge-manager/cmd/bbctl 3 | -------------------------------------------------------------------------------- /ci-build-all.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | GOOS=linux GOARCH=amd64 ./build.sh -o bbctl-linux-amd64 3 | GOOS=linux GOARCH=arm64 ./build.sh -o bbctl-linux-arm64 4 | GOOS=darwin GOARCH=amd64 ./build.sh -o bbctl-macos-amd64 5 | GOOS=darwin GOARCH=arm64 ./build.sh -o bbctl-macos-arm64 6 | -------------------------------------------------------------------------------- /cli/hyper/link.go: -------------------------------------------------------------------------------- 1 | package hyper 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/fatih/color" 7 | ) 8 | 9 | const OSC = "\x1b]" 10 | const OSC8 = OSC + "8" 11 | const ST = "\x07" // or "\x1b\\" 12 | const URLTemplate = OSC8 + ";%s;%s" + ST + "%s" + OSC8 + ";;" + ST 13 | 14 | func Link(text string, url string, important bool) string { 15 | if color.NoColor { 16 | if !important { 17 | return text 18 | } 19 | return fmt.Sprintf("%s (%s)", text, url) 20 | } 21 | params := "" 22 | return fmt.Sprintf(URLTemplate, params, url, text) 23 | } 24 | -------------------------------------------------------------------------------- /cli/interactive/flag.go: -------------------------------------------------------------------------------- 1 | package interactive 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/AlecAivazis/survey/v2" 7 | "github.com/urfave/cli/v2" 8 | ) 9 | 10 | type Flag struct { 11 | cli.Flag 12 | Survey survey.Prompt 13 | Validator survey.Validator 14 | Transform survey.Transformer 15 | } 16 | 17 | type settableContext struct { 18 | *cli.Context 19 | } 20 | 21 | func (sc *settableContext) WriteAnswer(field string, value interface{}) error { 22 | switch typedValue := value.(type) { 23 | case string: 24 | return sc.Set(field, typedValue) 25 | case []string: 26 | for _, item := range typedValue { 27 | if err := sc.Set(field, item); err != nil { 28 | return err 29 | } 30 | } 31 | return nil 32 | case int, uint, int8, uint8, int16, uint16, int32, uint32, int64, uint64: 33 | return sc.Set(field, fmt.Sprintf("%d", typedValue)) 34 | default: 35 | return fmt.Errorf("unsupported type %T", value) 36 | } 37 | } 38 | 39 | func Ask(ctx *cli.Context) error { 40 | var questions []*survey.Question 41 | for _, subCtx := range ctx.Lineage() { 42 | var flags []cli.Flag 43 | if subCtx.Command != nil { 44 | flags = subCtx.Command.Flags 45 | } else if subCtx.App != nil { 46 | flags = subCtx.App.Flags 47 | } else { 48 | return nil 49 | } 50 | for _, flag := range flags { 51 | interactiveFlag, ok := flag.(Flag) 52 | if !ok || flag.IsSet() || interactiveFlag.Survey == nil { 53 | continue 54 | } 55 | questions = append(questions, &survey.Question{ 56 | Name: flag.Names()[0], 57 | Prompt: interactiveFlag.Survey, 58 | Validate: interactiveFlag.Validator, 59 | Transform: interactiveFlag.Transform, 60 | }) 61 | var output string 62 | err := survey.AskOne(interactiveFlag.Survey, &output) 63 | if err != nil { 64 | return err 65 | } 66 | err = subCtx.Set(flag.Names()[0], output) 67 | if err != nil { 68 | return err 69 | } 70 | } 71 | } 72 | if len(questions) > 0 { 73 | err := survey.Ask(questions, &settableContext{ctx}) 74 | if err != nil { 75 | return err 76 | } 77 | } 78 | return nil 79 | } 80 | -------------------------------------------------------------------------------- /cmd/bbctl/authconfig.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "os" 8 | "path" 9 | "path/filepath" 10 | "runtime" 11 | "strings" 12 | 13 | "go.mau.fi/util/random" 14 | "maunium.net/go/mautrix/id" 15 | 16 | "github.com/beeper/bridge-manager/log" 17 | ) 18 | 19 | var envs = map[string]string{ 20 | "prod": "beeper.com", 21 | "staging": "beeper-staging.com", 22 | "dev": "beeper-dev.com", 23 | "local": "beeper.localtest.me", 24 | } 25 | 26 | type EnvConfig struct { 27 | ClusterID string `json:"cluster_id"` 28 | Username string `json:"username"` 29 | AccessToken string `json:"access_token"` 30 | BridgeDataDir string `json:"bridge_data_dir"` 31 | DatabaseDir string `json:"database_dir,omitempty"` 32 | } 33 | 34 | func (ec *EnvConfig) HasCredentials() bool { 35 | return strings.HasPrefix(ec.AccessToken, "syt_") 36 | } 37 | 38 | type EnvConfigs map[string]*EnvConfig 39 | 40 | func (ec EnvConfigs) Get(env string) *EnvConfig { 41 | conf, ok := ec[env] 42 | if !ok { 43 | conf = &EnvConfig{} 44 | ec[env] = conf 45 | } 46 | return conf 47 | } 48 | 49 | type Config struct { 50 | DeviceID id.DeviceID `json:"device_id"` 51 | Environments EnvConfigs `json:"environments"` 52 | Path string `json:"-"` 53 | } 54 | 55 | var UserDataDir string 56 | 57 | func getUserDataDir() (dir string, err error) { 58 | dir = os.Getenv("BBCTL_DATA_HOME") 59 | if dir != "" { 60 | return 61 | } 62 | if runtime.GOOS == "windows" || runtime.GOOS == "darwin" { 63 | return os.UserConfigDir() 64 | } 65 | dir = os.Getenv("XDG_DATA_HOME") 66 | if dir == "" { 67 | dir = os.Getenv("HOME") 68 | if dir == "" { 69 | return "", errors.New("neither $XDG_DATA_HOME nor $HOME are defined") 70 | } 71 | dir = filepath.Join(dir, ".local", "share") 72 | } 73 | return 74 | } 75 | 76 | func init() { 77 | var err error 78 | UserDataDir, err = getUserDataDir() 79 | if err != nil { 80 | panic(fmt.Errorf("couldn't find data directory: %w", err)) 81 | } 82 | } 83 | 84 | func migrateOldConfig(currentPath string) error { 85 | baseConfigDir, err := os.UserConfigDir() 86 | if err != nil { 87 | panic(err) 88 | } 89 | newDefault := path.Join(baseConfigDir, "bbctl", "config.json") 90 | oldDefault := path.Join(baseConfigDir, "bbctl.json") 91 | if currentPath != newDefault { 92 | return nil 93 | } else if _, err = os.Stat(oldDefault); err != nil { 94 | return nil 95 | } else if err = os.MkdirAll(filepath.Dir(newDefault), 0700); err != nil { 96 | return err 97 | } else if err = os.Rename(oldDefault, newDefault); err != nil { 98 | return err 99 | } else { 100 | log.Printf("Moved config to new path (from %s to %s)", oldDefault, newDefault) 101 | return nil 102 | } 103 | } 104 | 105 | func loadConfig(path string) (ret *Config, err error) { 106 | defer func() { 107 | if ret == nil { 108 | return 109 | } 110 | ret.Path = path 111 | if ret.DeviceID == "" { 112 | ret.DeviceID = id.DeviceID("bbctl_" + strings.ToUpper(random.String(8))) 113 | } 114 | if ret.Environments == nil { 115 | ret.Environments = make(EnvConfigs) 116 | } 117 | for key, env := range ret.Environments { 118 | if env == nil { 119 | delete(ret.Environments, key) 120 | continue 121 | } 122 | if env.BridgeDataDir == "" { 123 | env.BridgeDataDir = filepath.Join(UserDataDir, "bbctl", key) 124 | saveErr := ret.Save() 125 | if saveErr != nil { 126 | err = fmt.Errorf("failed to save config after updating data directory: %w", err) 127 | } 128 | } 129 | } 130 | }() 131 | 132 | err = migrateOldConfig(path) 133 | if err != nil { 134 | return nil, fmt.Errorf("failed to move config to new path: %w", err) 135 | } 136 | file, err := os.Open(path) 137 | if errors.Is(err, os.ErrNotExist) { 138 | return &Config{}, nil 139 | } else if err != nil { 140 | return nil, fmt.Errorf("failed to open config at %s for reading: %v", path, err) 141 | } 142 | var cfg Config 143 | err = json.NewDecoder(file).Decode(&cfg) 144 | if err != nil { 145 | return nil, fmt.Errorf("failed to parse config at %s: %v", path, err) 146 | } 147 | return &cfg, nil 148 | } 149 | 150 | func (cfg *Config) Save() error { 151 | dirName := filepath.Dir(cfg.Path) 152 | err := os.MkdirAll(dirName, 0700) 153 | if err != nil { 154 | return fmt.Errorf("failed to create config directory at %s: %w", dirName, err) 155 | } 156 | file, err := os.OpenFile(cfg.Path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) 157 | if err != nil { 158 | return fmt.Errorf("failed to open config at %s for writing: %v", cfg.Path, err) 159 | } 160 | err = json.NewEncoder(file).Encode(cfg) 161 | if err != nil { 162 | return fmt.Errorf("failed to write config to %s: %v", cfg.Path, err) 163 | } 164 | return nil 165 | } 166 | -------------------------------------------------------------------------------- /cmd/bbctl/bridgeutil.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "regexp" 7 | "strings" 8 | 9 | "github.com/AlecAivazis/survey/v2" 10 | "github.com/fatih/color" 11 | "github.com/urfave/cli/v2" 12 | 13 | "github.com/beeper/bridge-manager/bridgeconfig" 14 | ) 15 | 16 | var allowedBridgeRegex = regexp.MustCompile("^[a-z0-9-]{1,32}$") 17 | 18 | type bridgeTypeToNames struct { 19 | typeName string 20 | names []string 21 | } 22 | 23 | var officialBridges = []bridgeTypeToNames{ 24 | {"discord", []string{"discord"}}, 25 | {"meta", []string{"meta", "instagram", "facebook"}}, 26 | {"googlechat", []string{"googlechat", "gchat"}}, 27 | {"imessagego", []string{"imessagego"}}, 28 | {"imessage", []string{"imessage"}}, 29 | {"linkedin", []string{"linkedin"}}, 30 | {"signal", []string{"signal"}}, 31 | {"slack", []string{"slack"}}, 32 | {"telegram", []string{"telegram"}}, 33 | {"twitter", []string{"twitter"}}, 34 | {"whatsapp", []string{"whatsapp"}}, 35 | {"heisenbridge", []string{"irc", "heisenbridge"}}, 36 | {"gmessages", []string{"gmessages", "googlemessages", "rcs", "sms"}}, 37 | {"gvoice", []string{"gvoice", "googlevoice"}}, 38 | {"bluesky", []string{"bluesky", "bsky"}}, 39 | } 40 | 41 | var websocketBridges = map[string]bool{ 42 | "discord": true, 43 | "slack": true, 44 | "whatsapp": true, 45 | "gmessages": true, 46 | "gvoice": true, 47 | "heisenbridge": true, 48 | "imessage": true, 49 | "imessagego": true, 50 | "signal": true, 51 | "bridgev2": true, 52 | "meta": true, 53 | "twitter": true, 54 | "bluesky": true, 55 | "linkedin": true, 56 | } 57 | 58 | func doOutputFile(ctx *cli.Context, name, data string) error { 59 | outputPath := ctx.String("output") 60 | if outputPath == "-" { 61 | _, _ = fmt.Fprintln(os.Stderr, color.YellowString(name+" file:")) 62 | fmt.Println(strings.TrimRight(data, "\n")) 63 | } else { 64 | err := os.WriteFile(outputPath, []byte(data), 0600) 65 | if err != nil { 66 | return fmt.Errorf("failed to write %s to %s: %w", strings.ToLower(name), outputPath, err) 67 | } 68 | _, _ = fmt.Fprintln(os.Stderr, color.YellowString("Wrote "+strings.ToLower(name)+" file to"), color.CyanString(outputPath)) 69 | } 70 | return nil 71 | } 72 | 73 | func validateBridgeName(ctx *cli.Context, bridge string) error { 74 | if !allowedBridgeRegex.MatchString(bridge) { 75 | return UserError{"Invalid bridge name. Names must consist of 1-32 lowercase ASCII letters, digits and -."} 76 | } 77 | if !strings.HasPrefix(bridge, "sh-") { 78 | if !ctx.Bool("force") { 79 | return UserError{"Self-hosted bridge names should start with sh-"} 80 | } 81 | _, _ = fmt.Fprintln(os.Stderr, "Self-hosted bridge names should start with sh-") 82 | } 83 | return nil 84 | } 85 | 86 | func guessOrAskBridgeType(bridge, bridgeType string) (string, error) { 87 | if bridgeType == "" { 88 | Outer: 89 | for _, br := range officialBridges { 90 | for _, name := range br.names { 91 | if strings.Contains(bridge, name) { 92 | bridgeType = br.typeName 93 | break Outer 94 | } 95 | } 96 | } 97 | } 98 | if !bridgeconfig.IsSupported(bridgeType) { 99 | _, _ = fmt.Fprintln(os.Stderr, color.YellowString("Unsupported bridge type"), color.CyanString(bridgeType)) 100 | err := survey.AskOne(&survey.Select{ 101 | Message: "Select bridge type:", 102 | Options: bridgeconfig.SupportedBridges, 103 | }, &bridgeType) 104 | if err != nil { 105 | return "", err 106 | } 107 | } 108 | return bridgeType, nil 109 | } 110 | -------------------------------------------------------------------------------- /cmd/bbctl/config.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "crypto/aes" 5 | "encoding/base64" 6 | "encoding/hex" 7 | "fmt" 8 | "hash/crc32" 9 | "os" 10 | "path/filepath" 11 | "runtime" 12 | "strings" 13 | 14 | "github.com/AlecAivazis/survey/v2" 15 | "github.com/fatih/color" 16 | "github.com/urfave/cli/v2" 17 | "golang.org/x/exp/maps" 18 | 19 | "github.com/beeper/bridge-manager/bridgeconfig" 20 | "github.com/beeper/bridge-manager/cli/hyper" 21 | ) 22 | 23 | var configCommand = &cli.Command{ 24 | Name: "config", 25 | Aliases: []string{"c"}, 26 | Usage: "Generate a config for an official Beeper bridge", 27 | ArgsUsage: "BRIDGE", 28 | Before: RequiresAuth, 29 | Flags: []cli.Flag{ 30 | &cli.StringFlag{ 31 | Name: "type", 32 | Aliases: []string{"t"}, 33 | EnvVars: []string{"BEEPER_BRIDGE_TYPE"}, 34 | Usage: "The type of bridge being registered.", 35 | }, 36 | &cli.StringSliceFlag{ 37 | Name: "param", 38 | Aliases: []string{"p"}, 39 | Usage: "Set a bridge-specific config generation option. Can be specified multiple times for different keys. Format: key=value", 40 | }, 41 | &cli.StringFlag{ 42 | Name: "output", 43 | Aliases: []string{"o"}, 44 | Value: "-", 45 | EnvVars: []string{"BEEPER_BRIDGE_CONFIG_FILE"}, 46 | Usage: "Path to save generated config file to.", 47 | }, 48 | &cli.BoolFlag{ 49 | Name: "force", 50 | Aliases: []string{"f"}, 51 | Usage: "Force register a bridge without the sh- prefix (dangerous).", 52 | Hidden: true, 53 | }, 54 | &cli.BoolFlag{ 55 | Name: "no-state", 56 | Usage: "Don't send a bridge state update (dangerous).", 57 | Hidden: true, 58 | }, 59 | }, 60 | Action: generateBridgeConfig, 61 | } 62 | 63 | func simpleDescriptions(descs map[string]string) func(string, int) string { 64 | return func(s string, i int) string { 65 | return descs[s] 66 | } 67 | } 68 | 69 | var askParams = map[string]func(string, map[string]string) (bool, error){ 70 | "meta": func(bridgeName string, extraParams map[string]string) (bool, error) { 71 | metaPlatform := extraParams["meta_platform"] 72 | changed := false 73 | if metaPlatform == "" { 74 | if strings.Contains(bridgeName, "facebook-tor") || strings.Contains(bridgeName, "facebooktor") { 75 | metaPlatform = "facebook-tor" 76 | } else if strings.Contains(bridgeName, "facebook") { 77 | metaPlatform = "facebook" 78 | } else if strings.Contains(bridgeName, "messenger") { 79 | metaPlatform = "messenger" 80 | } else if strings.Contains(bridgeName, "instagram") { 81 | metaPlatform = "instagram" 82 | } else { 83 | extraParams["meta_platform"] = "" 84 | return false, nil 85 | } 86 | extraParams["meta_platform"] = metaPlatform 87 | } else if metaPlatform != "instagram" && metaPlatform != "facebook" && metaPlatform != "facebook-tor" && metaPlatform != "messenger" { 88 | return false, UserError{"Invalid Meta platform specified"} 89 | } 90 | if metaPlatform == "facebook-tor" { 91 | proxy := extraParams["proxy"] 92 | if proxy == "" { 93 | err := survey.AskOne(&survey.Input{ 94 | Message: "Enter Tor proxy address", 95 | Default: "socks5://localhost:1080", 96 | }, &proxy) 97 | if err != nil { 98 | return false, err 99 | } 100 | extraParams["proxy"] = proxy 101 | changed = true 102 | } 103 | } 104 | return changed, nil 105 | }, 106 | "imessagego": func(bridgeName string, extraParams map[string]string) (bool, error) { 107 | nacToken := extraParams["nac_token"] 108 | var didAddParams bool 109 | if nacToken == "" { 110 | err := survey.AskOne(&survey.Input{ 111 | Message: "Enter iMessage registration code", 112 | }, &nacToken) 113 | if err != nil { 114 | return didAddParams, err 115 | } 116 | extraParams["nac_token"] = nacToken 117 | didAddParams = true 118 | } 119 | return didAddParams, nil 120 | }, 121 | "imessage": func(bridgeName string, extraParams map[string]string) (bool, error) { 122 | platform := extraParams["imessage_platform"] 123 | barcelonaPath := extraParams["barcelona_path"] 124 | bbURL := extraParams["bluebubbles_url"] 125 | bbPassword := extraParams["bluebubbles_password"] 126 | var didAddParams bool 127 | if runtime.GOOS != "darwin" && platform == "" { 128 | // Linux can't run the other connectors 129 | platform = "bluebubbles" 130 | } 131 | if platform == "" { 132 | err := survey.AskOne(&survey.Select{ 133 | Message: "Select iMessage connector:", 134 | Options: []string{"mac", "mac-nosip", "bluebubbles"}, 135 | Description: simpleDescriptions(map[string]string{ 136 | "mac": "Use AppleScript to send messages and read chat.db for incoming data - only requires Full Disk Access (from system settings)", 137 | "mac-nosip": "Use Barcelona to interact with private APIs - requires disabling SIP and AMFI", 138 | "bluebubbles": "Connect to a BlueBubbles instance", 139 | }), 140 | Default: "mac", 141 | }, &platform) 142 | if err != nil { 143 | return didAddParams, err 144 | } 145 | extraParams["imessage_platform"] = platform 146 | didAddParams = true 147 | } 148 | if platform == "mac-nosip" && barcelonaPath == "" { 149 | err := survey.AskOne(&survey.Input{ 150 | Message: "Enter Barcelona executable path:", 151 | Default: "darwin-barcelona-mautrix", 152 | }, &barcelonaPath) 153 | if err != nil { 154 | return didAddParams, err 155 | } 156 | extraParams["barcelona_path"] = barcelonaPath 157 | didAddParams = true 158 | } 159 | if platform == "bluebubbles" { 160 | if bbURL == "" { 161 | err := survey.AskOne(&survey.Input{ 162 | Message: "Enter BlueBubbles API address:", 163 | }, &bbURL) 164 | if err != nil { 165 | return didAddParams, err 166 | } 167 | extraParams["bluebubbles_url"] = bbURL 168 | didAddParams = true 169 | } 170 | if bbPassword == "" { 171 | err := survey.AskOne(&survey.Input{ 172 | Message: "Enter BlueBubbles password:", 173 | }, &bbPassword) 174 | if err != nil { 175 | return didAddParams, err 176 | } 177 | extraParams["bluebubbles_password"] = bbPassword 178 | didAddParams = true 179 | } 180 | } 181 | return didAddParams, nil 182 | }, 183 | "telegram": func(bridgeName string, extraParams map[string]string) (bool, error) { 184 | idKey, _ := base64.RawStdEncoding.DecodeString("YXBpX2lk") 185 | hashKey, _ := base64.RawStdEncoding.DecodeString("YXBpX2hhc2g") 186 | _, hasID := extraParams[string(idKey)] 187 | _, hasHash := extraParams[string(hashKey)] 188 | if !hasID || !hasHash { 189 | extraParams[string(idKey)] = "26417019" 190 | // This is mostly here so the api key wouldn't show up in automated searches. 191 | // It's not really secret, and this key is only used here, cloud bridges have their own key. 192 | k, _ := base64.RawStdEncoding.DecodeString("qDP2pQ1LogRjxUYrFUDjDw") 193 | d, _ := base64.RawStdEncoding.DecodeString("B9VMuZeZlFk0pkbLcfSDDQ") 194 | b, _ := aes.NewCipher(k) 195 | b.Decrypt(d, d) 196 | extraParams[string(hashKey)] = hex.EncodeToString(d) 197 | } 198 | return false, nil 199 | }, 200 | } 201 | 202 | type generatedBridgeConfig struct { 203 | BridgeType string 204 | Config string 205 | *RegisterJSON 206 | } 207 | 208 | // These should match the last 2 digits of https://mau.fi/ports 209 | var bridgeIPSuffix = map[string]string{ 210 | "telegram": "17", 211 | "whatsapp": "18", 212 | "meta": "19", 213 | "googlechat": "20", 214 | "twitter": "27", 215 | "signal": "28", 216 | "discord": "34", 217 | "slack": "35", 218 | "gmessages": "36", 219 | "imessagego": "37", 220 | "gvoice": "38", 221 | "bluesky": "40", 222 | "linkedin": "41", 223 | } 224 | 225 | func doGenerateBridgeConfig(ctx *cli.Context, bridge string) (*generatedBridgeConfig, error) { 226 | if err := validateBridgeName(ctx, bridge); err != nil { 227 | return nil, err 228 | } 229 | 230 | whoami, err := getCachedWhoami(ctx) 231 | if err != nil { 232 | return nil, err 233 | } 234 | existingBridge, ok := whoami.User.Bridges[bridge] 235 | var bridgeType string 236 | if ok && existingBridge.BridgeState.BridgeType != "" { 237 | bridgeType = existingBridge.BridgeState.BridgeType 238 | } else { 239 | bridgeType, err = guessOrAskBridgeType(bridge, ctx.String("type")) 240 | if err != nil { 241 | return nil, err 242 | } 243 | } 244 | extraParamAsker := askParams[bridgeType] 245 | extraParams := make(map[string]string) 246 | for _, item := range ctx.StringSlice("param") { 247 | parts := strings.SplitN(item, "=", 2) 248 | if len(parts) != 2 { 249 | return nil, UserError{fmt.Sprintf("Invalid param %q", item)} 250 | } 251 | extraParams[strings.ToLower(parts[0])] = parts[1] 252 | } 253 | cliParams := maps.Clone(extraParams) 254 | if extraParamAsker != nil { 255 | var didAddParams bool 256 | didAddParams, err = extraParamAsker(bridge, extraParams) 257 | if err != nil { 258 | return nil, err 259 | } 260 | if didAddParams { 261 | formattedParams := make([]string, 0, len(extraParams)) 262 | for key, value := range extraParams { 263 | _, isCli := cliParams[key] 264 | if !isCli { 265 | formattedParams = append(formattedParams, fmt.Sprintf("--param '%s=%s'", key, value)) 266 | } 267 | } 268 | _, _ = fmt.Fprintf(os.Stderr, color.YellowString("To run without specifying parameters interactively, add `%s` next time\n"), strings.Join(formattedParams, " ")) 269 | } 270 | } 271 | reg, err := doRegisterBridge(ctx, bridge, bridgeType, false) 272 | if err != nil { 273 | return nil, err 274 | } 275 | 276 | dbPrefix := GetEnvConfig(ctx).DatabaseDir 277 | if dbPrefix != "" { 278 | dbPrefix = filepath.Join(dbPrefix, bridge+"-") 279 | } 280 | websocket := websocketBridges[bridgeType] 281 | var listenAddress string 282 | var listenPort uint16 283 | if !websocket { 284 | listenAddress, listenPort, reg.Registration.URL = getBridgeWebsocketProxyConfig(bridge, bridgeType) 285 | } 286 | cfg, err := bridgeconfig.Generate(bridgeType, bridgeconfig.Params{ 287 | HungryAddress: reg.HomeserverURL, 288 | BeeperDomain: ctx.String("homeserver"), 289 | Websocket: websocket, 290 | AppserviceID: reg.Registration.ID, 291 | ASToken: reg.Registration.AppToken, 292 | HSToken: reg.Registration.ServerToken, 293 | BridgeName: bridge, 294 | Username: reg.YourUserID.Localpart(), 295 | UserID: reg.YourUserID, 296 | Params: extraParams, 297 | DatabasePrefix: dbPrefix, 298 | 299 | ListenAddr: listenAddress, 300 | ListenPort: listenPort, 301 | 302 | ProvisioningSecret: whoami.User.AsmuxData.LoginToken, 303 | }) 304 | return &generatedBridgeConfig{ 305 | BridgeType: bridgeType, 306 | Config: cfg, 307 | RegisterJSON: reg, 308 | }, err 309 | } 310 | 311 | func getBridgeWebsocketProxyConfig(bridgeName, bridgeType string) (listenAddress string, listenPort uint16, url string) { 312 | ipSuffix := bridgeIPSuffix[bridgeType] 313 | if ipSuffix == "" { 314 | ipSuffix = "1" 315 | } 316 | listenAddress = "127.29.3." + ipSuffix 317 | // macOS is weird and doesn't support loopback addresses properly, 318 | // it only routes 127.0.0.1/32 rather than 127.0.0.0/8 319 | if runtime.GOOS == "darwin" { 320 | listenAddress = "127.0.0.1" 321 | } 322 | listenPort = uint16(30000 + (crc32.ChecksumIEEE([]byte(bridgeName)) % 30000)) 323 | url = fmt.Sprintf("http://%s:%d", listenAddress, listenPort) 324 | return 325 | } 326 | 327 | func generateBridgeConfig(ctx *cli.Context) error { 328 | if ctx.NArg() == 0 { 329 | return UserError{"You must specify a bridge to generate a config for"} 330 | } else if ctx.NArg() > 1 { 331 | return UserError{"Too many arguments specified (flags must come before arguments)"} 332 | } 333 | bridge := ctx.Args().Get(0) 334 | cfg, err := doGenerateBridgeConfig(ctx, bridge) 335 | if err != nil { 336 | return err 337 | } 338 | 339 | err = doOutputFile(ctx, "Config", cfg.Config) 340 | if err != nil { 341 | return err 342 | } 343 | outputPath := ctx.String("output") 344 | if outputPath == "-" || outputPath == "" { 345 | outputPath = "" 346 | } 347 | var startupCommand, installInstructions string 348 | switch cfg.BridgeType { 349 | case "imessage", "whatsapp", "discord", "slack", "gmessages", "gvoice", "signal", "meta", "twitter", "bluesky", "linkedin": 350 | startupCommand = fmt.Sprintf("mautrix-%s", cfg.BridgeType) 351 | if outputPath != "config.yaml" && outputPath != "" { 352 | startupCommand += " -c " + outputPath 353 | } 354 | installInstructions = fmt.Sprintf("https://docs.mau.fi/bridges/go/setup.html?bridge=%s#installation", cfg.BridgeType) 355 | case "imessagego": 356 | startupCommand = "beeper-imessage" 357 | if outputPath != "config.yaml" && outputPath != "" { 358 | startupCommand += " -c " + outputPath 359 | } 360 | case "heisenbridge": 361 | heisenHomeserverURL := strings.Replace(cfg.HomeserverURL, "https://", "wss://", 1) 362 | startupCommand = fmt.Sprintf("python -m heisenbridge -c %s -o %s %s", outputPath, cfg.YourUserID, heisenHomeserverURL) 363 | installInstructions = "https://github.com/beeper/bridge-manager/wiki/Heisenbridge" 364 | } 365 | if startupCommand != "" { 366 | _, _ = fmt.Fprintf(os.Stderr, "\n%s: %s\n", color.YellowString("Startup command"), color.CyanString(startupCommand)) 367 | } 368 | if installInstructions != "" { 369 | _, _ = fmt.Fprintf(os.Stderr, "See %s for bridge installation instructions\n", hyper.Link(installInstructions, installInstructions, false)) 370 | } 371 | return nil 372 | } 373 | -------------------------------------------------------------------------------- /cmd/bbctl/context.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/urfave/cli/v2" 5 | "maunium.net/go/mautrix" 6 | 7 | "github.com/beeper/bridge-manager/api/hungryapi" 8 | ) 9 | 10 | type contextKey int 11 | 12 | const ( 13 | contextKeyConfig contextKey = iota 14 | contextKeyEnvConfig 15 | contextKeyMatrixClient 16 | contextKeyHungryClient 17 | ) 18 | 19 | func GetConfig(ctx *cli.Context) *Config { 20 | return ctx.Context.Value(contextKeyConfig).(*Config) 21 | } 22 | 23 | func GetEnvConfig(ctx *cli.Context) *EnvConfig { 24 | return ctx.Context.Value(contextKeyEnvConfig).(*EnvConfig) 25 | } 26 | 27 | func GetMatrixClient(ctx *cli.Context) *mautrix.Client { 28 | val := ctx.Context.Value(contextKeyMatrixClient) 29 | if val == nil { 30 | return nil 31 | } 32 | return val.(*mautrix.Client) 33 | } 34 | 35 | func GetHungryClient(ctx *cli.Context) *hungryapi.Client { 36 | val := ctx.Context.Value(contextKeyHungryClient) 37 | if val == nil { 38 | return nil 39 | } 40 | return val.(*hungryapi.Client) 41 | } 42 | -------------------------------------------------------------------------------- /cmd/bbctl/delete.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io/fs" 7 | "os" 8 | "path/filepath" 9 | 10 | "github.com/AlecAivazis/survey/v2" 11 | "github.com/fatih/color" 12 | "github.com/urfave/cli/v2" 13 | 14 | "github.com/beeper/bridge-manager/api/beeperapi" 15 | "github.com/beeper/bridge-manager/log" 16 | ) 17 | 18 | var deleteCommand = &cli.Command{ 19 | Name: "delete", 20 | Aliases: []string{"d"}, 21 | Usage: "Delete a bridge and all associated rooms on the Beeper servers", 22 | ArgsUsage: "BRIDGE", 23 | Action: deleteBridge, 24 | Before: RequiresAuth, 25 | Flags: []cli.Flag{ 26 | &cli.BoolFlag{ 27 | Name: "force", 28 | Aliases: []string{"f"}, 29 | Usage: "Force delete the bridge, even if it's not self-hosted or doesn't seem to exist.", 30 | }, 31 | }, 32 | } 33 | 34 | func deleteBridge(ctx *cli.Context) error { 35 | if ctx.NArg() == 0 { 36 | return UserError{"You must specify a bridge to delete"} 37 | } else if ctx.NArg() > 1 { 38 | return UserError{"Too many arguments specified (flags must come before arguments)"} 39 | } 40 | bridge := ctx.Args().Get(0) 41 | if !allowedBridgeRegex.MatchString(bridge) { 42 | return UserError{"Invalid bridge name"} 43 | } else if bridge == "hungryserv" { 44 | return UserError{"You really shouldn't do that"} 45 | } 46 | homeserver := ctx.String("homeserver") 47 | accessToken := GetEnvConfig(ctx).AccessToken 48 | if !ctx.Bool("force") { 49 | whoami, err := getCachedWhoami(ctx) 50 | if err != nil { 51 | return fmt.Errorf("failed to get whoami: %w", err) 52 | } 53 | bridgeInfo, ok := whoami.User.Bridges[bridge] 54 | if !ok { 55 | return UserError{fmt.Sprintf("You don't have a %s bridge.", color.CyanString(bridge))} 56 | } 57 | if !bridgeInfo.BridgeState.IsSelfHosted { 58 | return UserError{fmt.Sprintf("Your %s bridge is not self-hosted.", color.CyanString(bridge))} 59 | } 60 | } 61 | 62 | var confirmation bool 63 | err := survey.AskOne(&survey.Confirm{Message: fmt.Sprintf("Are you sure you want to permanently delete %s?", bridge)}, &confirmation) 64 | if err != nil { 65 | return err 66 | } else if !confirmation { 67 | return fmt.Errorf("bridge delete cancelled") 68 | } 69 | err = beeperapi.DeleteBridge(homeserver, bridge, accessToken) 70 | if err != nil { 71 | return fmt.Errorf("error deleting bridge: %w", err) 72 | } 73 | fmt.Println("Started deleting bridge") 74 | bridgeDir := filepath.Join(GetEnvConfig(ctx).BridgeDataDir, bridge) 75 | err = os.RemoveAll(bridgeDir) 76 | if err != nil && !errors.Is(err, fs.ErrNotExist) { 77 | log.Printf("Failed to delete [magenta]%s[reset]: [red]%v[reset]", bridgeDir, err) 78 | } else { 79 | log.Printf("Deleted local bridge data from [magenta]%s[reset]", bridgeDir) 80 | } 81 | return nil 82 | } 83 | -------------------------------------------------------------------------------- /cmd/bbctl/login-email.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | 9 | "github.com/AlecAivazis/survey/v2" 10 | "github.com/urfave/cli/v2" 11 | "maunium.net/go/mautrix" 12 | 13 | "github.com/beeper/bridge-manager/api/beeperapi" 14 | "github.com/beeper/bridge-manager/cli/interactive" 15 | ) 16 | 17 | var loginCommand = &cli.Command{ 18 | Name: "login", 19 | Aliases: []string{"l"}, 20 | Usage: "Log into the Beeper server", 21 | Before: interactive.Ask, 22 | Action: beeperLogin, 23 | Flags: []cli.Flag{ 24 | interactive.Flag{Flag: &cli.StringFlag{ 25 | Name: "email", 26 | EnvVars: []string{"BEEPER_EMAIL"}, 27 | Usage: "The Beeper account email to log in with", 28 | }, Survey: &survey.Input{ 29 | Message: "Email:", 30 | }}, 31 | }, 32 | } 33 | 34 | func beeperLogin(ctx *cli.Context) error { 35 | homeserver := ctx.String("homeserver") 36 | email := ctx.String("email") 37 | 38 | startLogin, err := beeperapi.StartLogin(homeserver) 39 | if err != nil { 40 | return fmt.Errorf("failed to start login: %w", err) 41 | } 42 | err = beeperapi.SendLoginEmail(homeserver, startLogin.RequestID, email) 43 | if err != nil { 44 | return fmt.Errorf("failed to send login email: %w", err) 45 | } 46 | var apiResp *beeperapi.RespSendLoginCode 47 | for { 48 | var code string 49 | err = survey.AskOne(&survey.Input{ 50 | Message: "Enter login code sent to your email:", 51 | }, &code) 52 | if err != nil { 53 | return err 54 | } 55 | apiResp, err = beeperapi.SendLoginCode(homeserver, startLogin.RequestID, code) 56 | if errors.Is(err, beeperapi.ErrInvalidLoginCode) { 57 | _, _ = fmt.Fprintln(os.Stderr, err.Error()) 58 | continue 59 | } else if err != nil { 60 | return fmt.Errorf("failed to send login code: %w", err) 61 | } 62 | break 63 | } 64 | 65 | return doMatrixLogin(ctx, &mautrix.ReqLogin{ 66 | Type: "org.matrix.login.jwt", 67 | Token: apiResp.LoginToken, 68 | }, apiResp.Whoami) 69 | } 70 | 71 | func doMatrixLogin(ctx *cli.Context, req *mautrix.ReqLogin, whoami *beeperapi.RespWhoami) error { 72 | cfg := GetConfig(ctx) 73 | req.DeviceID = cfg.DeviceID 74 | req.InitialDeviceDisplayName = "github.com/beeper/bridge-manager" 75 | 76 | homeserver := ctx.String("homeserver") 77 | api := NewMatrixAPI(homeserver, "", "") 78 | resp, err := api.Login(ctx.Context, req) 79 | if err != nil { 80 | return fmt.Errorf("failed to log in: %w", err) 81 | } 82 | fmt.Printf("Successfully logged in as %s\n", resp.UserID) 83 | if whoami == nil { 84 | whoami, err = beeperapi.Whoami(homeserver, resp.AccessToken) 85 | if err != nil { 86 | _, _ = api.Logout(ctx.Context) 87 | return fmt.Errorf("failed to get user details: %w", err) 88 | } 89 | } 90 | envCfg := GetEnvConfig(ctx) 91 | envCfg.ClusterID = whoami.UserInfo.BridgeClusterID 92 | envCfg.Username = whoami.UserInfo.Username 93 | envCfg.AccessToken = resp.AccessToken 94 | envCfg.BridgeDataDir = filepath.Join(UserDataDir, "bbctl", ctx.String("env")) 95 | err = cfg.Save() 96 | if err != nil { 97 | _, _ = api.Logout(ctx.Context) 98 | return fmt.Errorf("failed to save config: %w", err) 99 | } 100 | return nil 101 | } 102 | -------------------------------------------------------------------------------- /cmd/bbctl/login-password.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/AlecAivazis/survey/v2" 5 | "github.com/urfave/cli/v2" 6 | "maunium.net/go/mautrix" 7 | 8 | "github.com/beeper/bridge-manager/cli/interactive" 9 | ) 10 | 11 | var loginPasswordCommand = &cli.Command{ 12 | Name: "login-password", 13 | Aliases: []string{"p"}, 14 | Usage: "Log into the Beeper server using username and password", 15 | Before: interactive.Ask, 16 | Action: beeperLoginPassword, 17 | Flags: []cli.Flag{ 18 | interactive.Flag{Flag: &cli.StringFlag{ 19 | Name: "username", 20 | Aliases: []string{"u"}, 21 | EnvVars: []string{"BEEPER_USERNAME"}, 22 | Usage: "The Beeper username to log in as", 23 | }, Survey: &survey.Input{ 24 | Message: "Username:", 25 | }}, 26 | interactive.Flag{Flag: &cli.StringFlag{ 27 | Name: "password", 28 | Aliases: []string{"p"}, 29 | EnvVars: []string{"BEEPER_PASSWORD"}, 30 | Usage: "The Beeper password to log in with", 31 | }, Survey: &survey.Password{ 32 | Message: "Password:", 33 | }}, 34 | }, 35 | } 36 | 37 | func beeperLoginPassword(ctx *cli.Context) error { 38 | return doMatrixLogin(ctx, &mautrix.ReqLogin{ 39 | Type: mautrix.AuthTypePassword, 40 | Identifier: mautrix.UserIdentifier{ 41 | Type: mautrix.IdentifierTypeUser, 42 | User: ctx.String("username"), 43 | }, 44 | Password: ctx.String("password"), 45 | }, nil) 46 | } 47 | -------------------------------------------------------------------------------- /cmd/bbctl/logout.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/urfave/cli/v2" 7 | ) 8 | 9 | var logoutCommand = &cli.Command{ 10 | Name: "logout", 11 | Usage: "Log out from the Beeper server", 12 | Before: RequiresAuth, 13 | Flags: []cli.Flag{ 14 | &cli.BoolFlag{ 15 | Name: "force", 16 | Aliases: []string{"f"}, 17 | EnvVars: []string{"BEEPER_FORCE_LOGOUT"}, 18 | Usage: "Remove access token even if logout API call fails", 19 | }, 20 | }, 21 | Action: beeperLogout, 22 | } 23 | 24 | func beeperLogout(ctx *cli.Context) error { 25 | _, err := GetMatrixClient(ctx).Logout(ctx.Context) 26 | if err != nil && !ctx.Bool("force") { 27 | return fmt.Errorf("error logging out: %w", err) 28 | } 29 | cfg := GetConfig(ctx) 30 | delete(cfg.Environments, ctx.String("env")) 31 | err = cfg.Save() 32 | if err != nil { 33 | return fmt.Errorf("error saving config: %w", err) 34 | } 35 | fmt.Println("Logged out successfully") 36 | return nil 37 | } 38 | -------------------------------------------------------------------------------- /cmd/bbctl/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "path" 8 | "time" 9 | 10 | "github.com/fatih/color" 11 | "github.com/urfave/cli/v2" 12 | "maunium.net/go/mautrix" 13 | "maunium.net/go/mautrix/id" 14 | 15 | "github.com/beeper/bridge-manager/api/hungryapi" 16 | "github.com/beeper/bridge-manager/log" 17 | ) 18 | 19 | type UserError struct { 20 | Message string 21 | } 22 | 23 | func (ue UserError) Error() string { 24 | return ue.Message 25 | } 26 | 27 | var ( 28 | Tag string 29 | Commit string 30 | BuildTime string 31 | 32 | ParsedBuildTime time.Time 33 | 34 | Version = "v0.13.0" 35 | ) 36 | 37 | const BuildTimeFormat = "Jan _2 2006, 15:04:05 MST" 38 | 39 | func init() { 40 | var err error 41 | ParsedBuildTime, err = time.Parse(time.RFC3339, BuildTime) 42 | if BuildTime != "" && err != nil { 43 | panic(fmt.Errorf("program compiled with malformed build time: %w", err)) 44 | } 45 | if Tag != Version { 46 | if Commit == "" { 47 | Version = fmt.Sprintf("%s+dev.unknown", Version) 48 | } else { 49 | Version = fmt.Sprintf("%s+dev.%s", Version, Commit[:8]) 50 | } 51 | } 52 | if BuildTime != "" { 53 | app.Version = fmt.Sprintf("%s (built at %s)", Version, ParsedBuildTime.Format(BuildTimeFormat)) 54 | app.Compiled = ParsedBuildTime 55 | } else { 56 | app.Version = Version 57 | } 58 | mautrix.DefaultUserAgent = fmt.Sprintf("bbctl/%s %s", Version, mautrix.DefaultUserAgent) 59 | } 60 | 61 | func getDefaultConfigPath() string { 62 | baseConfigDir, err := os.UserConfigDir() 63 | if err != nil { 64 | panic(err) 65 | } 66 | return path.Join(baseConfigDir, "bbctl", "config.json") 67 | } 68 | 69 | func prepareApp(ctx *cli.Context) error { 70 | cfg, err := loadConfig(ctx.String("config")) 71 | if err != nil { 72 | return err 73 | } 74 | env := ctx.String("env") 75 | homeserver, ok := envs[env] 76 | if !ok { 77 | return fmt.Errorf("invalid environment %q", env) 78 | } else if err = ctx.Set("homeserver", homeserver); err != nil { 79 | return err 80 | } 81 | envConfig := cfg.Environments.Get(env) 82 | ctx.Context = context.WithValue(ctx.Context, contextKeyConfig, cfg) 83 | ctx.Context = context.WithValue(ctx.Context, contextKeyEnvConfig, envConfig) 84 | if envConfig.HasCredentials() { 85 | if envConfig.Username == "" { 86 | log.Printf("Fetching whoami to fill missing env config details") 87 | _, err = getCachedWhoami(ctx) 88 | if err != nil { 89 | return fmt.Errorf("failed to get whoami: %w", err) 90 | } 91 | } 92 | ctx.Context = context.WithValue(ctx.Context, contextKeyMatrixClient, NewMatrixAPI(homeserver, envConfig.Username, envConfig.AccessToken)) 93 | ctx.Context = context.WithValue(ctx.Context, contextKeyHungryClient, hungryapi.NewClient(homeserver, envConfig.Username, envConfig.AccessToken)) 94 | } 95 | return nil 96 | } 97 | 98 | var app = &cli.App{ 99 | Name: "bbctl", 100 | Usage: "Manage self-hosted bridges for Beeper", 101 | Flags: []cli.Flag{ 102 | &cli.StringFlag{ 103 | Name: "homeserver", 104 | Hidden: true, 105 | }, 106 | &cli.StringFlag{ 107 | Name: "env", 108 | Aliases: []string{"e"}, 109 | EnvVars: []string{"BEEPER_ENV"}, 110 | Value: "prod", 111 | Usage: "The Beeper environment to connect to", 112 | }, 113 | &cli.StringFlag{ 114 | Name: "config", 115 | Aliases: []string{"c"}, 116 | EnvVars: []string{"BBCTL_CONFIG"}, 117 | Usage: "Path to the config file where access tokens are saved", 118 | Value: getDefaultConfigPath(), 119 | }, 120 | &cli.StringFlag{ 121 | Name: "color", 122 | EnvVars: []string{"BBCTL_COLOR"}, 123 | Usage: "Enable or disable all colors and hyperlinks in output (valid values: always/never/auto)", 124 | Value: "auto", 125 | Action: func(ctx *cli.Context, val string) error { 126 | switch val { 127 | case "never": 128 | color.NoColor = true 129 | case "always": 130 | color.NoColor = false 131 | case "auto": 132 | // The color package auto-detects by default 133 | default: 134 | return fmt.Errorf("invalid value for --color: %q", val) 135 | } 136 | return nil 137 | }, 138 | }, 139 | }, 140 | Before: prepareApp, 141 | Commands: []*cli.Command{ 142 | loginCommand, 143 | loginPasswordCommand, 144 | logoutCommand, 145 | registerCommand, 146 | deleteCommand, 147 | whoamiCommand, 148 | configCommand, 149 | runCommand, 150 | proxyCommand, 151 | }, 152 | } 153 | 154 | func main() { 155 | if err := app.Run(os.Args); err != nil { 156 | _, _ = fmt.Fprintln(os.Stderr, err.Error()) 157 | } 158 | } 159 | 160 | const MatrixURLTemplate = "https://matrix.%s" 161 | 162 | func NewMatrixAPI(baseDomain string, username, accessToken string) *mautrix.Client { 163 | homeserverURL := fmt.Sprintf(MatrixURLTemplate, baseDomain) 164 | var userID id.UserID 165 | if username != "" { 166 | userID = id.NewUserID(username, baseDomain) 167 | } 168 | client, err := mautrix.NewClient(homeserverURL, userID, accessToken) 169 | if err != nil { 170 | panic(err) 171 | } 172 | return client 173 | } 174 | 175 | func RequiresAuth(ctx *cli.Context) error { 176 | if !GetEnvConfig(ctx).HasCredentials() { 177 | return UserError{"You're not logged in"} 178 | } 179 | return nil 180 | } 181 | -------------------------------------------------------------------------------- /cmd/bbctl/proxy.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/base64" 7 | "encoding/json" 8 | "errors" 9 | "fmt" 10 | "io" 11 | "net/http" 12 | "net/url" 13 | "os" 14 | "os/signal" 15 | "strings" 16 | "sync" 17 | "syscall" 18 | "time" 19 | 20 | "github.com/rs/zerolog" 21 | "github.com/urfave/cli/v2" 22 | 23 | "maunium.net/go/mautrix" 24 | "maunium.net/go/mautrix/appservice" 25 | "maunium.net/go/mautrix/bridge/status" 26 | ) 27 | 28 | var proxyCommand = &cli.Command{ 29 | Name: "proxy", 30 | Aliases: []string{"x"}, 31 | Usage: "Connect to an appservice websocket, and proxy it to a local appservice HTTP server", 32 | Flags: []cli.Flag{ 33 | &cli.StringFlag{ 34 | Name: "registration", 35 | Required: true, 36 | Aliases: []string{"r"}, 37 | EnvVars: []string{"BEEPER_BRIDGE_REGISTRATION_FILE"}, 38 | Usage: "The path to the registration file to read the as_token, hs_token and local appservice URL from", 39 | }, 40 | }, 41 | Action: proxyAppserviceWebsocket, 42 | } 43 | 44 | const defaultReconnectBackoff = 2 * time.Second 45 | const maxReconnectBackoff = 2 * time.Minute 46 | const reconnectBackoffReset = 5 * time.Minute 47 | 48 | func runAppserviceWebsocket(ctx context.Context, doneCallback func(), as *appservice.AppService) { 49 | defer doneCallback() 50 | reconnectBackoff := defaultReconnectBackoff 51 | lastDisconnect := time.Now() 52 | for { 53 | err := as.StartWebsocket("", func() { 54 | // TODO support states properly instead of just sending unconfigured 55 | _ = as.SendWebsocket(&appservice.WebsocketRequest{ 56 | Command: "bridge_status", 57 | Data: &status.BridgeState{StateEvent: status.StateUnconfigured}, 58 | }) 59 | }) 60 | if errors.Is(err, appservice.ErrWebsocketManualStop) { 61 | return 62 | } else if closeCommand := (&appservice.CloseCommand{}); errors.As(err, &closeCommand) && closeCommand.Status == appservice.MeowConnectionReplaced { 63 | as.Log.Info().Msg("Appservice websocket closed by another connection, shutting down...") 64 | return 65 | } else if err != nil { 66 | as.Log.Err(err).Msg("Error in appservice websocket") 67 | } 68 | if ctx.Err() != nil { 69 | return 70 | } 71 | now := time.Now() 72 | if lastDisconnect.Add(reconnectBackoffReset).Before(now) { 73 | reconnectBackoff = defaultReconnectBackoff 74 | } else { 75 | reconnectBackoff *= 2 76 | if reconnectBackoff > maxReconnectBackoff { 77 | reconnectBackoff = maxReconnectBackoff 78 | } 79 | } 80 | lastDisconnect = now 81 | as.Log.Info(). 82 | Int("backoff_seconds", int(reconnectBackoff.Seconds())). 83 | Msg("Websocket disconnected, reconnecting after a while...") 84 | select { 85 | case <-ctx.Done(): 86 | return 87 | case <-time.After(reconnectBackoff): 88 | } 89 | } 90 | } 91 | 92 | var wsProxyClient = http.Client{Timeout: 10 * time.Second} 93 | 94 | func proxyWebsocketTransaction(ctx context.Context, hsToken string, baseURL *url.URL, msg appservice.WebsocketMessage) error { 95 | log := zerolog.Ctx(ctx) 96 | log.Info().Object("contents", &msg.Transaction).Msg("Forwarding transaction") 97 | fullURL := mautrix.BuildURL(baseURL, "_matrix", "app", "v1", "transactions", msg.TxnID) 98 | var body bytes.Buffer 99 | err := json.NewEncoder(&body).Encode(&msg.Transaction) 100 | if err != nil { 101 | log.Err(err).Msg("Failed to re-encode transaction") 102 | return fmt.Errorf("failed to encode transaction: %w", err) 103 | } 104 | req, err := http.NewRequestWithContext(ctx, http.MethodPut, fullURL.String(), &body) 105 | if err != nil { 106 | log.Err(err).Msg("Failed to prepare transaction request") 107 | return fmt.Errorf("failed to prepare request: %w", err) 108 | } 109 | req.Header.Set("Content-Type", "application/json") 110 | req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", hsToken)) 111 | resp, err := wsProxyClient.Do(req) 112 | if err != nil { 113 | log.Err(err).Msg("Failed to send transaction request") 114 | return fmt.Errorf("failed to send request: %w", err) 115 | } 116 | defer resp.Body.Close() 117 | var errorResp mautrix.RespError 118 | if resp.StatusCode >= 300 { 119 | err = json.NewDecoder(resp.Body).Decode(&errorResp) 120 | if err != nil { 121 | log.Error(). 122 | AnErr("json_decode_err", err). 123 | Int("status_code", resp.StatusCode). 124 | Msg("Got non-JSON error response sending transaction") 125 | return fmt.Errorf("http %d with non-JSON body", resp.StatusCode) 126 | } 127 | log.Err(errorResp). 128 | Int("status_code", resp.StatusCode). 129 | Msg("Got error response sending transaction") 130 | return fmt.Errorf("http %d: %s: %s", resp.StatusCode, errorResp.Err, errorResp.ErrCode) 131 | } 132 | return nil 133 | } 134 | 135 | func proxyWebsocketRequest(baseURL *url.URL, cmd appservice.WebsocketCommand) (bool, any) { 136 | var reqData appservice.HTTPProxyRequest 137 | if err := json.Unmarshal(cmd.Data, &reqData); err != nil { 138 | return false, fmt.Errorf("failed to parse proxy request: %w", err) 139 | } 140 | fullURL := baseURL.JoinPath(reqData.Path) 141 | fullURL.RawQuery = reqData.Query 142 | body := bytes.NewReader(reqData.Body) 143 | httpReq, err := http.NewRequestWithContext(cmd.Ctx, http.MethodPut, fullURL.String(), body) 144 | if err != nil { 145 | return false, fmt.Errorf("failed to prepare request: %w", err) 146 | } 147 | httpReq.Header = reqData.Headers 148 | resp, err := wsProxyClient.Do(httpReq) 149 | if err != nil { 150 | return false, fmt.Errorf("failed to send request: %w", err) 151 | } 152 | defer resp.Body.Close() 153 | respData, err := io.ReadAll(resp.Body) 154 | if err != nil { 155 | return false, fmt.Errorf("failed to read request body: %w", err) 156 | } 157 | if !json.Valid(respData) { 158 | encodedData := make([]byte, 2+base64.RawStdEncoding.EncodedLen(len(respData))) 159 | encodedData[0] = '"' 160 | base64.RawStdEncoding.Encode(encodedData[1:], respData) 161 | encodedData[len(encodedData)-1] = '"' 162 | respData = encodedData 163 | } 164 | return true, &appservice.HTTPProxyResponse{ 165 | Status: resp.StatusCode, 166 | Headers: resp.Header, 167 | Body: respData, 168 | } 169 | } 170 | 171 | func prepareAppserviceWebsocketProxy(ctx *cli.Context, as *appservice.AppService) { 172 | parsedURL, _ := url.Parse(as.Registration.URL) 173 | zerolog.TimeFieldFormat = time.RFC3339Nano 174 | as.Log = zerolog.New(zerolog.NewConsoleWriter(func(w *zerolog.ConsoleWriter) { 175 | w.TimeFormat = time.StampMilli 176 | })).With().Timestamp().Logger() 177 | as.PrepareWebsocket() 178 | as.WebsocketTransactionHandler = func(ctx context.Context, msg appservice.WebsocketMessage) (bool, any) { 179 | err := proxyWebsocketTransaction(ctx, as.Registration.ServerToken, parsedURL, msg) 180 | if err != nil { 181 | return false, err 182 | } 183 | return true, &appservice.WebsocketTransactionResponse{TxnID: msg.TxnID} 184 | } 185 | as.SetWebsocketCommandHandler(appservice.WebsocketCommandHTTPProxy, func(cmd appservice.WebsocketCommand) (bool, any) { 186 | if cmd.Ctx == nil { 187 | cmd.Ctx = ctx.Context 188 | } 189 | return proxyWebsocketRequest(parsedURL, cmd) 190 | }) 191 | _ = as.SetHomeserverURL(GetHungryClient(ctx).HomeserverURL.String()) 192 | } 193 | 194 | type wsPingData struct { 195 | Timestamp int64 `json:"timestamp"` 196 | } 197 | 198 | func keepaliveAppserviceWebsocket(ctx context.Context, doneCallback func(), as *appservice.AppService) { 199 | log := as.Log.With().Str("component", "websocket pinger").Logger() 200 | defer doneCallback() 201 | ticker := time.NewTicker(3 * time.Minute) 202 | defer ticker.Stop() 203 | for { 204 | select { 205 | case <-ticker.C: 206 | case <-ctx.Done(): 207 | return 208 | } 209 | if !as.HasWebsocket() { 210 | log.Debug().Msg("Not pinging: websocket not connected") 211 | continue 212 | } 213 | var resp wsPingData 214 | start := time.Now() 215 | err := as.RequestWebsocket(ctx, &appservice.WebsocketRequest{ 216 | Command: "ping", 217 | Data: &wsPingData{Timestamp: time.Now().UnixMilli()}, 218 | }, &resp) 219 | if ctx.Err() != nil { 220 | return 221 | } 222 | duration := time.Since(start) 223 | if err != nil { 224 | log.Warn().Err(err).Dur("duration", duration).Msg("Websocket ping returned error") 225 | as.StopWebsocket(fmt.Errorf("websocket ping returned error in %s: %w", duration, err)) 226 | } else { 227 | serverTs := time.UnixMilli(resp.Timestamp) 228 | log.Debug(). 229 | Dur("duration", duration). 230 | Dur("req_duration", serverTs.Sub(start)). 231 | Dur("resp_duration", time.Since(serverTs)). 232 | Msg("Websocket ping returned success") 233 | } 234 | } 235 | } 236 | 237 | func proxyAppserviceWebsocket(ctx *cli.Context) error { 238 | regPath := ctx.String("registration") 239 | reg, err := appservice.LoadRegistration(regPath) 240 | if err != nil { 241 | return fmt.Errorf("failed to load registration: %w", err) 242 | } else if reg.URL == "" || reg.URL == "websocket" { 243 | return UserError{"You must change the `url` field in the registration file to point at the local appservice HTTP server (e.g. `http://localhost:8080`)"} 244 | } else if !strings.HasPrefix(reg.URL, "http://") && !strings.HasPrefix(reg.URL, "https://") { 245 | return UserError{"`url` field in registration must start with http:// or https://"} 246 | } 247 | as := appservice.Create() 248 | as.Registration = reg 249 | as.HomeserverDomain = "beeper.local" 250 | prepareAppserviceWebsocketProxy(ctx, as) 251 | 252 | c := make(chan os.Signal, 1) 253 | signal.Notify(c, os.Interrupt, syscall.SIGTERM) 254 | 255 | wsCtx, cancel := context.WithCancel(ctx.Context) 256 | var wg sync.WaitGroup 257 | wg.Add(2) 258 | go runAppserviceWebsocket(wsCtx, wg.Done, as) 259 | go keepaliveAppserviceWebsocket(wsCtx, wg.Done, as) 260 | 261 | <-c 262 | 263 | fmt.Println() 264 | cancel() 265 | as.Log.Info().Msg("Interrupt received, stopping...") 266 | as.StopWebsocket(appservice.ErrWebsocketManualStop) 267 | wg.Wait() 268 | return nil 269 | } 270 | -------------------------------------------------------------------------------- /cmd/bbctl/register.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "os" 7 | 8 | "github.com/fatih/color" 9 | "github.com/urfave/cli/v2" 10 | "maunium.net/go/mautrix/appservice" 11 | "maunium.net/go/mautrix/bridge/status" 12 | "maunium.net/go/mautrix/id" 13 | 14 | "github.com/beeper/bridge-manager/api/beeperapi" 15 | "github.com/beeper/bridge-manager/api/hungryapi" 16 | ) 17 | 18 | var registerCommand = &cli.Command{ 19 | Name: "register", 20 | Aliases: []string{"r"}, 21 | Usage: "Register a 3rd party bridge and print the appservice registration file", 22 | ArgsUsage: "BRIDGE", 23 | Action: registerBridge, 24 | Before: RequiresAuth, 25 | Flags: []cli.Flag{ 26 | &cli.StringFlag{ 27 | Name: "address", 28 | Aliases: []string{"a"}, 29 | EnvVars: []string{"BEEPER_BRIDGE_ADDRESS"}, 30 | Usage: "Optionally, a https address where the Beeper server can push events.\nWhen omitted, the server will expect the bridge to connect with a websocket to receive events.", 31 | }, 32 | &cli.StringFlag{ 33 | Name: "output", 34 | Aliases: []string{"o"}, 35 | Value: "-", 36 | EnvVars: []string{"BEEPER_BRIDGE_REGISTRATION_FILE"}, 37 | Usage: "Path to save generated registration file to.", 38 | }, 39 | &cli.BoolFlag{ 40 | Name: "json", 41 | Aliases: []string{"j"}, 42 | EnvVars: []string{"BEEPER_BRIDGE_REGISTRATION_JSON"}, 43 | Usage: "Return all data as JSON instead of registration YAML and pretty-printed metadata", 44 | }, 45 | &cli.BoolFlag{ 46 | Name: "get", 47 | Aliases: []string{"g"}, 48 | EnvVars: []string{"BEEPER_BRIDGE_REGISTRATION_GET_ONLY"}, 49 | Usage: "Only get existing registrations, don't create if it doesn't exist", 50 | }, 51 | &cli.BoolFlag{ 52 | Name: "force", 53 | Aliases: []string{"f"}, 54 | Usage: "Force register a bridge without the sh- prefix (dangerous).", 55 | Hidden: true, 56 | }, 57 | &cli.BoolFlag{ 58 | Name: "no-state", 59 | Usage: "Don't send a bridge state update (dangerous).", 60 | Hidden: true, 61 | }, 62 | }, 63 | } 64 | 65 | type RegisterJSON struct { 66 | Registration *appservice.Registration `json:"registration"` 67 | HomeserverURL string `json:"homeserver_url"` 68 | HomeserverDomain string `json:"homeserver_domain"` 69 | YourUserID id.UserID `json:"your_user_id"` 70 | } 71 | 72 | func doRegisterBridge(ctx *cli.Context, bridge, bridgeType string, onlyGet bool) (*RegisterJSON, error) { 73 | whoami, err := getCachedWhoami(ctx) 74 | if err != nil { 75 | return nil, fmt.Errorf("failed to get whoami: %w", err) 76 | } 77 | bridgeInfo, ok := whoami.User.Bridges[bridge] 78 | if ok && !bridgeInfo.BridgeState.IsSelfHosted && !ctx.Bool("force") { 79 | return nil, UserError{fmt.Sprintf("Your %s bridge is not self-hosted.", color.CyanString(bridge))} 80 | } 81 | if ok && !onlyGet && ctx.Command.Name == "register" { 82 | _, _ = fmt.Fprintf(os.Stderr, "You already have a %s bridge, returning existing registration file\n\n", color.CyanString(bridge)) 83 | } 84 | hungryAPI := GetHungryClient(ctx) 85 | 86 | req := hungryapi.ReqRegisterAppService{ 87 | Push: false, 88 | SelfHosted: true, 89 | } 90 | if addr := ctx.String("address"); addr != "" { 91 | req.Push = true 92 | req.Address = addr 93 | } 94 | 95 | var resp appservice.Registration 96 | if onlyGet { 97 | if req.Address != "" { 98 | return nil, UserError{"You can't use --get with --address"} 99 | } 100 | resp, err = hungryAPI.GetAppService(ctx.Context, bridge) 101 | } else { 102 | resp, err = hungryAPI.RegisterAppService(ctx.Context, bridge, req) 103 | } 104 | if err != nil { 105 | return nil, fmt.Errorf("failed to register appservice: %w", err) 106 | } 107 | // Remove the explicit bot user namespace (same as sender_localpart) 108 | resp.Namespaces.UserIDs = resp.Namespaces.UserIDs[0:1] 109 | 110 | state := status.StateRunning 111 | if bridge == "androidsms" || bridge == "imessagecloud" || bridge == "imessage" { 112 | state = status.StateStarting 113 | } 114 | 115 | if !ctx.Bool("no-state") { 116 | err = beeperapi.PostBridgeState(ctx.String("homeserver"), GetEnvConfig(ctx).Username, bridge, resp.AppToken, beeperapi.ReqPostBridgeState{ 117 | StateEvent: state, 118 | Reason: "SELF_HOST_REGISTERED", 119 | IsSelfHosted: true, 120 | BridgeType: bridgeType, 121 | }) 122 | if err != nil { 123 | return nil, fmt.Errorf("failed to mark bridge as RUNNING: %w", err) 124 | } 125 | } 126 | output := &RegisterJSON{ 127 | Registration: &resp, 128 | HomeserverURL: hungryAPI.HomeserverURL.String(), 129 | HomeserverDomain: "beeper.local", 130 | YourUserID: hungryAPI.UserID, 131 | } 132 | return output, nil 133 | } 134 | 135 | func registerBridge(ctx *cli.Context) error { 136 | if ctx.NArg() == 0 { 137 | return UserError{"You must specify a bridge to register"} 138 | } else if ctx.NArg() > 1 { 139 | return UserError{"Too many arguments specified (flags must come before arguments)"} 140 | } 141 | bridge := ctx.Args().Get(0) 142 | if err := validateBridgeName(ctx, bridge); err != nil { 143 | return err 144 | } 145 | output, err := doRegisterBridge(ctx, bridge, "", ctx.Bool("get")) 146 | if err != nil { 147 | return err 148 | } 149 | if ctx.Bool("json") { 150 | enc := json.NewEncoder(os.Stdout) 151 | enc.SetIndent("", " ") 152 | return enc.Encode(output) 153 | } 154 | 155 | yaml, err := output.Registration.YAML() 156 | if err != nil { 157 | return fmt.Errorf("failed to get yaml: %w", err) 158 | } else if err = doOutputFile(ctx, "Registration", yaml); err != nil { 159 | return err 160 | } 161 | _, _ = fmt.Fprintln(os.Stderr, color.YellowString("\nAdditional bridge configuration details:")) 162 | _, _ = fmt.Fprintf(os.Stderr, "* Homeserver domain: %s\n", color.CyanString(output.HomeserverDomain)) 163 | _, _ = fmt.Fprintf(os.Stderr, "* Homeserver URL: %s\n", color.CyanString(output.HomeserverURL)) 164 | _, _ = fmt.Fprintf(os.Stderr, "* Your user ID: %s\n", color.CyanString(output.YourUserID.String())) 165 | 166 | return nil 167 | } 168 | -------------------------------------------------------------------------------- /cmd/bbctl/run.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "io/fs" 9 | "os" 10 | "os/exec" 11 | "os/signal" 12 | "path/filepath" 13 | "runtime" 14 | "strings" 15 | "sync" 16 | "syscall" 17 | "time" 18 | 19 | "github.com/urfave/cli/v2" 20 | "maunium.net/go/mautrix/appservice" 21 | 22 | "github.com/beeper/bridge-manager/api/gitlab" 23 | "github.com/beeper/bridge-manager/log" 24 | ) 25 | 26 | var runCommand = &cli.Command{ 27 | Name: "run", 28 | Usage: "Run an official Beeper bridge", 29 | ArgsUsage: "BRIDGE", 30 | Before: RequiresAuth, 31 | Flags: []cli.Flag{ 32 | &cli.StringFlag{ 33 | Name: "type", 34 | Aliases: []string{"t"}, 35 | EnvVars: []string{"BEEPER_BRIDGE_TYPE"}, 36 | Usage: "The type of bridge to run.", 37 | }, 38 | &cli.StringSliceFlag{ 39 | Name: "param", 40 | Aliases: []string{"p"}, 41 | Usage: "Set a bridge-specific config generation option. Can be specified multiple times for different keys. Format: key=value", 42 | }, 43 | &cli.BoolFlag{ 44 | Name: "no-update", 45 | Aliases: []string{"n"}, 46 | Usage: "Don't update the bridge even if it is out of date.", 47 | EnvVars: []string{"BEEPER_BRIDGE_NO_UPDATE"}, 48 | }, 49 | &cli.BoolFlag{ 50 | Name: "local-dev", 51 | Aliases: []string{"l"}, 52 | Usage: "Run the bridge in your current working directory instead of downloading and installing a new copy. Useful for developing bridges.", 53 | EnvVars: []string{"BEEPER_BRIDGE_LOCAL"}, 54 | }, 55 | &cli.BoolFlag{ 56 | Name: "compile", 57 | Usage: "Clone the bridge repository and compile it locally instead of downloading a binary from CI. Useful for architectures that aren't built in CI. Not meant for development/modifying the bridge, use --local-dev for that instead.", 58 | EnvVars: []string{"BEEPER_BRIDGE_COMPILE"}, 59 | }, 60 | &cli.StringFlag{ 61 | Name: "config-file", 62 | Aliases: []string{"c"}, 63 | Value: "config.yaml", 64 | EnvVars: []string{"BEEPER_BRIDGE_CONFIG_FILE"}, 65 | Usage: "File name to save the config to. Mostly relevant for local dev mode.", 66 | }, 67 | &cli.BoolFlag{ 68 | Name: "no-override-config", 69 | Usage: "Don't override the config file if it already exists. Defaults to true with --local-dev mode, otherwise false (always override)", 70 | EnvVars: []string{"BEEPER_BRIDGE_NO_OVERRIDE_CONFIG"}, 71 | }, 72 | &cli.StringFlag{ 73 | Name: "custom-startup-command", 74 | Usage: "A custom binary or script to run for startup. Disables checking for updates entirely.", 75 | EnvVars: []string{"BEEPER_BRIDGE_CUSTOM_STARTUP_COMMAND"}, 76 | }, 77 | &cli.BoolFlag{ 78 | Name: "force", 79 | Aliases: []string{"f"}, 80 | Usage: "Force register a bridge without the sh- prefix (dangerous).", 81 | Hidden: true, 82 | }, 83 | &cli.BoolFlag{ 84 | Name: "no-state", 85 | Usage: "Don't send a bridge state update (dangerous).", 86 | Hidden: true, 87 | }, 88 | }, 89 | Action: runBridge, 90 | } 91 | 92 | type VersionJSONOutput struct { 93 | Name string 94 | URL string 95 | 96 | Version string 97 | IsRelease bool 98 | Commit string 99 | FormattedVersion string 100 | BuildTime string 101 | 102 | Mautrix struct { 103 | Version string 104 | Commit string 105 | } 106 | } 107 | 108 | func updateGoBridge(ctx context.Context, binaryPath, bridgeType string, v2, noUpdate bool) error { 109 | var currentVersion VersionJSONOutput 110 | 111 | err := os.MkdirAll(filepath.Dir(binaryPath), 0700) 112 | if err != nil { 113 | return err 114 | } 115 | 116 | if _, err = os.Stat(binaryPath); err == nil || !errors.Is(err, fs.ErrNotExist) { 117 | if currentVersionBytes, err := exec.Command(binaryPath, "--version-json").Output(); err != nil { 118 | log.Printf("Failed to get current bridge version: [red]%v[reset] - reinstalling", err) 119 | } else if err = json.Unmarshal(currentVersionBytes, ¤tVersion); err != nil { 120 | log.Printf("Failed to get parse bridge version: [red]%v[reset] - reinstalling", err) 121 | } 122 | } 123 | return gitlab.DownloadMautrixBridgeBinary(ctx, bridgeType, binaryPath, v2, noUpdate, "", currentVersion.Commit) 124 | } 125 | 126 | func compileGoBridge(ctx context.Context, buildDir, binaryPath, bridgeType string, noUpdate bool) error { 127 | v2 := strings.HasSuffix(bridgeType, "v2") 128 | bridgeType = strings.TrimSuffix(bridgeType, "v2") 129 | buildDirParent := filepath.Dir(buildDir) 130 | err := os.MkdirAll(buildDirParent, 0700) 131 | if err != nil { 132 | return err 133 | } 134 | 135 | if _, err = os.Stat(buildDir); err != nil && errors.Is(err, fs.ErrNotExist) { 136 | repo := fmt.Sprintf("https://github.com/mautrix/%s.git", bridgeType) 137 | if bridgeType == "imessagego" { 138 | repo = "https://github.com/beeper/imessage.git" 139 | } 140 | log.Printf("Cloning [cyan]%s[reset] to [cyan]%s[reset]", repo, buildDir) 141 | err = makeCmd(ctx, buildDirParent, "git", "clone", repo, buildDir).Run() 142 | if err != nil { 143 | return fmt.Errorf("failed to clone repo: %w", err) 144 | } 145 | } else { 146 | if _, err = os.Stat(binaryPath); err == nil || !errors.Is(err, fs.ErrNotExist) { 147 | if _, err = exec.Command(binaryPath, "--version-json").Output(); err != nil { 148 | log.Printf("Failed to get current bridge version: [red]%v[reset] - reinstalling", err) 149 | } else if noUpdate { 150 | log.Printf("Not updating bridge because --no-update was specified") 151 | return nil 152 | } 153 | } 154 | log.Printf("Pulling [cyan]%s[reset]", buildDir) 155 | err = makeCmd(ctx, buildDir, "git", "pull").Run() 156 | if err != nil { 157 | return fmt.Errorf("failed to pull repo: %w", err) 158 | } 159 | } 160 | buildScript := "./build.sh" 161 | if v2 { 162 | buildScript = "./build-v2.sh" 163 | } 164 | log.Printf("Compiling bridge with %s", buildScript) 165 | err = makeCmd(ctx, buildDir, buildScript).Run() 166 | if err != nil { 167 | return fmt.Errorf("failed to compile bridge: %w", err) 168 | } 169 | log.Printf("Successfully compiled bridge") 170 | return nil 171 | } 172 | 173 | func setupPythonVenv(ctx context.Context, bridgeDir, bridgeType string, localDev bool) (string, error) { 174 | var installPackage string 175 | localRequirements := []string{"-r", "requirements.txt"} 176 | switch bridgeType { 177 | case "heisenbridge": 178 | installPackage = "heisenbridge" 179 | case "telegram", "googlechat": 180 | installPackage = fmt.Sprintf("mautrix-%s[all]", bridgeType) 181 | localRequirements = append(localRequirements, "-r", "optional-requirements.txt") 182 | default: 183 | return "", fmt.Errorf("unknown python bridge type %s", bridgeType) 184 | } 185 | var venvPath string 186 | if localDev { 187 | venvPath = filepath.Join(bridgeDir, ".venv") 188 | } else { 189 | venvPath = filepath.Join(bridgeDir, "venv") 190 | } 191 | log.Printf("Creating Python virtualenv at [magenta]%s[reset]", venvPath) 192 | venvArgs := []string{"-m", "venv"} 193 | if os.Getenv("SYSTEM_SITE_PACKAGES") == "true" { 194 | venvArgs = append(venvArgs, "--system-site-packages") 195 | } 196 | venvArgs = append(venvArgs, venvPath) 197 | err := makeCmd(ctx, bridgeDir, "python3", venvArgs...).Run() 198 | if err != nil { 199 | return venvPath, fmt.Errorf("failed to create venv: %w", err) 200 | } 201 | packages := []string{installPackage} 202 | if localDev { 203 | packages = localRequirements 204 | } 205 | log.Printf("Installing [cyan]%s[reset] into virtualenv", strings.Join(packages, " ")) 206 | pipPath := filepath.Join(venvPath, "bin", "pip3") 207 | installArgs := append([]string{"install", "--upgrade"}, packages...) 208 | err = makeCmd(ctx, bridgeDir, pipPath, installArgs...).Run() 209 | if err != nil { 210 | return venvPath, fmt.Errorf("failed to install package: %w", err) 211 | } 212 | log.Printf("[green]Installation complete[reset]") 213 | return venvPath, nil 214 | } 215 | 216 | func makeCmd(ctx context.Context, pwd, path string, args ...string) *exec.Cmd { 217 | cmd := exec.CommandContext(ctx, path, args...) 218 | cmd.Dir = pwd 219 | cmd.Stdout = os.Stdout 220 | cmd.Stderr = os.Stderr 221 | cmd.Stdin = os.Stdin 222 | return cmd 223 | } 224 | 225 | func runBridge(ctx *cli.Context) error { 226 | if ctx.NArg() == 0 { 227 | return UserError{"You must specify a bridge to run"} 228 | } else if ctx.NArg() > 1 { 229 | return UserError{"Too many arguments specified (flags must come before arguments)"} 230 | } 231 | bridgeName := ctx.Args().Get(0) 232 | 233 | var err error 234 | dataDir := GetEnvConfig(ctx).BridgeDataDir 235 | var bridgeDir string 236 | compile := ctx.Bool("compile") 237 | localDev := ctx.Bool("local-dev") 238 | if localDev { 239 | if compile { 240 | log.Printf("--compile does nothing when using --local-dev") 241 | } 242 | bridgeDir, err = os.Getwd() 243 | if err != nil { 244 | return fmt.Errorf("failed to get working directory: %w", err) 245 | } 246 | } else { 247 | bridgeDir = filepath.Join(dataDir, bridgeName) 248 | err = os.MkdirAll(bridgeDir, 0700) 249 | if err != nil { 250 | return fmt.Errorf("failed to create bridge directory: %w", err) 251 | } 252 | } 253 | // TODO creating this here feels a bit hacky 254 | err = os.MkdirAll(filepath.Join(bridgeDir, "logs"), 0700) 255 | if err != nil { 256 | return err 257 | } 258 | 259 | configFileName := ctx.String("config-file") 260 | configPath := filepath.Join(bridgeDir, configFileName) 261 | noOverrideConfig := ctx.Bool("no-override-config") || localDev 262 | doWriteConfig := true 263 | if noOverrideConfig { 264 | _, err = os.Stat(configPath) 265 | doWriteConfig = errors.Is(err, fs.ErrNotExist) 266 | } 267 | 268 | var cfg *generatedBridgeConfig 269 | if !doWriteConfig { 270 | whoami, err := getCachedWhoami(ctx) 271 | if err != nil { 272 | return fmt.Errorf("failed to get whoami: %w", err) 273 | } 274 | existingBridge, ok := whoami.User.Bridges[bridgeName] 275 | if !ok || existingBridge.BridgeState.BridgeType == "" { 276 | log.Printf("Existing bridge type not found, falling back to generating new config") 277 | doWriteConfig = true 278 | } else if reg, err := doRegisterBridge(ctx, bridgeName, existingBridge.BridgeState.BridgeType, true); err != nil { 279 | log.Printf("Failed to get existing bridge registration: %v", err) 280 | log.Printf("Falling back to generating new config") 281 | doWriteConfig = true 282 | } else { 283 | cfg = &generatedBridgeConfig{ 284 | BridgeType: existingBridge.BridgeState.BridgeType, 285 | RegisterJSON: reg, 286 | } 287 | } 288 | } 289 | 290 | if doWriteConfig { 291 | cfg, err = doGenerateBridgeConfig(ctx, bridgeName) 292 | if err != nil { 293 | return err 294 | } 295 | err = os.WriteFile(configPath, []byte(cfg.Config), 0600) 296 | if err != nil { 297 | return fmt.Errorf("failed to save config: %w", err) 298 | } 299 | } else { 300 | log.Printf("Config already exists, not overriding - if you want to regenerate it, delete [cyan]%s[reset]", configPath) 301 | } 302 | 303 | overrideBridgeCmd := ctx.String("custom-startup-command") 304 | if overrideBridgeCmd != "" { 305 | if localDev { 306 | log.Printf("--local-dev does nothing when using --custom-startup-command") 307 | } 308 | if compile { 309 | log.Printf("--compile does nothing when using --custom-startup-command") 310 | } 311 | } 312 | var bridgeCmd string 313 | var bridgeArgs []string 314 | var needsWebsocketProxy bool 315 | switch cfg.BridgeType { 316 | case "imessage", "imessagego", "whatsapp", "discord", "slack", "gmessages", "gvoice", "signal", "meta", "twitter", "bluesky", "linkedin": 317 | binaryName := fmt.Sprintf("mautrix-%s", cfg.BridgeType) 318 | ciV2 := false 319 | switch cfg.BridgeType { 320 | case "": 321 | ciV2 = true 322 | } 323 | if cfg.BridgeType == "imessagego" { 324 | binaryName = "beeper-imessage" 325 | } 326 | bridgeCmd = filepath.Join(dataDir, "binaries", binaryName) 327 | if localDev && overrideBridgeCmd == "" { 328 | bridgeCmd = filepath.Join(bridgeDir, binaryName) 329 | buildScript := "./build.sh" 330 | if ciV2 { 331 | buildScript = "./build-v2.sh" 332 | } 333 | log.Printf("Compiling [cyan]%s[reset] with %s", binaryName, buildScript) 334 | err = makeCmd(ctx.Context, bridgeDir, buildScript).Run() 335 | if err != nil { 336 | return fmt.Errorf("failed to compile bridge: %w", err) 337 | } 338 | } else if compile && overrideBridgeCmd == "" { 339 | buildDir := filepath.Join(dataDir, "compile", binaryName) 340 | bridgeCmd = filepath.Join(buildDir, binaryName) 341 | err = compileGoBridge(ctx.Context, buildDir, bridgeCmd, cfg.BridgeType, ctx.Bool("no-update")) 342 | if err != nil { 343 | return fmt.Errorf("failed to compile bridge: %w", err) 344 | } 345 | } else if overrideBridgeCmd == "" { 346 | err = updateGoBridge(ctx.Context, bridgeCmd, cfg.BridgeType, ciV2, ctx.Bool("no-update")) 347 | if errors.Is(err, gitlab.ErrNotBuiltInCI) { 348 | return UserError{fmt.Sprintf("Binaries for %s are not built in the CI. Use --compile to tell bbctl to build the bridge locally.", binaryName)} 349 | } else if err != nil { 350 | return fmt.Errorf("failed to update bridge: %w", err) 351 | } 352 | } 353 | bridgeArgs = []string{"-c", configFileName} 354 | case "telegram", "googlechat": 355 | if overrideBridgeCmd == "" { 356 | var venvPath string 357 | venvPath, err = setupPythonVenv(ctx.Context, bridgeDir, cfg.BridgeType, localDev) 358 | if err != nil { 359 | return fmt.Errorf("failed to update bridge: %w", err) 360 | } 361 | bridgeCmd = filepath.Join(venvPath, "bin", "python3") 362 | } 363 | bridgeArgs = []string{"-m", "mautrix_" + cfg.BridgeType, "-c", configFileName} 364 | needsWebsocketProxy = true 365 | case "heisenbridge": 366 | if overrideBridgeCmd == "" { 367 | var venvPath string 368 | venvPath, err = setupPythonVenv(ctx.Context, bridgeDir, cfg.BridgeType, localDev) 369 | if err != nil { 370 | return fmt.Errorf("failed to update bridge: %w", err) 371 | } 372 | bridgeCmd = filepath.Join(venvPath, "bin", "python3") 373 | } 374 | heisenHomeserverURL := strings.Replace(cfg.HomeserverURL, "https://", "wss://", 1) 375 | bridgeArgs = []string{"-m", "heisenbridge", "-c", configFileName, "-o", cfg.YourUserID.String(), heisenHomeserverURL} 376 | default: 377 | if overrideBridgeCmd == "" { 378 | return UserError{"Unsupported bridge type for bbctl run"} 379 | } 380 | } 381 | if overrideBridgeCmd != "" { 382 | bridgeCmd = overrideBridgeCmd 383 | } 384 | 385 | cmd := makeCmd(ctx.Context, bridgeDir, bridgeCmd, bridgeArgs...) 386 | if runtime.GOOS == "linux" { 387 | cmd.SysProcAttr = &syscall.SysProcAttr{ 388 | // Don't pass through signals to the bridge, we'll send a sigterm when we want to stop it. 389 | // Causes weird issues on macOS, so limited to Linux. 390 | Setpgid: true, 391 | } 392 | } 393 | var as *appservice.AppService 394 | var wg sync.WaitGroup 395 | var cancelWS context.CancelFunc 396 | wsProxyClosed := make(chan struct{}) 397 | if needsWebsocketProxy { 398 | if cfg.Registration.URL == "" || cfg.Registration.URL == "websocket" { 399 | _, _, cfg.Registration.URL = getBridgeWebsocketProxyConfig(bridgeName, cfg.BridgeType) 400 | } 401 | wg.Add(2) 402 | log.Printf("Starting websocket proxy") 403 | as = appservice.Create() 404 | as.Registration = cfg.Registration 405 | as.HomeserverDomain = "beeper.local" 406 | prepareAppserviceWebsocketProxy(ctx, as) 407 | var wsCtx context.Context 408 | wsCtx, cancelWS = context.WithCancel(ctx.Context) 409 | defer cancelWS() 410 | go runAppserviceWebsocket(wsCtx, func() { 411 | wg.Done() 412 | close(wsProxyClosed) 413 | }, as) 414 | go keepaliveAppserviceWebsocket(wsCtx, wg.Done, as) 415 | } 416 | 417 | log.Printf("Starting [cyan]%s[reset]", cfg.BridgeType) 418 | 419 | c := make(chan os.Signal, 1) 420 | interrupted := false 421 | signal.Notify(c, os.Interrupt, syscall.SIGTERM) 422 | 423 | go func() { 424 | select { 425 | case <-c: 426 | interrupted = true 427 | fmt.Println() 428 | case <-wsProxyClosed: 429 | log.Printf("Websocket proxy exited, shutting down bridge") 430 | } 431 | log.Printf("Shutting down [cyan]%s[reset]", cfg.BridgeType) 432 | if as != nil && as.StopWebsocket != nil { 433 | as.StopWebsocket(appservice.ErrWebsocketManualStop) 434 | } 435 | proc := cmd.Process 436 | // On non-Linux, assume setpgid wasn't set, so the signal will be automatically sent to both processes. 437 | if proc != nil && runtime.GOOS == "linux" { 438 | err := proc.Signal(syscall.SIGTERM) 439 | if err != nil { 440 | log.Printf("Failed to send SIGTERM to bridge: %v", err) 441 | } 442 | } 443 | time.Sleep(3 * time.Second) 444 | log.Printf("Killing process") 445 | err := proc.Kill() 446 | if err != nil { 447 | log.Printf("Failed to kill bridge: %v", err) 448 | } 449 | os.Exit(1) 450 | }() 451 | 452 | err = cmd.Run() 453 | if !interrupted { 454 | log.Printf("Bridge exited") 455 | } 456 | if as != nil && as.StopWebsocket != nil { 457 | as.StopWebsocket(appservice.ErrWebsocketManualStop) 458 | } 459 | if cancelWS != nil { 460 | cancelWS() 461 | } 462 | if err != nil { 463 | return err 464 | } 465 | wg.Wait() 466 | return nil 467 | } 468 | -------------------------------------------------------------------------------- /cmd/bbctl/whoami.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "regexp" 7 | "sort" 8 | "strings" 9 | 10 | "github.com/fatih/color" 11 | "github.com/urfave/cli/v2" 12 | "golang.org/x/exp/maps" 13 | "maunium.net/go/mautrix/bridge/status" 14 | 15 | "github.com/beeper/bridge-manager/api/beeperapi" 16 | "github.com/beeper/bridge-manager/cli/hyper" 17 | "github.com/beeper/bridge-manager/log" 18 | ) 19 | 20 | var whoamiCommand = &cli.Command{ 21 | Name: "whoami", 22 | Aliases: []string{"w"}, 23 | Usage: "Get info about yourself", 24 | Flags: []cli.Flag{ 25 | &cli.BoolFlag{ 26 | Name: "raw", 27 | Aliases: []string{"r"}, 28 | EnvVars: []string{"BEEPER_WHOAMI_RAW"}, 29 | Usage: "Get raw JSON output instead of pretty-printed bridge status", 30 | }, 31 | }, 32 | Before: RequiresAuth, 33 | Action: whoamiFunction, 34 | } 35 | 36 | func coloredHomeserver(domain string) string { 37 | switch domain { 38 | case "beeper.com": 39 | return color.GreenString(domain) 40 | case "beeper-staging.com": 41 | return color.CyanString(domain) 42 | case "beeper-dev.com": 43 | return color.RedString(domain) 44 | case "beeper.localtest.me": 45 | return color.YellowString(domain) 46 | default: 47 | return domain 48 | } 49 | } 50 | 51 | func coloredChannel(channel string) string { 52 | switch channel { 53 | case "STABLE": 54 | return color.GreenString(channel) 55 | case "NIGHTLY": 56 | return color.YellowString(channel) 57 | case "INTERNAL": 58 | return color.RedString(channel) 59 | default: 60 | return channel 61 | } 62 | } 63 | 64 | func coloredBridgeState(state status.BridgeStateEvent) string { 65 | switch state { 66 | case status.StateStarting, status.StateConnecting: 67 | return color.CyanString(string(state)) 68 | case status.StateTransientDisconnect, status.StateBridgeUnreachable: 69 | return color.YellowString(string(state)) 70 | case status.StateUnknownError, status.StateBadCredentials: 71 | return color.RedString(string(state)) 72 | case status.StateRunning, status.StateConnected: 73 | return color.GreenString(string(state)) 74 | default: 75 | return string(state) 76 | } 77 | } 78 | 79 | var bridgeImageRegex = regexp.MustCompile(`^docker\.beeper-tools\.com/(?:bridge/)?([a-z]+):(v2-)?([0-9a-f]{40})(?:-amd64)?$`) 80 | 81 | var dockerToGitRepo = map[string]string{ 82 | "hungryserv": "https://github.com/beeper/hungryserv/commit/%s", 83 | "discordgo": "https://github.com/mautrix/discord/commit/%s", 84 | "dummybridge": "https://github.com/beeper/dummybridge/commit/%s", 85 | "facebook": "https://github.com/mautrix/facebook/commit/%s", 86 | "googlechat": "https://github.com/mautrix/googlechat/commit/%s", 87 | "instagram": "https://github.com/mautrix/instagram/commit/%s", 88 | "meta": "https://github.com/mautrix/meta/commit/%s", 89 | "linkedin": "https://github.com/mautrix/linkedin/commit/%s", 90 | "signal": "https://github.com/mautrix/signal/commit/%s", 91 | "slackgo": "https://github.com/mautrix/slack/commit/%s", 92 | "telegram": "https://github.com/mautrix/telegram/commit/%s", 93 | "telegramgo": "https://github.com/mautrix/telegramgo/commit/%s", 94 | "twitter": "https://github.com/mautrix/twitter/commit/%s", 95 | "bluesky": "https://github.com/mautrix/bluesky/commit/%s", 96 | "whatsapp": "https://github.com/mautrix/whatsapp/commit/%s", 97 | } 98 | 99 | func parseBridgeImage(bridge, image string, internal bool) string { 100 | if image == "" || image == "?" { 101 | // Self-hosted bridges don't have a version in whoami 102 | return "" 103 | } else if bridge == "imessagecloud" { 104 | return image[:8] 105 | } 106 | match := bridgeImageRegex.FindStringSubmatch(image) 107 | if match == nil { 108 | return color.YellowString(image) 109 | } 110 | if match[1] == "hungryserv" && !internal { 111 | return match[3][:8] 112 | } 113 | return color.HiBlueString(match[2] + hyper.Link(match[3][:8], fmt.Sprintf(dockerToGitRepo[match[1]], match[3]), false)) 114 | } 115 | 116 | func formatBridgeRemotes(name string, bridge beeperapi.WhoamiBridge) string { 117 | switch { 118 | case name == "hungryserv", name == "androidsms", name == "imessage": 119 | return "" 120 | case len(bridge.RemoteState) == 0: 121 | if bridge.BridgeState.IsSelfHosted { 122 | return "" 123 | } 124 | return color.YellowString("not logged in") 125 | case len(bridge.RemoteState) == 1: 126 | remoteState := maps.Values(bridge.RemoteState)[0] 127 | return fmt.Sprintf("remote: %s (%s / %s)", coloredBridgeState(remoteState.StateEvent), color.CyanString(remoteState.RemoteName), color.CyanString(remoteState.RemoteID)) 128 | case len(bridge.RemoteState) > 1: 129 | return "multiple remotes" 130 | } 131 | return "" 132 | } 133 | 134 | func formatBridge(name string, bridge beeperapi.WhoamiBridge, internal bool) string { 135 | formatted := color.CyanString(name) 136 | versionString := parseBridgeImage(name, bridge.Version, internal) 137 | if versionString != "" { 138 | formatted += fmt.Sprintf(" (version: %s)", versionString) 139 | } 140 | if bridge.BridgeState.IsSelfHosted { 141 | var typeName string 142 | if !strings.Contains(name, bridge.BridgeState.BridgeType) { 143 | typeName = bridge.BridgeState.BridgeType + ", " 144 | } 145 | formatted += fmt.Sprintf(" (%s%s)", typeName, color.HiGreenString("self-hosted")) 146 | } 147 | formatted += fmt.Sprintf(" - %s", coloredBridgeState(bridge.BridgeState.StateEvent)) 148 | remotes := formatBridgeRemotes(name, bridge) 149 | if remotes != "" { 150 | formatted += " - " + remotes 151 | } 152 | return formatted 153 | } 154 | 155 | var cachedWhoami *beeperapi.RespWhoami 156 | 157 | func getCachedWhoami(ctx *cli.Context) (*beeperapi.RespWhoami, error) { 158 | if cachedWhoami != nil { 159 | return cachedWhoami, nil 160 | } 161 | ec := GetEnvConfig(ctx) 162 | resp, err := beeperapi.Whoami(ctx.String("homeserver"), ec.AccessToken) 163 | if err != nil { 164 | return nil, err 165 | } 166 | changed := false 167 | if ec.Username != resp.UserInfo.Username { 168 | ec.Username = resp.UserInfo.Username 169 | changed = true 170 | } 171 | if ec.ClusterID != resp.UserInfo.BridgeClusterID { 172 | ec.ClusterID = resp.UserInfo.BridgeClusterID 173 | changed = true 174 | } 175 | if changed { 176 | err = GetConfig(ctx).Save() 177 | if err != nil { 178 | log.Printf("Failed to save config after updating: %v", err) 179 | } 180 | } 181 | cachedWhoami = resp 182 | return resp, nil 183 | } 184 | 185 | func whoamiFunction(ctx *cli.Context) error { 186 | whoami, err := getCachedWhoami(ctx) 187 | if err != nil { 188 | return fmt.Errorf("failed to get whoami: %w", err) 189 | } 190 | if ctx.Bool("raw") { 191 | data, err := json.MarshalIndent(whoami, "", " ") 192 | if err != nil { 193 | return fmt.Errorf("failed to marshal JSON: %w", err) 194 | } 195 | fmt.Println(string(data)) 196 | return nil 197 | } 198 | if oldID := GetEnvConfig(ctx).ClusterID; whoami.UserInfo.BridgeClusterID != oldID { 199 | GetEnvConfig(ctx).ClusterID = whoami.UserInfo.BridgeClusterID 200 | err = GetConfig(ctx).Save() 201 | if err != nil { 202 | fmt.Printf("Noticed cluster ID changed from %s to %s, but failed to save change: %v\n", oldID, whoami.UserInfo.BridgeClusterID, err) 203 | } else { 204 | fmt.Printf("Noticed cluster ID changed from %s to %s and saved to config\n", oldID, whoami.UserInfo.BridgeClusterID) 205 | } 206 | } 207 | homeserver := ctx.String("homeserver") 208 | fmt.Printf("User ID: @%s:%s\n", color.GreenString(whoami.UserInfo.Username), coloredHomeserver(homeserver)) 209 | if whoami.UserInfo.Admin { 210 | fmt.Printf("Admin: %s\n", color.RedString("true")) 211 | } 212 | if whoami.UserInfo.Free { 213 | fmt.Printf("Free: %s\n", color.GreenString("true")) 214 | } 215 | fmt.Printf("Name: %s\n", color.CyanString(whoami.UserInfo.FullName)) 216 | fmt.Printf("Email: %s\n", color.CyanString(whoami.UserInfo.Email)) 217 | fmt.Printf("Support room ID: %s\n", color.CyanString(whoami.UserInfo.SupportRoomID.String())) 218 | fmt.Printf("Registered at: %s\n", color.CyanString(whoami.UserInfo.CreatedAt.Local().Format(BuildTimeFormat))) 219 | fmt.Printf("Cloud bridge details:\n") 220 | fmt.Printf(" Update channel: %s\n", coloredChannel(whoami.UserInfo.Channel)) 221 | fmt.Printf(" Cluster ID: %s\n", color.CyanString(whoami.UserInfo.BridgeClusterID)) 222 | hungryAPI := GetHungryClient(ctx) 223 | homeserverURL := hungryAPI.HomeserverURL.String() 224 | fmt.Printf(" Hungryserv URL: %s\n", color.CyanString(hyper.Link(homeserverURL, homeserverURL, false))) 225 | fmt.Printf("Bridges:\n") 226 | internal := homeserver != "beeper.com" || whoami.UserInfo.Channel == "INTERNAL" 227 | fmt.Println(" ", formatBridge("hungryserv", whoami.User.Hungryserv, internal)) 228 | keys := maps.Keys(whoami.User.Bridges) 229 | sort.Strings(keys) 230 | for _, name := range keys { 231 | fmt.Println(" ", formatBridge(name, whoami.User.Bridges[name], internal)) 232 | } 233 | return nil 234 | } 235 | -------------------------------------------------------------------------------- /docker/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM dock.mau.dev/tulir/lottieconverter:alpine-3.21 AS lottie 2 | 3 | FROM golang:1.24-alpine3.21 AS builder 4 | 5 | COPY . /build/ 6 | RUN cd /build && ./build.sh 7 | 8 | FROM alpine:3.21 9 | 10 | RUN apk add --no-cache bash curl jq git ffmpeg \ 11 | # Python for python bridges 12 | python3 py3-pip py3-setuptools py3-wheel \ 13 | # Common dependencies that need native extensions for Python bridges 14 | py3-magic py3-ruamel.yaml py3-aiohttp py3-pillow py3-olm py3-pycryptodome 15 | 16 | COPY --from=lottie /cryptg-*.whl /tmp/ 17 | RUN pip3 install --break-system-packages /tmp/cryptg-*.whl && rm -f /tmp/cryptg-*.whl 18 | 19 | COPY --from=builder /build/bbctl /usr/local/bin/bbctl 20 | COPY --from=lottie /usr/lib/librlottie.so* /usr/lib/ 21 | COPY --from=lottie /usr/local/bin/lottieconverter /usr/local/bin/lottieconverter 22 | COPY ./docker/run-bridge.sh /usr/local/bin/run-bridge.sh 23 | ENV SYSTEM_SITE_PACKAGES=true 24 | VOLUME /data 25 | 26 | ENTRYPOINT ["/usr/local/bin/run-bridge.sh"] 27 | -------------------------------------------------------------------------------- /docker/README.md: -------------------------------------------------------------------------------- 1 | # Docker 2 | bridge-manager includes a docker file which wraps `bbctl run`. It's primarily 3 | meant for the automated Fly deployer ([self-host.beeper.com]), but can be used 4 | manually as well. 5 | 6 | [self-host.beeper.com]: https://self-host.beeper.com 7 | 8 | ## Usage 9 | ```sh 10 | docker run \ 11 | # Mount the current directory to /data in the container 12 | # (the bridge binaries, config and database will be stored here) 13 | -v $(pwd):/data \ 14 | # Pass your Beeper access token here. You can find it in ~/.config/bbctl/config.json 15 | # or Beeper Desktop settings -> Help & About 16 | -e MATRIX_ACCESS_TOKEN=... \ 17 | # The image to run, followed by the name of the bridge to run. 18 | ghcr.io/beeper/bridge-manager sh-telegram 19 | ``` 20 | 21 | The container should work fine as any user (as long as the mounted `/data` 22 | directory is writable), so you can just use the standard `--user` flag to 23 | change the UID/GID. 24 | -------------------------------------------------------------------------------- /docker/run-bridge.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euf -o pipefail 3 | if [[ -z "${BRIDGE_NAME:-}" ]]; then 4 | if [[ ! -z "$1" ]]; then 5 | export BRIDGE_NAME="$1" 6 | else 7 | echo "BRIDGE_NAME not set" 8 | exit 1 9 | fi 10 | fi 11 | export BBCTL_CONFIG=${BBCTL_CONFIG:-/tmp/bbctl.json} 12 | export BEEPER_ENV=${BEEPER_ENV:-prod} 13 | if [[ ! -f $BBCTL_CONFIG ]]; then 14 | if [[ -z "$MATRIX_ACCESS_TOKEN" ]]; then 15 | echo "MATRIX_ACCESS_TOKEN not set" 16 | exit 1 17 | fi 18 | export DATA_DIR=${DATA_DIR:-/data} 19 | if [[ ! -d $DATA_DIR ]]; then 20 | echo "DATA_DIR ($DATA_DIR) does not exist, creating" 21 | mkdir -p $DATA_DIR 22 | fi 23 | export DB_DIR=${DB_DIR:-/data/db} 24 | mkdir -p $DB_DIR 25 | jq -n '{environments: {"\(env.BEEPER_ENV)": {access_token: env.MATRIX_ACCESS_TOKEN, database_dir: env.DB_DIR, bridge_data_dir: env.DATA_DIR}}}' > $BBCTL_CONFIG 26 | fi 27 | bbctl -e $BEEPER_ENV run $BRIDGE_NAME 28 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/beeper/bridge-manager 2 | 3 | go 1.23.0 4 | 5 | toolchain go1.24.0 6 | 7 | require ( 8 | github.com/AlecAivazis/survey/v2 v2.3.7 9 | github.com/fatih/color v1.18.0 10 | github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db 11 | github.com/rs/zerolog v1.33.0 12 | github.com/schollz/progressbar/v3 v3.18.0 13 | github.com/tidwall/gjson v1.18.0 14 | github.com/urfave/cli/v2 v2.27.5 15 | go.mau.fi/util v0.8.5 16 | golang.org/x/exp v0.0.0-20250228200357-dead58393ab7 17 | maunium.net/go/mautrix v0.23.1 18 | ) 19 | 20 | require ( 21 | filippo.io/edwards25519 v1.1.0 // indirect 22 | github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect 23 | github.com/gorilla/mux v1.8.0 // indirect 24 | github.com/gorilla/websocket v1.5.0 // indirect 25 | github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect 26 | github.com/mattn/go-colorable v0.1.14 // indirect 27 | github.com/mattn/go-isatty v0.0.20 // indirect 28 | github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b // indirect 29 | github.com/rivo/uniseg v0.4.7 // indirect 30 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 31 | github.com/tidwall/match v1.1.1 // indirect 32 | github.com/tidwall/pretty v1.2.1 // indirect 33 | github.com/tidwall/sjson v1.2.5 // indirect 34 | github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect 35 | golang.org/x/crypto v0.33.0 // indirect 36 | golang.org/x/net v0.35.0 // indirect 37 | golang.org/x/sys v0.30.0 // indirect 38 | golang.org/x/term v0.29.0 // indirect 39 | golang.org/x/text v0.22.0 // indirect 40 | gopkg.in/yaml.v3 v3.0.1 // indirect 41 | ) 42 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= 2 | filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= 3 | github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ= 4 | github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo= 5 | github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s= 6 | github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w= 7 | github.com/chengxilo/virtualterm v1.0.4 h1:Z6IpERbRVlfB8WkOmtbHiDbBANU7cimRIof7mk9/PwM= 8 | github.com/chengxilo/virtualterm v1.0.4/go.mod h1:DyxxBZz/x1iqJjFxTFcr6/x+jSpqN0iwWCOK1q10rlY= 9 | github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= 10 | github.com/cpuguy83/go-md2man/v2 v2.0.5 h1:ZtcqGrnekaHpVLArFSe4HK5DoKx1T0rq2DwVB0alcyc= 11 | github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 12 | github.com/creack/pty v1.1.17 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI= 13 | github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= 14 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 15 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 16 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 17 | github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= 18 | github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= 19 | github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 20 | github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= 21 | github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= 22 | github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= 23 | github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 24 | github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog= 25 | github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68= 26 | github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= 27 | github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= 28 | github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= 29 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 30 | github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= 31 | github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= 32 | github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= 33 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 34 | github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 35 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 36 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 37 | github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 38 | github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 39 | github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4= 40 | github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= 41 | github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ= 42 | github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw= 43 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 44 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 45 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 46 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 47 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 48 | github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= 49 | github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8= 50 | github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= 51 | github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= 52 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 53 | github.com/schollz/progressbar/v3 v3.18.0 h1:uXdoHABRFmNIjUfte/Ex7WtuyVslrw2wVPQmCN62HpA= 54 | github.com/schollz/progressbar/v3 v3.18.0/go.mod h1:IsO3lpbaGuzh8zIMzgY3+J8l4C8GjO0Y9S69eFvNsec= 55 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 56 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 57 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 58 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 59 | github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= 60 | github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= 61 | github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= 62 | github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= 63 | github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= 64 | github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= 65 | github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= 66 | github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= 67 | github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= 68 | github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= 69 | github.com/urfave/cli/v2 v2.27.5 h1:WoHEJLdsXr6dDWoJgMq/CboDmyY/8HMMH1fTECbih+w= 70 | github.com/urfave/cli/v2 v2.27.5/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ= 71 | github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= 72 | github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= 73 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 74 | go.mau.fi/util v0.8.5 h1:PwCAAtcfK0XxZ4sdErJyfBMkTEWoQU33aB7QqDDzQRI= 75 | go.mau.fi/util v0.8.5/go.mod h1:Ycug9mrbztlahHPEJ6H5r8Nu/xqZaWbE5vPHVWmfz6M= 76 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 77 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 78 | golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= 79 | golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= 80 | golang.org/x/exp v0.0.0-20250228200357-dead58393ab7 h1:aWwlzYV971S4BXRS9AmqwDLAD85ouC6X+pocatKY58c= 81 | golang.org/x/exp v0.0.0-20250228200357-dead58393ab7/go.mod h1:BHOTPb3L19zxehTsLoJXVaTktb06DFgmdW6Wb9s8jqk= 82 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 83 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 84 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 85 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 86 | golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= 87 | golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= 88 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 89 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 90 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 91 | golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 92 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 93 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 94 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 95 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 96 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 97 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 98 | golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 99 | golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= 100 | golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 101 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 102 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 103 | golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU= 104 | golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s= 105 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 106 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 107 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 108 | golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 109 | golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= 110 | golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= 111 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 112 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 113 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 114 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 115 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 116 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 117 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 118 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 119 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 120 | maunium.net/go/mautrix v0.23.1 h1:xZtX43YZF3WRxkdR+oMUrpiQe+jbjc+LeXLxHuXP5IM= 121 | maunium.net/go/mautrix v0.23.1/go.mod h1:kldoZQDneV/jquIhwG1MmMw5j2A2M/MnQYRSWt863cY= 122 | -------------------------------------------------------------------------------- /log/log.go: -------------------------------------------------------------------------------- 1 | package log 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/fatih/color" 8 | "github.com/mitchellh/colorstring" 9 | ) 10 | 11 | func Printf(format string, args ...any) { 12 | if !color.NoColor { 13 | format = colorstring.Color(format) 14 | } 15 | _, _ = fmt.Fprintf(os.Stderr, format+"\n", args...) 16 | } 17 | -------------------------------------------------------------------------------- /run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | go run -ldflags "-X main.Tag=$(git describe --exact-match --tags 2>/dev/null) -X main.Commit=$(git rev-parse HEAD) -X 'main.BuildTime=`date -Iseconds`'" github.com/beeper/bridge-manager/cmd/bbctl "$@" 3 | --------------------------------------------------------------------------------