diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..39eb3d4 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +root = true + +[*] +indent_style = tab +indent_size = 4 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.md] +trim_trailing_whitespace = false + +[*.{yaml,yml,sql}] +indent_style = space + +[{.gitlab-ci.yml,.github/workflows/*.yml}] +indent_size = 2 diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml new file mode 100644 index 0000000..2479296 --- /dev/null +++ b/.github/workflows/go.yml @@ -0,0 +1,33 @@ +name: Go + +on: [push, pull_request] + +jobs: + lint: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + go-version: ["1.22"] + name: Lint ${{ matrix.go-version == '1.22' && '(latest)' || '(old)' }} + + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: ${{ matrix.go-version }} + cache: true + + - name: Install libolm + run: sudo apt-get install libolm-dev + + - name: Install dependencies + run: | + go install golang.org/x/tools/cmd/goimports@latest + go install honnef.co/go/tools/cmd/staticcheck@latest + export PATH="$HOME/go/bin:$PATH" + + - name: Run pre-commit + uses: pre-commit/action@v3.0.0 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..96cab54 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +*.yaml +!example-config.yaml +!.pre-commit-config.yaml + +*.db* +*.log* + +/mautrix-twilio +/start diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..6a50b7f --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,6 @@ +include: +- project: 'mautrix/ci' + file: '/gov2.yml' + +variables: + BINARY_NAME_V2: mautrix-twilio diff --git a/.idea/icon.svg b/.idea/icon.svg new file mode 100644 index 0000000..1bec791 --- /dev/null +++ b/.idea/icon.svg @@ -0,0 +1 @@ + diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..b7d8855 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,27 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.6.0 + hooks: + - id: trailing-whitespace + exclude_types: [markdown] + - id: end-of-file-fixer + - id: check-yaml + - id: check-added-large-files + + - repo: https://github.com/tekwizely/pre-commit-golang + rev: v1.0.0-rc.1 + hooks: + - id: go-imports + exclude: "pb\\.go$" + args: + - "-local" + - "go.mau.fi/mautrix-twilio" + - "-w" + - id: go-vet-mod + - id: go-staticcheck-repo-mod + + - repo: https://github.com/beeper/pre-commit-go + rev: v0.3.1 + hooks: + - id: zerolog-ban-msgf + - id: zerolog-use-stringer diff --git a/Dockerfile.v2.ci b/Dockerfile.v2.ci new file mode 100644 index 0000000..78bd768 --- /dev/null +++ b/Dockerfile.v2.ci @@ -0,0 +1,15 @@ +FROM alpine:3.20 + +ENV UID=1337 \ + GID=1337 + +RUN apk add --no-cache ffmpeg su-exec ca-certificates bash jq curl yq-go + +ARG EXECUTABLE=./mautrix-twilio +COPY $EXECUTABLE /usr/bin/mautrix-twilio +COPY ./docker-run.sh /docker-run.sh +ENV BRIDGEV2=1 +VOLUME /data +WORKDIR /data + +CMD ["/docker-run.sh"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f2c2db0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Tulir Asokan + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..be19ae2 --- /dev/null +++ b/build.sh @@ -0,0 +1,4 @@ +#!/bin/sh +MAUTRIX_VERSION=$(cat go.mod | grep 'maunium.net/go/mautrix ' | awk '{ print $2 }' | head -n1) +GO_LDFLAGS="-s -w -X main.Tag=$(git describe --exact-match --tags 2>/dev/null) -X main.Commit=$(git rev-parse HEAD) -X 'main.BuildTime=`date -Iseconds`' -X 'maunium.net/go/mautrix.GoModVersion=$MAUTRIX_VERSION'" +go build -ldflags="$GO_LDFLAGS" ./cmd/mautrix-twilio "$@" diff --git a/cmd/mautrix-twilio/main.go b/cmd/mautrix-twilio/main.go new file mode 100644 index 0000000..0028707 --- /dev/null +++ b/cmd/mautrix-twilio/main.go @@ -0,0 +1,27 @@ +package main + +import ( + "maunium.net/go/mautrix/bridgev2/matrix/mxmain" + + "go.mau.fi/mautrix-twilio/pkg/connector" +) + +// Information to find out exactly which commit the bridge was built from. +// These are filled at build time with the -X linker flag. +var ( + Tag = "unknown" + Commit = "unknown" + BuildTime = "unknown" +) + +func main() { + m := mxmain.BridgeMain{ + Name: "mautrix-twilio", + Description: "A Matrix-Twilio bridge", + URL: "https://github.com/mautrix/twilio", + Version: "0.1.0", + Connector: &connector.TwilioConnector{}, + } + m.InitVersion(Tag, Commit, BuildTime) + m.Run() +} diff --git a/docker-run.sh b/docker-run.sh new file mode 100755 index 0000000..6798d5e --- /dev/null +++ b/docker-run.sh @@ -0,0 +1,32 @@ +#!/bin/bash + +if [[ -z "$GID" ]]; then + GID="$UID" +fi + +BINARY_NAME=/usr/bin/mautrix-twilio + +function fixperms { + chown -R $UID:$GID /data +} + +if [[ ! -f /data/config.yaml ]]; then + $BINARY_NAME -c /data/config -e + echo "Didn't find a config file." + echo "Copied default config file to /data/config.yaml" + echo "Modify that config file to your liking." + echo "Start the container again after that to generate the registration file." + exit +fi + +if [[ ! -f /data/registration.yaml ]]; then + $BINARY_NAME -g -c /data/config.yaml -r /data/registration.yaml || exit $? + echo "Didn't find a registration file." + echo "Generated one for you." + echo "See https://docs.mau.fi/bridges/general/registering-appservices.html on how to use it." + exit +fi + +cd /data +fixperms +exec su-exec $UID:$GID $BINARY_NAME diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..4bbd4ca --- /dev/null +++ b/go.mod @@ -0,0 +1,38 @@ +module go.mau.fi/mautrix-twilio + +go 1.22 + +require ( + github.com/gorilla/mux v1.8.0 + github.com/rs/zerolog v1.33.0 + github.com/twilio/twilio-go v1.22.2 + go.mau.fi/util v0.5.1-0.20240702170310-bd1da3c069eb + maunium.net/go/mautrix v0.19.0-beta.1.0.20240706124659-b4057a26c3ed +) + +require ( + github.com/beevik/etree v1.1.0 // indirect + github.com/coreos/go-systemd/v22 v22.5.0 // indirect + github.com/golang/mock v1.6.0 // indirect + github.com/gorilla/websocket v1.5.0 // indirect + github.com/lib/pq v1.10.9 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.19 // indirect + github.com/mattn/go-sqlite3 v1.14.22 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/rs/xid v1.5.0 // indirect + github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect + github.com/tidwall/gjson v1.17.1 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.0 // indirect + github.com/tidwall/sjson v1.2.5 // indirect + github.com/yuin/goldmark v1.7.4 // indirect + go.mau.fi/zeroconfig v0.1.2 // indirect + golang.org/x/crypto v0.24.0 // indirect + golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 // indirect + golang.org/x/net v0.26.0 // indirect + golang.org/x/sys v0.21.0 // indirect + gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + maunium.net/go/mauflag v1.0.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..14cf66b --- /dev/null +++ b/go.sum @@ -0,0 +1,112 @@ +github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= +github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= +github.com/beevik/etree v1.1.0 h1:T0xke/WvNtMoCqgzPhkX2r4rjY3GDZFi+FjpRZY2Jbs= +github.com/beevik/etree v1.1.0/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A= +github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= +github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= +github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= +github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= +github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= +github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= +github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/localtunnel/go-localtunnel v0.0.0-20170326223115-8a804488f275 h1:IZycmTpoUtQK3PD60UYBwjaCUHUP7cML494ao9/O8+Q= +github.com/localtunnel/go-localtunnel v0.0.0-20170326223115-8a804488f275/go.mod h1:zt6UU74K6Z6oMOYJbJzYpYucqdcQwSMPBEdSvGiaUMw= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rs/xid v1.5.0 h1:mKX4bl4iPYJtEIxp6CYiUuLQ/8DYMoz0PUdtGgMFRVc= +github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= +github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8= +github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.17.1 h1:wlYEnwqAHgzmhNUFfw7Xalt2JzQvsMx2Se4PcoFCT/U= +github.com/tidwall/gjson v1.17.1/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +github.com/twilio/twilio-go v1.22.2 h1:LUz6OTWKY4/oW4e+O2ah2JMq03gJvGu6bxaF0Y7l+Xc= +github.com/twilio/twilio-go v1.22.2/go.mod h1:zRkMjudW7v7MqQ3cWNZmSoZJ7EBjPZ4OpNh2zm7Q6ko= +github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +github.com/yuin/goldmark v1.7.4 h1:BDXOHExt+A7gwPCJgPIIq7ENvceR7we7rOS9TNoLZeg= +github.com/yuin/goldmark v1.7.4/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= +go.mau.fi/util v0.5.1-0.20240702170310-bd1da3c069eb h1:VZPo2pvfjNj6fkFv5e9FyTYx96BLwwYNA19WYaY+KN8= +go.mau.fi/util v0.5.1-0.20240702170310-bd1da3c069eb/go.mod h1:DsJzUrJAG53lCZnnYvq9/mOyLuPScWwYhvETiTrpdP4= +go.mau.fi/zeroconfig v0.1.2 h1:DKOydWnhPMn65GbXZOafgkPm11BvFashZWLct0dGFto= +go.mau.fi/zeroconfig v0.1.2/go.mod h1:NcSJkf180JT+1IId76PcMuLTNa1CzsFFZ0nBygIQM70= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI= +golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= +golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 h1:yixxcjnhBmY0nkL253HFVIm0JsFHwrHdT3Yh6szTnfY= +golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8/go.mod h1:jj3sYF3dwk5D+ghuXyeI3r5MFf+NT2An6/9dOA95KSI= +golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ= +golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= +golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= +gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +maunium.net/go/mauflag v1.0.0 h1:YiaRc0tEI3toYtJMRIfjP+jklH45uDHtT80nUamyD4M= +maunium.net/go/mauflag v1.0.0/go.mod h1:nLivPOpTpHnpzEh8jEdSL9UqO9+/KBJFmNRlwKfkPeA= +maunium.net/go/mautrix v0.19.0-beta.1.0.20240706124659-b4057a26c3ed h1:3F4YHSFaUJ9N0l4zNGeXZvnTBIHC9PDVOWOFiOvNn3Y= +maunium.net/go/mautrix v0.19.0-beta.1.0.20240706124659-b4057a26c3ed/go.mod h1:bNQrvIftiwJ+7OjSh+Gza5xcncq1ooHk6oyDWq4B4sg= diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go new file mode 100644 index 0000000..4e57653 --- /dev/null +++ b/pkg/connector/connector.go @@ -0,0 +1,458 @@ +package connector + +import ( + "context" + "fmt" + "net/http" + "slices" + "strings" + "time" + + "github.com/gorilla/mux" + "github.com/rs/zerolog" + "github.com/twilio/twilio-go" + tclient "github.com/twilio/twilio-go/client" + openapi "github.com/twilio/twilio-go/rest/api/v2010" + "github.com/twilio/twilio-go/twiml" + "go.mau.fi/util/configupgrade" + "go.mau.fi/util/ptr" + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/database" + "maunium.net/go/mautrix/bridgev2/networkid" + "maunium.net/go/mautrix/event" +) + +type TwilioConnector struct { + br *bridgev2.Bridge +} + +var _ bridgev2.NetworkConnector = (*TwilioConnector)(nil) + +func (tc *TwilioConnector) Init(bridge *bridgev2.Bridge) { + tc.br = bridge +} + +func (tc *TwilioConnector) Start(ctx context.Context) error { + server, ok := tc.br.Matrix.(bridgev2.MatrixConnectorWithServer) + if !ok { + return fmt.Errorf("matrix connector does not implement MatrixConnectorWithServer") + } else if server.GetPublicAddress() == "" { + return fmt.Errorf("public address of bridge not configured") + } + r := server.GetRouter().PathPrefix("/_twilio").Subrouter() + r.HandleFunc("/{loginID}/receive", tc.ReceiveMessage).Methods(http.MethodPost) + return nil +} + +func (tc *TwilioConnector) ReceiveMessage(w http.ResponseWriter, r *http.Request) { + // First make sure the signature header is present and that the request body is valid form data. + sig := r.Header.Get("X-Twilio-Signature") + if sig == "" { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte("Missing signature header\n")) + return + } + + params := make(map[string]string) + err := r.ParseForm() + if err != nil { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte("Failed to parse form data\n")) + return + } + for key, value := range r.PostForm { + params[key] = value[0] + } + + // Get the user login based on the path. We need it to find the right token + // to use for validating the request signature. + loginID := mux.Vars(r)["loginID"] + login := tc.br.GetCachedUserLoginByID(networkid.UserLoginID(loginID)) + if login == nil { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte("Unrecognized login ID in request path\n")) + return + } + client := login.Client.(*TwilioClient) + + // Now that we have the client, validate the request. + if !client.RequestValidator.Validate(client.GetWebhookURL(), params, sig) { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte("Invalid signature\n")) + return + } + + // Pass the request to the client for handling. This is where everything actually happens. + client.HandleWebhook(r.Context(), params) + + // We don't want to respond immediately, so just send a blank TwiML response. + twimlResult, err := twiml.Messages(nil) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + } else { + w.Header().Set("Content-Type", "text/xml") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(twimlResult)) + } +} + +func (tc *TwilioConnector) GetCapabilities() *bridgev2.NetworkGeneralCapabilities { + return &bridgev2.NetworkGeneralCapabilities{} +} + +func (tc *TwilioConnector) GetName() bridgev2.BridgeName { + return bridgev2.BridgeName{ + DisplayName: "Twilio", + NetworkURL: "https://twilio.com", + NetworkIcon: "mxc://maunium.net/FYuKJHaCrSeSpvBJfHwgYylP", + NetworkID: "twilio", + BeeperBridgeType: "go.mau.fi/mautrix-twilio", + DefaultPort: 29322, + } +} + +func (tc *TwilioConnector) GetConfig() (example string, data any, upgrader configupgrade.Upgrader) { + return "", nil, nil +} + +func (tc *TwilioConnector) LoadUserLogin(ctx context.Context, login *bridgev2.UserLogin) error { + accountSID := login.Metadata.Extra["account_sid"].(string) + authToken := login.Metadata.Extra["auth_token"].(string) + restClient := twilio.NewRestClientWithParams(twilio.ClientParams{ + Username: accountSID, + Password: authToken, + AccountSid: accountSID, + }) + validator := tclient.NewRequestValidator(authToken) + login.Client = &TwilioClient{ + UserLogin: login, + Twilio: restClient, + RequestValidator: validator, + } + return nil +} + +type TwilioClient struct { + UserLogin *bridgev2.UserLogin + Twilio *twilio.RestClient + RequestValidator tclient.RequestValidator +} + +var _ bridgev2.NetworkAPI = (*TwilioClient)(nil) + +func (tc *TwilioClient) Connect(ctx context.Context) error { + phoneNumbers, err := tc.Twilio.Api.ListIncomingPhoneNumber(nil) + if err != nil { + return fmt.Errorf("failed to list phone numbers: %w", err) + } + numberInUse := tc.UserLogin.Metadata.Extra["phone"].(string) + var numberFound bool + for _, number := range phoneNumbers { + if number.PhoneNumber != nil && *number.PhoneNumber == numberInUse { + numberFound = true + break + } + } + if !numberFound { + return fmt.Errorf("phone number %s not found on account", numberInUse) + } + return nil +} + +func (tc *TwilioClient) Disconnect() {} + +func (tc *TwilioClient) IsLoggedIn() bool { + return true +} + +func (tc *TwilioClient) LogoutRemote(ctx context.Context) {} + +func (tc *TwilioClient) GetCapabilities(ctx context.Context, portal *bridgev2.Portal) *bridgev2.NetworkRoomCapabilities { + return &bridgev2.NetworkRoomCapabilities{ + MaxTextLength: 1600, + } +} + +func makeUserID(e164Phone string) networkid.UserID { + return networkid.UserID(strings.TrimLeft(e164Phone, "+")) +} + +func makePortalID(e164Phone string) networkid.PortalID { + return networkid.PortalID(strings.TrimLeft(e164Phone, "+")) +} + +func makeUserLoginID(accountSID, phoneSID string) networkid.UserLoginID { + return networkid.UserLoginID(fmt.Sprintf("%s:%s", accountSID, phoneSID)) +} + +func (tc *TwilioClient) IsThisUser(ctx context.Context, userID networkid.UserID) bool { + phoneNum := tc.UserLogin.Metadata.Extra["phone"].(string) + return makeUserID(phoneNum) == userID +} + +func (tc *TwilioClient) GetChatInfo(ctx context.Context, portal *bridgev2.Portal) (*bridgev2.ChatInfo, error) { + return &bridgev2.ChatInfo{ + Members: &bridgev2.ChatMemberList{ + IsFull: true, + Members: []bridgev2.ChatMember{ + { + EventSender: bridgev2.EventSender{ + IsFromMe: true, + Sender: makeUserID(tc.UserLogin.Metadata.Extra["phone"].(string)), + }, + // This could be omitted, but leave it in to be explicit. + Membership: event.MembershipJoin, + // Make the user moderator, so they can adjust the room metadata if they want to. + PowerLevel: 50, + }, + { + EventSender: bridgev2.EventSender{ + Sender: networkid.UserID(portal.ID), + }, + Membership: event.MembershipJoin, + PowerLevel: 50, + }, + }, + }, + }, nil +} + +func (tc *TwilioClient) GetUserInfo(ctx context.Context, ghost *bridgev2.Ghost) (*bridgev2.UserInfo, error) { + return &bridgev2.UserInfo{ + Identifiers: []string{fmt.Sprintf("tel:+%s", ghost.ID)}, + Name: ptr.Ptr(fmt.Sprintf("+%s", ghost.ID)), + }, nil +} + +func (tc *TwilioClient) GetWebhookURL() string { + server := tc.UserLogin.Bridge.Matrix.(bridgev2.MatrixConnectorWithServer) + return fmt.Sprintf("%s/_twilio/%s/receive", server.GetPublicAddress(), tc.UserLogin.ID) +} + +func (tc *TwilioClient) HandleWebhook(ctx context.Context, params map[string]string) { + tc.UserLogin.Bridge.QueueRemoteEvent(tc.UserLogin, &bridgev2.SimpleRemoteEvent[map[string]string]{ + Type: bridgev2.RemoteEventMessage, + LogContext: func(c zerolog.Context) zerolog.Context { + return c. + Str("from", params["From"]). + Str("message_id", params["MessageSid"]) + }, + PortalKey: networkid.PortalKey{ + ID: makePortalID(params["From"]), + Receiver: tc.UserLogin.ID, + }, + Data: params, + CreatePortal: true, + ID: networkid.MessageID(params["MessageSid"]), + Sender: bridgev2.EventSender{ + Sender: makeUserID(params["From"]), + }, + Timestamp: time.Now(), + ConvertMessageFunc: tc.convertMessage, + }) +} + +func (tc *TwilioClient) convertMessage(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI, data map[string]string) (*bridgev2.ConvertedMessage, error) { + return &bridgev2.ConvertedMessage{ + Parts: []*bridgev2.ConvertedMessagePart{{ + Type: event.EventMessage, + Content: &event.MessageEventContent{ + MsgType: event.MsgText, + Body: data["Body"], + }, + }}, + }, nil +} + +func (tc *TwilioClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.MatrixMessage) (message *bridgev2.MatrixMessageResponse, err error) { + resp, err := tc.Twilio.Api.CreateMessage(&openapi.CreateMessageParams{ + To: ptr.Ptr(fmt.Sprintf("+%s", msg.Portal.ID)), + From: ptr.Ptr(tc.UserLogin.Metadata.Extra["phone"].(string)), + Body: ptr.Ptr(msg.Content.Body), + }) + if err != nil { + return nil, err + } + return &bridgev2.MatrixMessageResponse{ + DB: &database.Message{ + ID: networkid.MessageID(*resp.Sid), + SenderID: makeUserID(*resp.From), + }, + }, nil +} + +func (tc *TwilioConnector) GetLoginFlows() []bridgev2.LoginFlow { + return []bridgev2.LoginFlow{{ + Name: "Auth token", + Description: "Log in with your Twilio account SID and auth token", + ID: "auth-token", + }} +} + +func (tc *TwilioConnector) CreateLogin(ctx context.Context, user *bridgev2.User, flowID string) (bridgev2.LoginProcess, error) { + if flowID != "auth-token" { + return nil, fmt.Errorf("unknown login flow ID") + } + return &TwilioLogin{User: user}, nil +} + +type TwilioLogin struct { + User *bridgev2.User + Client *twilio.RestClient + PhoneNumbers []twilioPhoneNumber + AccountSID string + AuthToken string +} + +var _ bridgev2.LoginProcessUserInput = (*TwilioLogin)(nil) + +func (tl *TwilioLogin) Start(ctx context.Context) (*bridgev2.LoginStep, error) { + return &bridgev2.LoginStep{ + Type: bridgev2.LoginStepTypeUserInput, + StepID: "fi.mau.twilio.enter_api_keys", + Instructions: "", + UserInputParams: &bridgev2.LoginUserInputParams{ + Fields: []bridgev2.LoginInputDataField{ + { + Type: bridgev2.LoginInputFieldTypeUsername, + ID: "account_sid", + Name: "Twilio account SID", + Pattern: `^AC[0-9a-fA-F]{32}$`, + }, + { + Type: bridgev2.LoginInputFieldTypePassword, + ID: "auth_token", + Name: "Twilio auth token", + Pattern: "^[0-9a-f]{32}$", + }, + }, + }, + }, nil +} + +func (tl *TwilioLogin) SubmitUserInput(ctx context.Context, input map[string]string) (*bridgev2.LoginStep, error) { + if tl.Client == nil { + return tl.submitAPIKeys(ctx, input) + } else { + return tl.submitChosenPhoneNumber(ctx, input) + } +} + +type twilioPhoneNumber struct { + SID string + Number string + PrettyNumber string +} + +func (tl *TwilioLogin) submitAPIKeys(ctx context.Context, input map[string]string) (*bridgev2.LoginStep, error) { + tl.AccountSID = input["account_sid"] + tl.AuthToken = input["auth_token"] + twilioClient := twilio.NewRestClientWithParams(twilio.ClientParams{ + Username: tl.AccountSID, + Password: tl.AuthToken, + AccountSid: tl.AccountSID, + }) + // Get the list of phone numbers. This doubles as a way to verify the credentials are valid. + phoneNumbers, err := twilioClient.Api.ListIncomingPhoneNumber(nil) + if err != nil { + return nil, fmt.Errorf("failed to list phone numbers: %w", err) + } + var numbers []twilioPhoneNumber + for _, number := range phoneNumbers { + if number.Status == nil || number.PhoneNumber == nil || *number.Status != "in-use" { + continue + } + numbers = append(numbers, twilioPhoneNumber{ + SID: *number.Sid, + Number: *number.PhoneNumber, + PrettyNumber: *number.FriendlyName, + }) + } + tl.Client = twilioClient + tl.PhoneNumbers = numbers + if len(numbers) == 0 { + return nil, fmt.Errorf("no active phone numbers found") + } else if len(numbers) == 1 { + return tl.finishLogin(ctx, numbers[0]) + } else { + phoneNumberList := make([]string, len(numbers)) + for i, number := range numbers { + phoneNumberList[i] = fmt.Sprintf("* %s", number.Number) + } + return &bridgev2.LoginStep{ + Type: bridgev2.LoginStepTypeUserInput, + StepID: "fi.mau.twilio.choose_number", + Instructions: "Your Twilio account has multiple phone numbers. Please choose one:\n\n" + strings.Join(phoneNumberList, "\n"), + UserInputParams: &bridgev2.LoginUserInputParams{ + Fields: []bridgev2.LoginInputDataField{{ + Type: bridgev2.LoginInputFieldTypePhoneNumber, + ID: "chosen_number", + Name: "Phone number", + }}, + }, + }, nil + } +} + +func (tl *TwilioLogin) submitChosenPhoneNumber(ctx context.Context, input map[string]string) (*bridgev2.LoginStep, error) { + numberIdx := slices.IndexFunc(tl.PhoneNumbers, func(e twilioPhoneNumber) bool { + return e.Number == input["chosen_number"] + }) + if numberIdx == -1 { + // We could also return a new LoginStep here if we wanted to allow the user to retry. + // Errors are always fatal, so returning an error here will cancel the login process. + return nil, fmt.Errorf("invalid phone number") + } + return tl.finishLogin(ctx, tl.PhoneNumbers[numberIdx]) +} + +func (tl *TwilioLogin) finishLogin(ctx context.Context, phoneNumber twilioPhoneNumber) (*bridgev2.LoginStep, error) { + ul, err := tl.User.NewLogin(ctx, &database.UserLogin{ + ID: makeUserLoginID(tl.AccountSID, phoneNumber.SID), + Metadata: database.UserLoginMetadata{ + StandardUserLoginMetadata: database.StandardUserLoginMetadata{ + RemoteName: phoneNumber.PrettyNumber, + }, + Extra: map[string]any{ + "phone": phoneNumber.Number, + "phone_sid": phoneNumber.SID, + "auth_token": tl.AuthToken, + "account_sid": tl.AccountSID, + }, + }, + }, &bridgev2.NewLoginParams{ + LoadUserLogin: func(ctx context.Context, login *bridgev2.UserLogin) error { + login.Client = &TwilioClient{ + UserLogin: login, + Twilio: tl.Client, + RequestValidator: tclient.NewRequestValidator(tl.AuthToken), + } + return nil + }, + }) + if err != nil { + return nil, err + } + tc := ul.Client.(*TwilioClient) + // In addition to creating the UserLogin, we'll also want to set the webhook URL for the phone number. + _, err = tc.Twilio.Api.UpdateIncomingPhoneNumber(phoneNumber.SID, &openapi.UpdateIncomingPhoneNumberParams{ + SmsMethod: ptr.Ptr(http.MethodPost), + SmsUrl: ptr.Ptr(tc.GetWebhookURL()), + }) + if err != nil { + return nil, fmt.Errorf("failed to set webhook URL for phone number: %w", err) + } + // Finally, return the special complete step indicating the login was successful. + // It doesn't have any params other than the UserLogin we just created. + return &bridgev2.LoginStep{ + Type: bridgev2.LoginStepTypeComplete, + StepID: "fi.mau.twilio.complete", + Instructions: "Successfully logged in", + CompleteParams: &bridgev2.LoginCompleteParams{ + UserLoginID: ul.ID, + UserLogin: ul, + }, + }, nil +} + +func (tl *TwilioLogin) Cancel() {}