├── .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 |
--------------------------------------------------------------------------------