diff --git a/.dockerignore b/.dockerignore index b7c2055..758394e 100644 --- a/.dockerignore +++ b/.dockerignore @@ -4,5 +4,17 @@ logs start config.yaml registration.yaml -*.db +*.db* +*.sqlite* *.pickle +*.log +.git +.claude +.DS_Store +**/.DS_Store +.env* +*cookies*.json +*storage-state*.json +/reddit +/evidence/ +/runtime/ diff --git a/.gitignore b/.gitignore index fdd3b60..ac66e33 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,8 @@ logs/ !.github/workflows/*.yml !.gitlab-ci.yml .idea +.env* +*cookies*.json +*storage-state*.json +/evidence/ +/runtime/ diff --git a/Dockerfile b/Dockerfile index 3597af3..f10549c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,9 +2,11 @@ FROM golang:1-alpine3.23 AS builder RUN apk add --no-cache git ca-certificates build-base su-exec olm-dev +ARG CI_COMMIT_SHA=unknown +ARG CI_COMMIT_TAG= COPY . /build WORKDIR /build -RUN ./build.sh +RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build CI=true ./build.sh FROM alpine:3.23 @@ -15,6 +17,9 @@ RUN apk add --no-cache ffmpeg su-exec ca-certificates olm bash jq yq curl COPY --from=builder /build/reddit /usr/bin/reddit COPY --from=builder /build/docker-run.sh /docker-run.sh +ARG CI_COMMIT_SHA=unknown +LABEL org.opencontainers.image.source="https://github.com/beeper/reddit" \ + org.opencontainers.image.revision="${CI_COMMIT_SHA}" VOLUME /data - -CMD ["/docker-run.sh"] +WORKDIR /data +ENTRYPOINT ["/docker-run.sh"] diff --git a/README.md b/README.md index 1bf9a72..1cc305d 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,24 @@ require bridge updates. - Text and media send and receive. - Replies, edits, reactions, read receipts, and typing notifications. +## Configuration + +Direct media uses the standard top-level `direct_media` configuration: enable +it with a reachable media server name/delegation and a persistent server signing +key. Keep that name and key stable so existing signed URLs remain usable. The +bridge must retain the originating Reddit login and Reddit must still serve the +file. Downloads use bounded memory and sniff the actual image MIME type; they +are proxied on demand, without a second media cache or an upload to Matrix. +Reddit can transcode WebP to JPEG, so native event metadata may differ from the +bytes served by the proxy. Existing reuploaded messages are unchanged. + +Ordinary Matrix leave events also require `bridge.bridge_matrix_leave: true`; +Beeper membership state requests require `bridge.enable_send_state_requests: true`. +The existing Delete/Ignore action remains available independently of +the Matrix-leave switch. DMs are hidden for the owner, while groups are left. +Group invite/kick capabilities remain rejected in DMs; Reddit enforces group +permissions and invitation limits. Ban/unban and role changes are unsupported. + ## Setup Install and log in to [`bbctl`](https://github.com/beeper/bridge-manager): @@ -21,20 +39,42 @@ Install and log in to [`bbctl`](https://github.com/beeper/bridge-manager): bbctl login ``` -Register the bridge and generate its config: +Build in the source checkout (Go 1.26+ and libolm are required): ```sh -bbctl config --type bridgev2 -o config.yaml sh-reddit -bbctl register -g -o registration.yaml sh-reddit +./build.sh ``` -Run the bridge (requires Go 1.26+ and libolm — `brew install libolm` on -macOS, `apt-get install libolm-dev` on Debian/Ubuntu): +Install libolm with `brew install libolm` on macOS or +`apt-get install libolm-dev` on Debian/Ubuntu. +Generate credentials in a private runtime directory outside the source +checkout. Use a dedicated bridge name for the first registration: ```sh -go run ./cmd/reddit -c config.yaml -r registration.yaml +install -d -m 700 "$HOME/.local/share/beeper-reddit" +cd "$HOME/.local/share/beeper-reddit" +umask 077 +bbctl config --type bridgev2 --param pickle_key=generate -o "$PWD/config.yaml" sh-reddit +bbctl register -g -o "$PWD/registration.yaml" sh-reddit ``` +Enable Beeper's state-request path in `config.yaml` so group-name changes can +reach the connector. Set this field under the `bridge` section: + +```yaml +bridge: + enable_send_state_requests: true +``` + +Then start the bridge: + +```sh +/absolute/path/to/reddit/reddit -c config.yaml -r registration.yaml +``` + +For updates, stop the bridge process and replace its binary while retaining +the same runtime, registration, database and encryption key. + Then open Beeper Desktop, go to Settings -> Bridges -> Self-hosted Bridges, find `sh-reddit`, add an account, and complete the Reddit login flow. diff --git a/docker-run.sh b/docker-run.sh index ecb5798..d83e3cb 100755 --- a/docker-run.sh +++ b/docker-run.sh @@ -1,20 +1,30 @@ #!/bin/sh +set -eu +umask 077 -if [[ -z "$GID" ]]; then - GID="$UID" -fi +bridge_uid="${UID:-1337}" +bridge_gid="${GID:-$bridge_uid}" +case "$bridge_uid:$bridge_gid" in + *[!0-9:]*|:*|*:) echo "UID and GID must be numeric" >&2; exit 1 ;; +esac + +mkdir -p /data +chmod 700 /data +cd /data -# Define functions. -function fixperms { - chown -R $UID:$GID /data +# Generate and upgrade files as the same numeric user that runs the bridge. +# The previous launcher wrote the initial 0600 config as root, preventing the +# host user from editing it even when UID/GID were explicitly supplied. +if [ "$(id -u)" = 0 ] && [ "$bridge_uid" != 0 ]; then + chown -R "$bridge_uid:$bridge_gid" /data + exec su-exec "$bridge_uid:$bridge_gid" "$0" "$@" +fi - # /opt/reddit is read-only, so disable file logging if it's pointing there. - if [[ "$(yq e '.logging.writers[1].filename' /data/config.yaml)" == "./logs/reddit.log" ]]; then - yq -I4 e -i 'del(.logging.writers[1])' /data/config.yaml - fi -} +if [ "$#" -gt 0 ]; then + exec /usr/bin/reddit "$@" +fi -if [[ ! -f /data/config.yaml ]]; then +if [ ! -f /data/config.yaml ]; then /usr/bin/reddit -c /data/config.yaml -e echo "Didn't find a config file." echo "Copied default config file to /data/config.yaml" @@ -23,7 +33,7 @@ if [[ ! -f /data/config.yaml ]]; then exit fi -if [[ ! -f /data/registration.yaml ]]; then +if [ ! -f /data/registration.yaml ]; then /usr/bin/reddit -g -c /data/config.yaml -r /data/registration.yaml || exit $? echo "Didn't find a registration file." echo "Generated one for you." @@ -31,6 +41,5 @@ if [[ ! -f /data/registration.yaml ]]; then exit fi -cd /data -fixperms -exec su-exec $UID:$GID /usr/bin/reddit +chmod 600 /data/config.yaml /data/registration.yaml +exec /usr/bin/reddit -c /data/config.yaml -r /data/registration.yaml diff --git a/go.mod b/go.mod index 20181c8..4014c40 100644 --- a/go.mod +++ b/go.mod @@ -5,44 +5,37 @@ go 1.26 tool go.mau.fi/util/cmd/maubuild require ( - github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc - github.com/chromedp/chromedp v0.15.1 github.com/google/uuid v1.6.0 github.com/rs/zerolog v1.35.1 - go.mau.fi/util v0.9.9 + go.mau.fi/util v0.10.0 gopkg.in/yaml.v3 v3.0.1 - maunium.net/go/mautrix v0.28.0 + maunium.net/go/mautrix v0.30.0 ) require ( filippo.io/edwards25519 v1.2.0 // indirect - github.com/chromedp/sysutil v1.1.0 // indirect - github.com/coder/websocket v1.8.14 // indirect + github.com/coder/websocket v1.8.15 // indirect github.com/coreos/go-systemd/v22 v22.7.0 // indirect - github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 // indirect - github.com/gobwas/httphead v0.1.0 // indirect - github.com/gobwas/pool v0.2.1 // indirect - github.com/gobwas/ws v1.4.0 // indirect github.com/lib/pq v1.12.3 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-sqlite3 v1.14.44 // indirect - github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 // indirect + github.com/mattn/go-sqlite3 v1.14.49 // indirect + github.com/petermattis/goid v0.0.0-20260816044145-ed329add6b1b // indirect github.com/rs/xid v1.6.0 // indirect github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect github.com/tidwall/gjson v1.19.0 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/sjson v1.2.5 // indirect - github.com/yuin/goldmark v1.8.2 // indirect + github.com/yuin/goldmark v1.8.5 // indirect go.mau.fi/zeroconfig v0.2.0 // indirect - golang.org/x/crypto v0.51.0 // indirect - golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a // indirect - golang.org/x/mod v0.36.0 // indirect - golang.org/x/net v0.54.0 // indirect - golang.org/x/sync v0.20.0 // indirect - golang.org/x/sys v0.44.0 // indirect - golang.org/x/text v0.37.0 // indirect + golang.org/x/crypto v0.55.0 // indirect + golang.org/x/exp v0.0.0-20260813180055-c1d0aacb2297 // indirect + golang.org/x/mod v0.40.0 // indirect + golang.org/x/net v0.58.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.41.0 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect maunium.net/go/mauflag v1.0.0 // indirect ) diff --git a/go.sum b/go.sum index 258c098..4bcbe63 100644 --- a/go.sum +++ b/go.sum @@ -2,42 +2,24 @@ filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= 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/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc h1:wkN/LMi5vc60pBRWx6qpbk/aEvq3/ZVNpnMvsw8PVVU= -github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc/go.mod h1:cbyjALe67vDvlvdiG9369P8w5U2w6IshwtyD2f2Tvag= -github.com/chromedp/chromedp v0.15.1 h1:EJWiPm7BNqDqjYy6U0lTSL5wNH+iNt9GjC3a4gfjNyQ= -github.com/chromedp/chromedp v0.15.1/go.mod h1:CdTHtUqD/dqaFw/cvFWtTydoEQS44wLBuwbMR9EkOY4= -github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM= -github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8= -github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= -github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= +github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= +github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/coreos/go-systemd/v22 v22.7.0 h1:LAEzFkke61DFROc7zNLX/WA2i5J8gYqe0rSj9KI28KA= github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w= 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/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 h1:vymEbVwYFP/L05h5TKQxvkXoKxNvTpjxYKdF1Nlwuao= -github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg= -github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= -github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= -github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= -github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= -github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs= -github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo= -github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs= github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ= github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8= -github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= -github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw= -github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0= -github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 h1:WDsQxOJDy0N1VRAjXLpi8sCEZRSGarLWQevDxpTBRrM= -github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= +github.com/mattn/go-sqlite3 v1.14.49 h1:B8jBHC3xhxZgxztrgruTuLucebnULQnx4W7cF7SAE9w= +github.com/mattn/go-sqlite3 v1.14.49/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w= +github.com/petermattis/goid v0.0.0-20260816044145-ed329add6b1b h1:sS7HLzwS+dO+gxATgQfeZDEdUZe2pKAB3nGoUwP5zU0= +github.com/petermattis/goid v0.0.0-20260816044145-ed329add6b1b/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= 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.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= @@ -58,27 +40,27 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/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/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE= -github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= -go.mau.fi/util v0.9.9 h1:ujDeXCo07HBor5oQLyO1tHklupmqVmPgasc53d7q/NE= -go.mau.fi/util v0.9.9/go.mod h1:pqt4Vcrt+5gcH/CgrHZg11qSx+b34o6mknGzOEA6waY= +github.com/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA= +github.com/yuin/goldmark v1.8.5/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +go.mau.fi/util v0.10.0 h1:vH9IXZmfBKa96p47HxrVqEPkrj02zDJg3o4EF172+Lk= +go.mau.fi/util v0.10.0/go.mod h1:uZwpm9sK4wO2Qqy+t6QoVq29szMsRxWXp9/BkQLG4xk= go.mau.fi/zeroconfig v0.2.0 h1:e/OGEERqVRRKlgaro7E6bh8xXiKFSXB3eNNIud7FUjU= go.mau.fi/zeroconfig v0.2.0/go.mod h1:J0Vn0prHNOm493oZoQ84kq83ZaNCYZnq+noI1b1eN8w= -golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= -golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= -golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a h1:+3jdDGGB8NGb1Zktc737jlt3/A5f6UlwSzmvqUuufxw= -golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw= -golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= -golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= -golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= -golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= +golang.org/x/exp v0.0.0-20260813180055-c1d0aacb2297 h1:YXnL44eJ77R+ji4/ooy8UsXIhz+lbi2Qgdlc8iRN0gY= +golang.org/x/exp v0.0.0-20260813180055-c1d0aacb2297/go.mod h1:Mkmymgv+uMpSQ/XxJ/7GpdrdYoqm3u72jEbpCLiJmNk= +golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs= +golang.org/x/mod v0.40.0/go.mod h1:0/weTWkPWGBikyTWAX3dkjVztMmBA5hM0DH6BElSupE= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= -golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= @@ -87,5 +69,5 @@ 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.28.0 h1:vBakLzf8MAdfED3NzAKiMeKQbc3AQ4EAS03NC+TVMXQ= -maunium.net/go/mautrix v0.28.0/go.mod h1:/a9A7LGaqb9B3nho4tLd28n0EPcCdwpm2dxkxkLLgh0= +maunium.net/go/mautrix v0.30.0 h1:bad+q7w5tLqiHpr+oUxVI+8m8ePbV3AvoFKg2jQzPyo= +maunium.net/go/mautrix v0.30.0/go.mod h1:bb0gjxbTFOqTaAYKGw5E7j9XROUR2Sl1Etm3IbmYXbo= diff --git a/pkg/connector/backfill.go b/pkg/connector/backfill.go new file mode 100644 index 0000000..70d2039 --- /dev/null +++ b/pkg/connector/backfill.go @@ -0,0 +1,177 @@ +package connector + +import ( + "context" + "errors" + "fmt" + "sort" + "time" + + "github.com/rs/zerolog" + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/database" + "maunium.net/go/mautrix/bridgev2/networkid" + "maunium.net/go/mautrix/event" +) + +var _ bridgev2.BackfillingNetworkAPI = (*RedditClient)(nil) +var _ bridgev2.BackfillingNetworkAPIWithLimits = (*RedditClient)(nil) + +func (r *RedditClient) GetBackfillMaxBatchCount(ctx context.Context, portal *bridgev2.Portal, task *database.BackfillTask) int { + if portal.RoomType == database.RoomTypeDM { + return r.main.Bridge.Config.Backfill.Queue.GetOverride("dm") + } + return r.main.Bridge.Config.Backfill.Queue.GetOverride("group_dm") +} + +// FetchMessages uses Reddit's provider cursor, not timestamps or the number of +// returned events. Short and state-only pages are not terminal: the observed +// provider terminal response has no end cursor. +func (r *RedditClient) FetchMessages(ctx context.Context, params bridgev2.FetchMessagesParams) (*bridgev2.FetchMessagesResponse, error) { + if params.Portal == nil { + return nil, errors.New("history requires a portal") + } + if params.Portal.Receiver != "" && params.Portal.Receiver != r.userLogin.ID { + return nil, errors.New("history portal belongs to another login") + } + count := params.Count + if count <= 0 { + count = 100 + } + count = min(count, 100) + cursor := string(params.Cursor) + if params.Forward { + cursor = "" + } + seen := make(map[string]struct{}) + result := &bridgev2.FetchMessagesResponse{Forward: params.Forward, AggressiveDeduplication: true} + anchor := params.AnchorMessage + // The framework supplies the root as the initial thread anchor, but the + // native child stream contains only replies, never the root itself. + if params.ThreadRoot != "" && anchor != nil && anchor.ID == params.ThreadRoot { + anchor = nil + } + anchorFound := false + // A new backward scan starts at the provider's latest event, while the SDK + // asks for events older than its oldest mapping. Walk to that exact identity + // before converting anything, including relations on the newer side. Returning + // a page containing only already-imported messages makes the SDK reserve the + // task for an hour instead of continuing to older history. + seekBackwardAnchor := !params.Forward && cursor == "" && anchor != nil + for { + var page *mautrix.RespMessages + var err error + if params.ThreadRoot != "" { + page, err = r.remote().ThreadMessages(ctx, portalIDToRoomID(params.Portal.ID), messageIDToEventID(params.ThreadRoot), cursor, count) + } else { + page, err = r.remote().Messages(ctx, portalIDToRoomID(params.Portal.ID), cursor, "", count) + } + if err != nil { + return nil, fmt.Errorf("fetch Reddit history: %w", err) + } + if cursor != "" && page.Start != "" && page.Start != cursor { + return nil, errors.New("reddit history response does not match the requested cursor") + } + if page.End != "" { + if page.End == cursor { + return nil, errors.New("reddit history cursor did not advance") + } + if _, exists := seen[page.End]; exists { + return nil, errors.New("reddit history cursor cycle") + } + seen[page.End] = struct{}{} + } + for _, evt := range page.Chunk { + if evt == nil { + return nil, errors.New("reddit history contains a null event") + } + if evt.RoomID != "" && evt.RoomID != portalIDToRoomID(params.Portal.ID) { + return nil, errors.New("reddit history event belongs to another room") + } + if anchor != nil && makeMessageID(evt.ID) == anchor.ID { + anchorFound = true + if params.Forward { + break + } + continue + } + if evt.StateKey != nil { + continue + } + if seekBackwardAnchor && !anchorFound { + continue + } + // FetchMessages returns messages and their current reactions. Live + // removals use remote events; history must not send Matrix events or + // mutate bridgev2's mappings as a side effect of fetching a page. + if evt.Type != event.EventMessage { + continue + } + if evt.ID == "" || evt.Sender == "" { + return nil, errors.New("reddit history event has no stable identity") + } + if err = evt.Content.ParseRaw(event.EventMessage); err != nil && !errors.Is(err, event.ErrContentAlreadyParsed) { + result.Messages = append(result.Messages, r.historyPlaceholder(ctx, evt, err)) + continue + } + if evt.Content.AsMessage().RelatesTo.GetReplaceID() != "" { + continue + } + if params.ThreadRoot != "" && evt.Unsigned.RedactedBecause == nil && makeMessageID(evt.Content.AsMessage().RelatesTo.GetThreadParent()) != params.ThreadRoot { + return nil, errors.New("reddit thread history contains a reply to another root") + } + var intent bridgev2.MatrixAPI + if evt.Unsigned.RedactedBecause == nil && evt.Content.AsMessage().MsgType == event.MsgImage { + intent, _ = params.Portal.GetIntentFor(ctx, r.makeSender(evt.Sender), r.userLogin, bridgev2.RemoteEventMessage) + } + converted, err := r.convertMessage(ctx, params.Portal, intent, evt) + if isUnbridgeable(err) { + result.Messages = append(result.Messages, r.historyPlaceholder(ctx, evt, err)) + continue + } else if err != nil { + return nil, err + } + var reactions []*bridgev2.BackfillReaction + if evt.Unsigned.RedactedBecause == nil { + reactions, err = r.backfillReactions(ctx, params.Portal.PortalKey, makeMessageID(evt.ID)) + if isUnbridgeable(err) { + zerolog.Ctx(ctx).Warn().Err(err).Stringer("event_id", evt.ID).Msg("Skipping unbridgeable Reddit history reactions") + } else if err != nil { + return nil, err + } + } + result.Messages = append(result.Messages, &bridgev2.BackfillMessage{ConvertedMessage: converted, ID: makeMessageID(evt.ID), Sender: r.makeSender(evt.Sender), Timestamp: time.UnixMilli(evt.Timestamp), Reactions: reactions}) + } + result.Cursor = networkid.PaginationCursor(page.End) + result.HasMore = page.End != "" + if (!params.Forward && !seekBackwardAnchor) || anchor == nil || anchorFound || page.End == "" { + break + } + cursor = page.End + } + if (params.Forward || seekBackwardAnchor) && anchor != nil && !anchorFound { + return nil, errors.New("reddit history ended before the known message; cannot locate the history anchor") + } + sort.SliceStable(result.Messages, func(i, j int) bool { + left, right := result.Messages[i], result.Messages[j] + if left.Timestamp.Equal(right.Timestamp) { + return left.ID < right.ID + } + return left.Timestamp.Before(right.Timestamp) + }) + return result, nil +} + +// Map the placeholder to the native ID so replies and reactions to it resolve. +func (r *RedditClient) historyPlaceholder(ctx context.Context, evt *event.Event, err error) *bridgev2.BackfillMessage { + zerolog.Ctx(ctx).Warn().Err(err).Stringer("event_id", evt.ID).Msg("Failed to convert Reddit history message, using placeholder") + return &bridgev2.BackfillMessage{ + ConvertedMessage: &bridgev2.ConvertedMessage{Parts: []*bridgev2.ConvertedMessagePart{{ + Type: event.EventMessage, + Content: &event.MessageEventContent{MsgType: event.MsgNotice, Body: "An error occurred while processing an incoming message", Mentions: &event.Mentions{}}, + Extra: map[string]any{"fi.mau.bridge.internal_error": err.Error()}, + }}}, + ID: makeMessageID(evt.ID), Sender: r.makeSender(evt.Sender), Timestamp: time.UnixMilli(evt.Timestamp), + } +} diff --git a/pkg/connector/backfill_test.go b/pkg/connector/backfill_test.go new file mode 100644 index 0000000..4a996fb --- /dev/null +++ b/pkg/connector/backfill_test.go @@ -0,0 +1,201 @@ +package connector + +import ( + "context" + "fmt" + "io" + "net/http" + "strings" + "testing" + + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/database" + "maunium.net/go/mautrix/bridgev2/networkid" +) + +// Message-history fixtures have no native reactions unless a test supplies +// a dedicated relations endpoint. +func historyTestClient(t *testing.T, handler http.HandlerFunc) *RedditClient { + return testClient(t, func(w http.ResponseWriter, req *http.Request) { + if strings.Contains(req.URL.Path, "/relations/") { + _, _ = io.WriteString(w, `{"chunk":[]}`) + return + } + handler(w, req) + }) +} + +func historyPortal() *bridgev2.Portal { + return &bridgev2.Portal{Portal: &database.Portal{PortalKey: networkid.PortalKey{ID: "!room:reddit.com", Receiver: "t2_a"}}} +} + +func TestHistoryKeepsShortAndStateOnlyPagesUntilProviderTerminal(t *testing.T) { + pages := map[string]string{ + "": `{"start":"latest","end":"p1","chunk":[{"type":"m.room.message","event_id":"$new","sender":"@t2_b:reddit.com","origin_server_ts":3000,"content":{"msgtype":"m.text","body":"new"}}]}`, + "p1": `{"start":"p1","end":"p2","chunk":[{"type":"m.room.member","state_key":"@t2_b:reddit.com","content":{"membership":"join"}}]}`, + "p2": `{"start":"p2","end":"p3","chunk":[{"type":"m.room.message","event_id":"$older","sender":"@t2_b:reddit.com","origin_server_ts":2000,"content":{"msgtype":"m.text","body":"older"}},{"type":"m.room.message","event_id":"$oldest","sender":"@t2_b:reddit.com","origin_server_ts":1000,"content":{"msgtype":"m.text","body":"oldest"}}]}`, + "p3": `{"start":"p3","chunk":[]}`, + } + calls := 0 + r := historyTestClient(t, func(w http.ResponseWriter, req *http.Request) { + calls++ + if req.URL.Query().Get("dir") != "b" || req.URL.Query().Get("limit") != "2" { + t.Error("wrong pagination request") + } + page, ok := pages[req.URL.Query().Get("from")] + if !ok { + t.Error("unexpected continuation") + w.WriteHeader(400) + return + } + _, _ = io.WriteString(w, page) + }) + cursor := networkid.PaginationCursor("") + counts := []int{1, 0, 2, 0} + for i := range 4 { + resp, err := r.FetchMessages(context.Background(), bridgev2.FetchMessagesParams{Portal: historyPortal(), Cursor: cursor, Count: 2}) + if err != nil { + t.Fatal(err) + } + if len(resp.Messages) != counts[i] || resp.HasMore != (i < 3) { + t.Fatalf("wrong page %d: %+v", i, resp) + } + if i == 2 && (resp.Messages[0].ID != "$oldest" || resp.Messages[1].ID != "$older") { + t.Fatal("history is not chronological") + } + cursor = resp.Cursor + } + if calls != 4 { + t.Fatalf("expected four provider pages, got %d", calls) + } +} + +func TestForwardHistoryFindsAnchorAcrossPages(t *testing.T) { + calls := 0 + r := historyTestClient(t, func(w http.ResponseWriter, req *http.Request) { + calls++ + if calls == 1 { + _, _ = io.WriteString(w, `{"start":"latest","end":"p1","chunk":[{"type":"m.room.message","event_id":"$new","sender":"@t2_b:reddit.com","origin_server_ts":3000,"content":{"msgtype":"m.text","body":"new"}}]}`) + } else { + _, _ = io.WriteString(w, `{"start":"p1","end":"p2","chunk":[{"type":"m.room.message","event_id":"$middle","sender":"@t2_b:reddit.com","origin_server_ts":2000,"content":{"msgtype":"m.text","body":"middle"}},{"type":"m.room.message","event_id":"$anchor","sender":"@t2_b:reddit.com","origin_server_ts":1000,"content":{"msgtype":"m.text","body":"old"}}]}`) + } + }) + resp, err := r.FetchMessages(context.Background(), bridgev2.FetchMessagesParams{Portal: historyPortal(), Forward: true, Count: 2, AnchorMessage: &database.Message{ID: "$anchor"}}) + if err != nil { + t.Fatal(err) + } + if calls != 2 || len(resp.Messages) != 2 || resp.Messages[0].ID != "$middle" || resp.Messages[1].ID != "$new" { + t.Fatalf("incomplete catchup: %+v", resp) + } +} + +func TestHistoryRejectsStuckCursorAndWrongRoom(t *testing.T) { + for i, body := range []string{ + `{"start":"p1","end":"p1","chunk":[]}`, + `{"start":"wrong","end":"p2","chunk":[]}`, + `{"start":"p1","chunk":[{"room_id":"!other:reddit.com","event_id":"$x","type":"m.room.message"}]}`, + `{"start":"p1","chunk":[null]}`, + } { + t.Run(fmt.Sprint(i), func(t *testing.T) { + r := historyTestClient(t, func(w http.ResponseWriter, req *http.Request) { _, _ = io.WriteString(w, body) }) + if _, err := r.FetchMessages(context.Background(), bridgev2.FetchMessagesParams{Portal: historyPortal(), Cursor: "p1"}); err == nil { + t.Fatal("invalid history page was accepted") + } + }) + } +} + +func TestBackwardHistorySeeksOldestMappingBeforeReturningPage(t *testing.T) { + calls := 0 + r := historyTestClient(t, func(w http.ResponseWriter, req *http.Request) { + calls++ + switch req.URL.Query().Get("from") { + case "": + _, _ = io.WriteString(w, `{"end":"p1","chunk":[{"type":"m.reaction","event_id":"$new-reaction"},{"type":"m.room.message","event_id":"$new","sender":"@t2_b:reddit.com","origin_server_ts":3000,"content":{"msgtype":"m.text","body":"new"}}]}`) + case "p1": + _, _ = io.WriteString(w, `{"start":"p1","end":"p2","chunk":[{"type":"m.room.message","event_id":"$anchor","sender":"@t2_b:reddit.com","origin_server_ts":2000,"content":{"msgtype":"m.text","body":"known"}},{"type":"m.room.message","event_id":"$old","sender":"@t2_b:reddit.com","origin_server_ts":1000,"content":{"msgtype":"m.text","body":"old"}}]}`) + default: + t.Fatal("unexpected continuation") + } + }) + resp, err := r.FetchMessages(context.Background(), bridgev2.FetchMessagesParams{Portal: historyPortal(), Count: 2, AnchorMessage: &database.Message{ID: "$anchor"}}) + if err != nil { + t.Fatal(err) + } + if calls != 2 || len(resp.Messages) != 1 || resp.Messages[0].ID != "$old" || resp.Cursor != "p2" || !resp.HasMore { + t.Fatalf("wrong backward page: %+v", resp) + } +} + +func TestHistoryDoesNotSilentlyPassMissingAnchor(t *testing.T) { + for _, forward := range []bool{false, true} { + t.Run(fmt.Sprint(forward), func(t *testing.T) { + r := historyTestClient(t, func(w http.ResponseWriter, req *http.Request) { _, _ = io.WriteString(w, `{"chunk":[]}`) }) + _, err := r.FetchMessages(context.Background(), bridgev2.FetchMessagesParams{Portal: historyPortal(), Forward: forward, AnchorMessage: &database.Message{ID: "$absent"}}) + if err == nil { + t.Fatal("missing anchor silently treated as complete history") + } + }) + } +} + +func TestThreadHistoryExcludesRootAndPreservesRelations(t *testing.T) { + r := historyTestClient(t, func(w http.ResponseWriter, req *http.Request) { + if req.URL.Query().Get("event_id") == "$root" { + _, _ = io.WriteString(w, `{"events":[{"event_id":"$root","seq_id":"24"}]}`) + } else { + _, _ = io.WriteString(w, `{"events":[{"type":"m.room.message","event_id":"$reply","seq_id":"24.0","sender":"@t2_b:reddit.com","origin_server_ts":3000,"content":{"msgtype":"m.text","body":"reply","m.relates_to":{"rel_type":"m.thread","event_id":"$root","is_falling_back":true,"m.in_reply_to":{"event_id":"$root"}}}}]}`) + } + }) + resp, err := r.FetchMessages(context.Background(), bridgev2.FetchMessagesParams{ + Portal: historyPortal(), Forward: true, ThreadRoot: "$root", + AnchorMessage: &database.Message{ID: "$root"}, Count: 10, + }) + if err != nil { + t.Fatal(err) + } + if len(resp.Messages) != 1 || resp.Messages[0].ID != "$reply" || resp.Messages[0].ThreadRoot == nil || *resp.Messages[0].ThreadRoot != "$root" || resp.HasMore { + t.Fatalf("thread history lost its root relation or terminal boundary: %+v", resp) + } +} + +func TestThreadHistoryCatchesUpToExactReplyAnchor(t *testing.T) { + pages := 0 + r := historyTestClient(t, func(w http.ResponseWriter, req *http.Request) { + if req.URL.Query().Has("event_id") { + _, _ = io.WriteString(w, `{"events":[{"event_id":"$root","seq_id":"24"}]}`) + return + } + pages++ + if !req.URL.Query().Has("seq") { + _, _ = io.WriteString(w, `{"events":[{"type":"m.room.message","event_id":"$reply","seq_id":"24.1","sender":"@t2_b:reddit.com","origin_server_ts":3000,"content":{"msgtype":"m.text","body":"reply","m.relates_to":{"rel_type":"m.thread","event_id":"$root"}}}]}`) + } else { + _, _ = io.WriteString(w, `{"events":[{"event_id":"$anchor","seq_id":"24.0"}]}`) + } + }) + resp, err := r.FetchMessages(context.Background(), bridgev2.FetchMessagesParams{ + Portal: historyPortal(), Forward: true, ThreadRoot: "$root", + AnchorMessage: &database.Message{ID: "$anchor"}, Count: 1, + }) + if err != nil || pages != 2 || len(resp.Messages) != 1 || resp.Messages[0].ID != "$reply" { + t.Fatalf("thread catchup failed: %+v, %v (%d pages)", resp, err, pages) + } +} + +func TestThreadHistoryRejectsWrongRelationAndReceiver(t *testing.T) { + r := historyTestClient(t, func(w http.ResponseWriter, req *http.Request) { + if req.URL.Query().Has("event_id") { + _, _ = io.WriteString(w, `{"events":[{"event_id":"$root","seq_id":"24"}]}`) + } else { + _, _ = io.WriteString(w, `{"events":[{"type":"m.room.message","event_id":"$reply","seq_id":"24.0","sender":"@t2_b:reddit.com","content":{"msgtype":"m.text","body":"reply","m.relates_to":{"rel_type":"m.thread","event_id":"$other"}}}]}`) + } + }) + params := bridgev2.FetchMessagesParams{Portal: historyPortal(), ThreadRoot: "$root"} + if _, err := r.FetchMessages(context.Background(), params); err == nil { + t.Fatal("accepted a reply to another thread") + } + params.Portal.Receiver = "t2_other" + if _, err := r.FetchMessages(context.Background(), params); err == nil { + t.Fatal("accepted another login's history") + } +} diff --git a/pkg/connector/capabilities.go b/pkg/connector/capabilities.go index 757308a..43316e5 100644 --- a/pkg/connector/capabilities.go +++ b/pkg/connector/capabilities.go @@ -7,12 +7,14 @@ import ( "go.mau.fi/util/jsontime" "go.mau.fi/util/ptr" "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/database" "maunium.net/go/mautrix/event" ) func (rc *RedditConnector) GetCapabilities() *bridgev2.NetworkGeneralCapabilities { return &bridgev2.NetworkGeneralCapabilities{ Provisioning: bridgev2.ProvisioningCapabilities{ + ImagePackImport: true, ResolveIdentifier: bridgev2.ResolveIdentifierCapabilities{ CreateDM: true, Search: true, @@ -33,20 +35,12 @@ const ( MaxFileSize = 20 * 1024 * 1024 ) -var formattingCaps = event.FormattingFeatureMap{ - event.FmtBold: event.CapLevelFullySupported, - event.FmtItalic: event.CapLevelFullySupported, - event.FmtStrikethrough: event.CapLevelFullySupported, - event.FmtInlineCode: event.CapLevelFullySupported, - event.FmtCodeBlock: event.CapLevelFullySupported, - event.FmtBlockquote: event.CapLevelFullySupported, - event.FmtInlineLink: event.CapLevelFullySupported, - event.FmtUserLink: event.CapLevelFullySupported, - event.FmtUnorderedList: event.CapLevelFullySupported, - event.FmtOrderedList: event.CapLevelFullySupported, -} - var fileCaps = event.FileFeatureMap{ + event.CapMsgGIF: { + MimeTypes: map[string]event.CapabilitySupportLevel{"image/gif": event.CapLevelFullySupported}, + Caption: event.CapLevelRejected, + MaxSize: MaxFileSize, + }, event.MsgImage: { MimeTypes: map[string]event.CapabilitySupportLevel{ "image/gif": event.CapLevelFullySupported, @@ -54,26 +48,20 @@ var fileCaps = event.FileFeatureMap{ "image/png": event.CapLevelFullySupported, "image/webp": event.CapLevelFullySupported, }, - Caption: event.CapLevelFullySupported, - MaxCaptionLength: MaxTextLength, - MaxSize: MaxFileSize, + // Reddit keeps the body as image alt text, but does not show captions. + // Its own composer sends accompanying text as a separate message. + Caption: event.CapLevelRejected, + MaxSize: MaxFileSize, }, event.MsgVideo: { MimeTypes: map[string]event.CapabilitySupportLevel{ - "video/mp4": event.CapLevelFullySupported, - "video/quicktime": event.CapLevelFullySupported, + "*/*": event.CapLevelRejected, }, - Caption: event.CapLevelFullySupported, - MaxCaptionLength: MaxTextLength, - MaxSize: MaxFileSize, }, event.MsgFile: { MimeTypes: map[string]event.CapabilitySupportLevel{ - "*/*": event.CapLevelFullySupported, + "*/*": event.CapLevelRejected, }, - Caption: event.CapLevelFullySupported, - MaxCaptionLength: MaxTextLength, - MaxSize: MaxFileSize, }, } @@ -82,18 +70,24 @@ var stateCaps = event.StateFeatureMap{ } func (*RedditClient) GetCapabilities(ctx context.Context, portal *bridgev2.Portal) *event.RoomFeatures { - return &event.RoomFeatures{ - ID: "com.beeper.reddit.capabilities.v1", - Formatting: formattingCaps, - File: fileCaps, - MaxTextLength: MaxTextLength, - LocationMessage: event.CapLevelDropped, - Reply: event.CapLevelFullySupported, - Edit: event.CapLevelFullySupported, - EditMaxAge: ptr.Ptr(jsontime.S(60 * time.Minute)), - Delete: event.CapLevelFullySupported, - DeleteMaxAge: ptr.Ptr(jsontime.S(60 * time.Minute)), - Reaction: event.CapLevelFullySupported, + caps := (&event.RoomFeatures{ + ID: "com.beeper.reddit.capabilities.v10", + File: fileCaps, + MaxTextLength: MaxTextLength, + LocationMessage: event.CapLevelDropped, + Reply: event.CapLevelFullySupported, + Thread: event.CapLevelPartialSupport, + Edit: event.CapLevelRejected, + Delete: event.CapLevelFullySupported, + DeleteChat: true, + DeleteMaxAge: ptr.Ptr(jsontime.S(60 * time.Minute)), + Reaction: event.CapLevelFullySupported, + CustomEmojiReactions: true, + MessageRequest: &event.MessageRequestFeatures{ + AcceptWithButton: event.CapLevelFullySupported, + // The SDK accepts first, then sends the user's message. + AcceptWithMessage: event.CapLevelPartialSupport, + }, ReadReceipts: true, TypingNotifications: true, State: stateCaps, @@ -101,5 +95,12 @@ func (*RedditClient) GetCapabilities(ctx context.Context, portal *bridgev2.Porta event.MemberActionInvite: event.CapLevelFullySupported, event.MemberActionKick: event.CapLevelFullySupported, }, + }).Clone() + if portal != nil && portal.RoomType == database.RoomTypeDM { + caps.ID += ".dm" + caps.State[event.StateRoomName.Type].Level = event.CapLevelRejected + caps.MemberActions[event.MemberActionInvite] = event.CapLevelRejected + caps.MemberActions[event.MemberActionKick] = event.CapLevelRejected } + return caps } diff --git a/pkg/connector/chatinfo.go b/pkg/connector/chatinfo.go index a3aaa67..a351a79 100644 --- a/pkg/connector/chatinfo.go +++ b/pkg/connector/chatinfo.go @@ -2,28 +2,26 @@ package connector import ( "context" + "errors" "strings" "go.mau.fi/util/ptr" "maunium.net/go/mautrix" "maunium.net/go/mautrix/bridgev2" - "maunium.net/go/mautrix/bridgev2/database" "maunium.net/go/mautrix/bridgev2/networkid" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" ) -// GetChatInfo fetches room metadata from the Reddit Matrix homeserver. It is -// called by bridgev2 whenever a portal needs (re)synced state — typically -// after a ChatResync event. +// GetChatInfo uses saved native state, or fetches it when provisioning gets a +// new room before the polling loop does. This read never advances the cursor. func (r *RedditClient) GetChatInfo(ctx context.Context, portal *bridgev2.Portal) (*bridgev2.ChatInfo, error) { - roomID := portalIDToRoomID(portal.ID) - preset := "" - if meta, ok := portal.Metadata.(*PortalMetadata); ok && meta != nil { - preset = meta.Preset + state, key, err := r.resolveRoomState(ctx, portalIDToRoomID(portal.ID), &mautrix.SyncJoinedRoom{}, &roomSnapshots{}) + if err != nil { + return nil, err + } + if key != portal.PortalKey { + return nil, errors.New("chat ownership does not match Reddit room state") } - isDM := preset == redditDMPreset || portal.Receiver != "" - return r.fetchChatInfo(ctx, roomID, isDM, mautrix.SyncJoinedRoom{}) + return r.chatInfoFromState(state), nil } // GetUserInfo fetches profile info for a ghost (a Reddit user puppeted into @@ -47,97 +45,14 @@ func (r *RedditClient) GetUserInfo(ctx context.Context, ghost *bridgev2.Ghost) ( mxc := resp.AvatarURL.CUString() info.Avatar = &bridgev2.Avatar{ ID: networkid.AvatarID(mxc), - MXC: mxc, - } - } - return info, nil -} - -func (r *RedditClient) fetchChatInfo(ctx context.Context, roomID id.RoomID, isDM bool, sync mautrix.SyncJoinedRoom) (*bridgev2.ChatInfo, error) { - info := &bridgev2.ChatInfo{CanBackfill: true} - if isDM { - info.Type = ptr.Ptr(database.RoomTypeDM) - } else { - info.Type = ptr.Ptr(database.RoomTypeDefault) - } - - if name := r.readRoomName(ctx, roomID, sync); name != "" { - info.Name = ptr.Ptr(name) - } - if avatar := r.readRoomAvatar(ctx, roomID, sync); avatar != nil { - info.Avatar = avatar - } - - members, err := r.readRoomMembers(ctx, roomID, sync) - if err == nil { - info.Members = members - } - return info, nil -} - -func (r *RedditClient) readRoomName(ctx context.Context, roomID id.RoomID, sync mautrix.SyncJoinedRoom) string { - if name := findStateString(sync.State.Events, event.StateRoomName, "name"); name != "" { - return name - } - var content event.RoomNameEventContent - err := r.matrix().StateEvent(ctx, roomID, event.StateRoomName, "", &content) - if err == nil { - return content.Name - } - return "" -} - -func (r *RedditClient) readRoomAvatar(ctx context.Context, roomID id.RoomID, sync mautrix.SyncJoinedRoom) *bridgev2.Avatar { - url := findStateString(sync.State.Events, event.StateRoomAvatar, "url") - if url == "" { - var content event.RoomAvatarEventContent - if err := r.matrix().StateEvent(ctx, roomID, event.StateRoomAvatar, "", &content); err == nil { - url = string(content.URL) + Get: func(ctx context.Context) ([]byte, error) { return r.downloadRedditMedia(ctx, mxc, nil) }, } - } - if url == "" { - return nil - } - return &bridgev2.Avatar{ - ID: networkid.AvatarID(url), - MXC: id.ContentURIString(url), - } -} - -func (r *RedditClient) readRoomMembers(ctx context.Context, roomID id.RoomID, sync mautrix.SyncJoinedRoom) (*bridgev2.ChatMemberList, error) { - resp, err := r.matrix().JoinedMembers(ctx, roomID) - if err != nil { - return nil, err - } - memberMap := bridgev2.ChatMemberMap{} - for mxid := range resp.Joined { - uid := makeUserID(mxid) - memberMap.Set(bridgev2.ChatMember{ - EventSender: bridgev2.EventSender{ - IsFromMe: networkid.UserLoginID(uid) == r.userLogin.ID, - Sender: uid, - SenderLogin: networkid.UserLoginID(uid), - }, - Membership: event.MembershipJoin, - }) - } - return &bridgev2.ChatMemberList{ - IsFull: true, - TotalMemberCount: len(memberMap), - MemberMap: memberMap, - }, nil -} - -func findStateString(state []*event.Event, eventType event.Type, field string) string { - for _, e := range state { - if e == nil || e.Type != eventType { - continue - } - if v, ok := e.Content.Raw[field].(string); ok { - return v + if r.main.directMedia { + // Retain the normal download fallback if the identity cannot fit. + info.Avatar.MXC, _ = r.directMediaURI(ctx, mxc) } } - return "" + return info, nil } func stripUserPrefix(name string) string { diff --git a/pkg/connector/chatsync.go b/pkg/connector/chatsync.go index 19f962e..174bfb5 100644 --- a/pkg/connector/chatsync.go +++ b/pkg/connector/chatsync.go @@ -2,27 +2,34 @@ package connector import ( "context" + "encoding/json" "errors" - "strings" "time" "github.com/rs/zerolog" "maunium.net/go/mautrix" "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/database" "maunium.net/go/mautrix/bridgev2/networkid" "maunium.net/go/mautrix/bridgev2/simplevent" "maunium.net/go/mautrix/bridgev2/status" "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/id" + + "github.com/beeper/reddit/pkg/redditchat" ) // runSync is the long-poll loop that translates Reddit-Matrix sync responses // into bridgev2 RemoteEvents. It exits when the context is cancelled. -func (r *RedditClient) runSync(ctx context.Context) { +func (r *RedditClient) runSync(ctx context.Context, done chan struct{}) { defer func() { - if r.syncDone != nil { - close(r.syncDone) + r.syncMu.Lock() + if r.syncDone == done { + r.syncCancel = nil + r.syncDone = nil } + close(done) + r.syncMu.Unlock() }() log := zerolog.Ctx(ctx) @@ -33,29 +40,43 @@ func (r *RedditClient) runSync(ctx context.Context) { if err := ctx.Err(); err != nil { return } - resp, err := r.rc.Sync(ctx, r.meta.NextBatch, timeoutMS) + since := r.meta.NextBatch + if r.meta.RoomStateVersion < 4 { + since = "" + } + resp, err := r.remote().Sync(ctx, since, timeoutMS) if err != nil { if errors.Is(err, context.Canceled) { return } - // 401 means the chat token expired. Try to refresh once. + // An expired chat token may still have a renewable Reddit session. if isUnauthorized(err) { - if refreshErr := r.refreshChatToken(ctx); refreshErr != nil { - log.Error().Err(refreshErr).Msg("Failed to refresh Reddit chat token") + refreshErr := r.refreshChatToken(ctx) + if refreshErr == nil { + continue + } + if ctx.Err() != nil { + return + } + if refreshNeedsLogin(refreshErr) { + log.Warn().Err(refreshErr).Msg("Reddit session requires reauthentication") r.userLogin.BridgeState.Send(status.BridgeState{ StateEvent: status.StateBadCredentials, Error: "reddit-token-refresh-failed", Message: refreshErr.Error(), + UserAction: status.UserActionRelogin, }) return } - continue + // Provider outages, rate limits and local save failures retain the + // existing session and retry through the normal native poll loop. + err = refreshErr } log.Warn().Err(err).Msg("Sync failed, backing off") r.userLogin.BridgeState.Send(status.BridgeState{ StateEvent: status.StateTransientDisconnect, Error: "reddit-sync-error", - Message: err.Error(), + Message: "Unable to synchronize Reddit chats. Retrying automatically.", }) select { case <-time.After(backoff): @@ -65,171 +86,290 @@ func (r *RedditClient) runSync(ctx context.Context) { continue } - // First successful sync after (re)connect: mark connected. + err = r.handleSync(ctx, resp, &roomSnapshots{}) + if err != nil { + log.Error().Err(err).Msg("Failed to process Reddit sync; retaining cursor") + r.userLogin.BridgeState.Send(status.BridgeState{StateEvent: status.StateTransientDisconnect, Error: "reddit-sync-apply-failed", Message: "Unable to synchronize Reddit chat state. Retrying."}) + select { + case <-time.After(backoff): + case <-ctx.Done(): + return + } + continue + } + // All framework event calls have returned. Advance the native token; + // event delivery and crash safety belong to bridgev2 and its runtime. + previousCursor, previousVersion := r.meta.NextBatch, r.meta.RoomStateVersion + r.meta.NextBatch, r.meta.RoomStateVersion = resp.NextBatch, 4 + if err = r.saveMeta(ctx); err != nil { + r.meta.NextBatch, r.meta.RoomStateVersion = previousCursor, previousVersion + select { + case <-time.After(backoff): + case <-ctx.Done(): + return + } + continue + } + // First successfully processed sync after (re)connect: mark connected. if r.userLogin.BridgeState.GetPrevUnsent().StateEvent != status.StateConnected { r.userLogin.BridgeState.Send(status.BridgeState{StateEvent: status.StateConnected}) } - r.handleSync(ctx, resp) - r.meta.NextBatch = resp.NextBatch - r.saveMeta(ctx) } } -func (r *RedditClient) handleSync(ctx context.Context, resp *mautrix.RespSync) { +// Unbridgeable native data is skipped so it cannot hold the cursor forever. +// Any other failure retries the whole batch. +func (r *RedditClient) handleSync(ctx context.Context, resp *redditchat.SyncResponse, snapshots *roomSnapshots) error { + unhidden, err := r.applyHiddenChatData(ctx, resp) + if err != nil { + return err + } for roomID, joined := range resp.Rooms.Join { - r.handleJoinedRoom(ctx, roomID, joined) + if r.meta.HiddenRooms[roomID] { + continue + } + if err = skipUnbridgeable(ctx, r.handleRoomUpdate(ctx, roomID, joined, snapshots), roomID, ""); err != nil { + return err + } } for roomID, invited := range resp.Rooms.Invite { - r.handleInvitedRoom(ctx, roomID, invited) + if r.meta.HiddenRooms[roomID] { + continue + } + if err = skipUnbridgeable(ctx, r.handleInvitedRoom(ctx, roomID, invited), roomID, ""); err != nil { + return err + } + } + // Pending requests receive subsequent messages in rooms.peek. Process + // invitation state first, then use the same queue/backfill path as joined + // timelines. Membership comes from state, never from the response bucket. + for roomID, peek := range resp.Rooms.Peek { + if peek == nil || r.meta.HiddenRooms[roomID] || resp.Rooms.Join[roomID] != nil || resp.Rooms.Leave[roomID] != nil { + continue + } + if len(peek.State.Events)+len(peek.Timeline.Events)+len(peek.Ephemeral.Events) == 0 { + continue + } + if err = skipUnbridgeable(ctx, r.handleRoomUpdate(ctx, roomID, peek, snapshots), roomID, ""); err != nil { + return err + } } for roomID := range resp.Rooms.Leave { - r.handleLeftRoom(ctx, roomID) + if err = r.handleLeftRoom(ctx, roomID); err != nil { + return err + } + } + if len(unhidden) > 0 { + state, err := r.fetchRoomSnapshots(ctx, snapshots, unhidden) + if err != nil { + return err + } + return r.handleSync(ctx, state, snapshots) } + return nil } -func (r *RedditClient) handleJoinedRoom(ctx context.Context, roomID id.RoomID, joined *mautrix.SyncJoinedRoom) { - isDM := r.detectIsDM(joined.State.Events, joined.Timeline.Events) - portalKey := r.makePortalKey(roomID, isDM) +func skipUnbridgeable(ctx context.Context, err error, roomID id.RoomID, eventID id.EventID) error { + if !isUnbridgeable(err) { + return err + } + zerolog.Ctx(ctx).Warn().Err(err).Stringer("room_id", roomID).Stringer("event_id", eventID).Msg("Skipping unbridgeable Reddit data") + return nil +} - // Emit a ChatResync per room: bridgev2 deduplicates so we can do this every - // sync without spamming. The GetChatInfoFunc closure resolves room name, - // members, etc. lazily from the Reddit Matrix homeserver. - r.userLogin.QueueRemoteEvent(&simplevent.ChatResync{ - EventMeta: simplevent.EventMeta{ - Type: bridgev2.RemoteEventChatResync, - PortalKey: portalKey, - CreatePortal: true, - }, - GetChatInfoFunc: func(ctx context.Context, portal *bridgev2.Portal) (*bridgev2.ChatInfo, error) { - return r.fetchChatInfo(ctx, roomID, isDM, *joined) - }, - }) +func (r *RedditClient) applyHiddenChatData(ctx context.Context, resp *redditchat.SyncResponse) ([]id.RoomID, error) { + var unhidden []id.RoomID + // Apply account data before invitations: full native sync includes hidden + // DMs in both peek and invite. Never mistake a preview for membership. + for _, rooms := range []map[id.RoomID]*mautrix.SyncJoinedRoom{resp.Rooms.Join, resp.Rooms.Peek} { + for roomID, room := range rooms { + if room == nil { + continue + } + for _, evt := range room.AccountData.Events { + if evt == nil || evt.Type.Type != "com.reddit.hidden_chat" { + continue + } + hidden, err := parseHiddenChat(evt) + if err != nil { + zerolog.Ctx(ctx).Warn().Err(err).Stringer("room_id", roomID).Stringer("event_id", evt.ID).Msg("Skipping unbridgeable Reddit hidden chat state") + continue + } + if hidden { + if r.meta.HiddenRooms == nil { + r.meta.HiddenRooms = make(map[id.RoomID]bool) + } + r.meta.HiddenRooms[roomID] = true + if err = r.handleLeftRoom(ctx, roomID); err != nil { + return nil, err + } + } else { + delete(r.meta.HiddenRooms, roomID) + if resp.Rooms.Join[roomID] == nil && resp.Rooms.Invite[roomID] == nil { + unhidden = append(unhidden, roomID) + } + } + } + } + } + return unhidden, nil +} + +func parseHiddenChat(evt *event.Event) (bool, error) { + var content struct { + Hidden *bool `json:"hidden"` + } + raw, err := json.Marshal(&evt.Content) + if err != nil { + return false, err + } + if err = json.Unmarshal(raw, &content); err != nil { + return false, err + } + if content.Hidden == nil { + return false, errors.New("reddit hidden chat state has no hidden flag") + } + return *content.Hidden, nil +} + +func (r *RedditClient) handleRoomUpdate(ctx context.Context, roomID id.RoomID, joined *mautrix.SyncJoinedRoom, snapshots *roomSnapshots) error { + if joined == nil { + return unbridgeable(errors.New("reddit sync contains a null room")) + } + state, portalKey, err := r.resolveRoomState(ctx, roomID, joined, snapshots) + if err != nil { + return err + } + + resync := r.joinedRoomEvent(state, portalKey, joined.Timeline.Limited) + r.userLogin.QueueRemoteEvent(resync) for _, evt := range joined.Timeline.Events { - r.handleTimelineEvent(ctx, portalKey, evt) + if evt == nil { + continue + } + if err = skipUnbridgeable(ctx, r.handleTimelineEvent(ctx, portalKey, evt), roomID, evt.ID); err != nil { + return err + } } + r.handleEphemeralEvents(ctx, portalKey, joined.Ephemeral.Events) + return nil } -func (r *RedditClient) handleInvitedRoom(ctx context.Context, roomID id.RoomID, invited *mautrix.SyncInvitedRoom) { - // For now we auto-accept invites by joining and letting the next sync - // surface the room as joined. Reddit DMs arrive as invites. - if _, err := r.matrix().JoinRoomByID(ctx, roomID); err != nil { - zerolog.Ctx(ctx).Warn().Err(err).Stringer("room_id", roomID).Msg("Failed to auto-accept Reddit invite") +func (r *RedditClient) handleInvitedRoom(ctx context.Context, roomID id.RoomID, invited *mautrix.SyncInvitedRoom) error { + joined, err := r.inviteSnapshot(invited) + if err != nil { + return unbridgeable(err) } + state, key, err := r.roomState(ctx, roomID, joined) + if err != nil { + return err + } + request := r.pendingRequestEvent(state, key) + // Receiving an invitation must never join the remote room. Only an + // explicit acceptance or a user reply may cross that boundary. + r.userLogin.QueueRemoteEvent(request) + return nil } -func (r *RedditClient) handleLeftRoom(ctx context.Context, roomID id.RoomID) { - r.userLogin.QueueRemoteEvent(&simplevent.ChatDelete{ +func (r *RedditClient) handleLeftRoom(ctx context.Context, roomID id.RoomID) error { + portal, err := r.savedRoomPortal(ctx, roomID) + if err != nil || portal == nil { + return err + } + deletion := &simplevent.ChatDelete{ EventMeta: simplevent.EventMeta{ Type: bridgev2.RemoteEventChatDelete, - PortalKey: networkid.PortalKey{ID: makePortalID(roomID), Receiver: r.userLogin.ID}, + PortalKey: portal.PortalKey, }, OnlyForMe: true, - }) + } + r.userLogin.QueueRemoteEvent(deletion) + return nil } -func (r *RedditClient) handleTimelineEvent(ctx context.Context, portalKey networkid.PortalKey, evt *event.Event) { - if evt == nil { - return +func (r *RedditClient) handleTimelineEvent(ctx context.Context, portalKey networkid.PortalKey, evt *event.Event) error { + if evt.Type == event.EventMessage || evt.Type == event.EventReaction || evt.Type == event.EventRedaction { + if evt.ID == "" || evt.Sender == "" || (evt.RoomID != "" && makePortalID(evt.RoomID) != portalKey.ID) { + return unbridgeable(errors.New("reddit timeline event has invalid room or event identity")) + } + if err := evt.Content.ParseRaw(evt.Type); err != nil && !errors.Is(err, event.ErrContentAlreadyParsed) { + return unbridgeable(err) + } } switch evt.Type { case event.EventMessage: - r.queueMessage(ctx, portalKey, evt) + return r.queueMessage(ctx, portalKey, evt) case event.EventRedaction: - r.queueRedaction(portalKey, evt) + return r.queueRedaction(ctx, portalKey, evt) case event.EventReaction: - r.queueReaction(portalKey, evt) + return r.queueReaction(ctx, portalKey, evt) default: // Membership, name, avatar changes etc. are picked up by the next - // ChatResync's GetChatInfoFunc; no per-event handling needed for the - // MVP. Typing/receipts come through ephemeral events, not timeline. + // ChatResync. Typing/receipts come through ephemeral events. } + return nil } -func (r *RedditClient) queueMessage(ctx context.Context, portalKey networkid.PortalKey, evt *event.Event) { - sender := r.makeSender(evt.Sender) - r.userLogin.QueueRemoteEvent(&simplevent.Message[*event.Event]{ - EventMeta: simplevent.EventMeta{ - Type: bridgev2.RemoteEventMessage, - PortalKey: portalKey, - Sender: sender, - CreatePortal: true, - Timestamp: time.UnixMilli(evt.Timestamp), +// Let bridgev2 choose the catch-up anchor and run FetchMessages, as Slack does. +func (r *RedditClient) joinedRoomEvent(state *RoomState, key networkid.PortalKey, limited bool) *simplevent.ChatResync { + info := r.chatInfoFromState(state) + return &simplevent.ChatResync{ + EventMeta: simplevent.EventMeta{Type: bridgev2.RemoteEventChatResync, PortalKey: key, CreatePortal: true}, + ChatInfo: info, + CheckNeedsBackfillFunc: func(_ context.Context, latest *database.Message) (bool, error) { + return info.CanBackfill && (limited || latest == nil), nil }, - ID: makeMessageID(evt.ID), - Data: evt, - ConvertMessageFunc: r.convertMessage, - }) + } } -func (r *RedditClient) queueRedaction(portalKey networkid.PortalKey, evt *event.Event) { - target := id.EventID(evt.Redacts) - if target == "" { - if content, ok := evt.Content.Parsed.(*event.RedactionEventContent); ok { - target = content.Redacts +func (r *RedditClient) queueMessage(ctx context.Context, key networkid.PortalKey, evt *event.Event) error { + if evt.Unsigned.RedactedBecause != nil { + removal, err := r.convertDeletedEvent(key, evt) + if err != nil { + return unbridgeable(err) } + r.userLogin.QueueRemoteEvent(removal) + return nil } - if target == "" { - return - } - r.userLogin.QueueRemoteEvent(&simplevent.MessageRemove{ + r.userLogin.QueueRemoteEvent(&simplevent.Message[*event.Event]{ EventMeta: simplevent.EventMeta{ - Type: bridgev2.RemoteEventMessageRemove, - PortalKey: portalKey, - Sender: r.makeSender(evt.Sender), + Type: bridgev2.RemoteEventMessage, PortalKey: key, + Sender: r.makeSender(evt.Sender), CreatePortal: true, Timestamp: time.UnixMilli(evt.Timestamp), }, - TargetMessage: makeMessageID(target), + ID: makeMessageID(evt.ID), TransactionID: networkid.TransactionID(evt.Unsigned.TransactionID), + Data: evt, ConvertMessageFunc: r.convertMessage, }) + return nil } -func (r *RedditClient) queueReaction(portalKey networkid.PortalKey, evt *event.Event) { - content, ok := evt.Content.Parsed.(*event.ReactionEventContent) - if !ok || content.RelatesTo.EventID == "" { - return +func (r *RedditClient) queueRedaction(ctx context.Context, key networkid.PortalKey, evt *event.Event) error { + removal, err := r.convertRedaction(ctx, key, evt) + if err != nil { + return err } - r.userLogin.QueueRemoteEvent(&simplevent.Reaction{ - EventMeta: simplevent.EventMeta{ - Type: bridgev2.RemoteEventReaction, - PortalKey: portalKey, - Sender: r.makeSender(evt.Sender), - Timestamp: time.UnixMilli(evt.Timestamp), - }, - EmojiID: networkid.EmojiID(content.RelatesTo.Key), - Emoji: content.RelatesTo.Key, - TargetMessage: makeMessageID(content.RelatesTo.EventID), - }) + r.userLogin.QueueRemoteEvent(removal) + return nil } -// detectIsDM inspects state and a few timeline events for the m.room.create -// preset or the is_direct flag. Reddit DM rooms are created with preset -// "reddit_dm" — that's stored in m.room.create's content. -func (r *RedditClient) detectIsDM(state []*event.Event, timeline []*event.Event) bool { - check := func(evts []*event.Event) (bool, bool) { - for _, e := range evts { - if e.Type != event.StateCreate { - continue - } - raw, _ := e.Content.Raw["preset"].(string) - if strings.EqualFold(raw, redditDMPreset) { - return true, true - } - if v, ok := e.Content.Raw["m.federate"].(bool); ok && !v { - // Reddit chat rooms don't federate; not a reliable signal. - _ = v - } - return false, true +func (r *RedditClient) queueReaction(ctx context.Context, key networkid.PortalKey, evt *event.Event) error { + if evt.Unsigned.RedactedBecause != nil { + // Old sync responses may retain the original reaction content. + removal, err := r.convertDeletedEvent(key, evt) + if err != nil { + return unbridgeable(err) } - return false, false + r.userLogin.QueueRemoteEvent(removal) + return nil } - if v, ok := check(state); ok { - return v + reaction, err := r.convertReaction(ctx, key, evt) + if err != nil { + return err } - if v, ok := check(timeline); ok { - return v - } - return false + r.userLogin.QueueRemoteEvent(reaction) + return nil } const redditDMPreset = "reddit_dm" diff --git a/pkg/connector/client.go b/pkg/connector/client.go index c336b02..1bbccef 100644 --- a/pkg/connector/client.go +++ b/pkg/connector/client.go @@ -26,15 +26,21 @@ type RedditClient struct { userLogin *bridgev2.UserLogin userID networkid.UserID - rc *redditchat.Client - session *redditchat.RedditSession - meta *UserLoginMetadata + rc *redditchat.Client + clientMu sync.RWMutex + session *redditchat.RedditSession + meta *UserLoginMetadata syncMu sync.Mutex syncCancel context.CancelFunc syncDone chan struct{} saveMu sync.Mutex + roomMu sync.RWMutex + rooms map[id.RoomID]*RoomState + + typingMu sync.Mutex + typing map[networkid.PortalKey][]id.UserID } var _ bridgev2.NetworkAPI = (*RedditClient)(nil) @@ -98,25 +104,22 @@ func (r *RedditClient) Connect(ctx context.Context) { }) return } - r.userLogin.BridgeState.Send(status.BridgeState{StateEvent: status.StateConnecting}) - r.syncMu.Lock() defer r.syncMu.Unlock() if r.syncCancel != nil { return } + r.userLogin.BridgeState.Send(status.BridgeState{StateEvent: status.StateConnecting}) syncCtx, cancel := context.WithCancel(r.userLogin.Log.WithContext(context.Background())) r.syncCancel = cancel r.syncDone = make(chan struct{}) - go r.runSync(syncCtx) + go r.runSync(syncCtx, r.syncDone) } func (r *RedditClient) Disconnect() { r.syncMu.Lock() cancel := r.syncCancel done := r.syncDone - r.syncCancel = nil - r.syncDone = nil r.syncMu.Unlock() if cancel != nil { cancel() @@ -130,7 +133,8 @@ func (r *RedditClient) Disconnect() { } func (r *RedditClient) IsLoggedIn() bool { - return r.rc != nil && r.meta.Credentials.AccessToken != "" + client := r.remote() + return client != nil && client.Mautrix().AccessToken != "" } func (r *RedditClient) IsThisUser(ctx context.Context, userID networkid.UserID) bool { @@ -141,43 +145,75 @@ func (r *RedditClient) LogoutRemote(ctx context.Context) { if !r.IsLoggedIn() { return } - if _, err := r.rc.Mautrix().Logout(ctx); err != nil { + if _, err := r.remote().Mautrix().Logout(ctx); err != nil { zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to log out of Reddit Matrix homeserver") } } // saveMeta persists the UserLoginMetadata. Callers should mutate r.meta first, // then call this method. Concurrent callers are serialized. -func (r *RedditClient) saveMeta(ctx context.Context) { +func (r *RedditClient) saveMeta(ctx context.Context) error { r.saveMu.Lock() defer r.saveMu.Unlock() if err := r.userLogin.Save(ctx); err != nil { zerolog.Ctx(ctx).Error().Err(err).Msg("Failed to save user login metadata") + return err } + return nil } +var errRefreshAccountMismatch = errors.New("reddit session belongs to a different account; sign in again with the original account") + // refreshChatToken re-mints the Reddit Matrix access token using the saved // Reddit session cookies. Called on 401 from the Matrix homeserver. func (r *RedditClient) refreshChatToken(ctx context.Context) error { if r.session == nil { - return errors.New("no reddit session available for refresh") + return redditchat.ErrSessionUnavailable } token, creds, err := redditchat.CredentialsFromRedditSession(ctx, r.session) if err != nil { return err } - creds.DeviceID = r.meta.Credentials.DeviceID - r.meta.Credentials = creds - r.meta.ChatTokenExpiry = token.Expires + if creds.UserID != r.meta.Credentials.UserID || makeUserLoginID(id.UserID(creds.UserID)) != r.userLogin.ID { + return errRefreshAccountMismatch + } + if creds.DeviceID == "" { + creds.DeviceID = r.meta.Credentials.DeviceID + } newClient, err := redditchat.New(creds) if err != nil { return err } + cookies, err := serializeCookies(r.session) + if err != nil { + return fmt.Errorf("serialize refreshed Reddit session: %w", err) + } + previousCreds, previousExpiry, previousCookies := r.meta.Credentials, r.meta.ChatTokenExpiry, r.meta.CookiesJSON + r.meta.Credentials, r.meta.ChatTokenExpiry, r.meta.CookiesJSON = creds, token.Expires, cookies + if err = r.saveMeta(ctx); err != nil { + r.meta.Credentials, r.meta.ChatTokenExpiry, r.meta.CookiesJSON = previousCreds, previousExpiry, previousCookies + return fmt.Errorf("save refreshed Reddit session: %w", err) + } + r.clientMu.Lock() r.rc = newClient - r.saveMeta(ctx) + r.clientMu.Unlock() return nil } +func refreshNeedsLogin(err error) bool { + var httpErr *redditchat.LoginHTTPError + return errors.Is(err, redditchat.ErrSessionUnavailable) || errors.Is(err, errRefreshAccountMismatch) || + (errors.As(err, &httpErr) && (httpErr.StatusCode == http.StatusUnauthorized || httpErr.StatusCode == http.StatusForbidden)) +} + func (r *RedditClient) matrix() *mautrix.Client { - return r.rc.Mautrix() + return r.remote().Mautrix() +} + +// A refreshed client is published as one immutable object. In-flight requests +// may finish on the old token; later requests always see the new client. +func (r *RedditClient) remote() *redditchat.Client { + r.clientMu.RLock() + defer r.clientMu.RUnlock() + return r.rc } diff --git a/pkg/connector/client_test.go b/pkg/connector/client_test.go new file mode 100644 index 0000000..08fd355 --- /dev/null +++ b/pkg/connector/client_test.go @@ -0,0 +1,60 @@ +package connector + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "testing" + + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/bridgeconfig" + "maunium.net/go/mautrix/bridgev2/database" + + "github.com/beeper/reddit/pkg/redditchat" +) + +func testClient(t *testing.T, handler http.HandlerFunc) *RedditClient { + t.Helper() + srv := httptest.NewServer(handler) + t.Cleanup(srv.Close) + creds := redditchat.Credentials{Homeserver: srv.URL, UserID: "@t2_a:reddit.com", AccessToken: "test-token"} + rc, err := redditchat.New(creds) + if err != nil { + t.Fatal(err) + } + main := &RedditConnector{Bridge: &bridgev2.Bridge{Config: &bridgeconfig.BridgeConfig{}}} + main.Config.DisplaynameTemplate = "{{ .Username }}" + if err = main.Config.PostProcess(); err != nil { + t.Fatal(err) + } + return &RedditClient{main: main, rc: rc, meta: &UserLoginMetadata{Credentials: creds}, userID: "t2_a", userLogin: &bridgev2.UserLogin{UserLogin: &database.UserLogin{ID: "t2_a"}}} +} + +func TestResolveIdentifierRequiresUnambiguousExactMatch(t *testing.T) { + for _, tc := range []struct { + name, query, response, wantID string + }{ + {"fuzzy only", "intended", `{"results":[{"user_id":"@t2_b:reddit.com","display_name":"someone_else"}]}`, ""}, + {"ambiguous", "intended", `{"results":[{"user_id":"@t2_b:reddit.com","display_name":"intended"},{"user_id":"@t2_c:reddit.com","display_name":"intended"}]}`, ""}, + {"exact after fuzzy", " /u/Intended ", `{"results":[{"user_id":"@t2_b:reddit.com","display_name":"intended_other"},{"user_id":"@t2_c:reddit.com","display_name":"u/intended"}]}`, "t2_c"}, + {"duplicate same identity", "intended", `{"results":[{"user_id":"@t2_b:reddit.com","display_name":"intended"},{"user_id":"@t2_b:reddit.com","display_name":"intended"}]}`, "t2_b"}, + } { + t.Run(tc.name, func(t *testing.T) { + r := testClient(t, func(w http.ResponseWriter, req *http.Request) { + if req.URL.Path != "/_matrix/client/v3/user_directory/search" { + t.Errorf("unexpected request: %s", req.URL.Path) + } + _, _ = io.WriteString(w, tc.response) + }) + result, err := r.ResolveIdentifier(context.Background(), tc.query, false) + if tc.wantID == "" { + if err == nil || result != nil { + t.Fatal("ambiguous or fuzzy identifier was accepted") + } + } else if err != nil || string(result.UserID) != tc.wantID { + t.Fatalf("exact lookup = %v, %v", result, err) + } + }) + } +} diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index 3a7db4f..9fffd41 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -3,6 +3,8 @@ package connector import ( "context" "fmt" + "sync" + "sync/atomic" "github.com/google/uuid" "maunium.net/go/mautrix/bridgev2" @@ -12,14 +14,18 @@ import ( ) type RedditConnector struct { - Bridge *bridgev2.Bridge - Config Config + Bridge *bridgev2.Bridge + Config Config + mediaUploadLimit atomic.Int64 + emojiMu sync.Mutex + directMedia bool } var ( _ bridgev2.NetworkConnector = (*RedditConnector)(nil) _ bridgev2.ConfigValidatingNetwork = (*RedditConnector)(nil) _ bridgev2.TransactionIDGeneratingNetwork = (*RedditConnector)(nil) + _ bridgev2.MaxFileSizeingNetwork = (*RedditConnector)(nil) ) func (rc *RedditConnector) Init(bridge *bridgev2.Bridge) { @@ -42,9 +48,11 @@ func (rc *RedditConnector) GetName() bridgev2.BridgeName { } func (rc *RedditConnector) GetBridgeInfoVersion() (info, capabilities int) { - return 1, 1 + return 3, 9 } +func (rc *RedditConnector) SetMaxFileSize(size int64) { rc.mediaUploadLimit.Store(size) } + func (rc *RedditConnector) GenerateTransactionID(userID id.UserID, roomID id.RoomID, eventType event.Type) networkid.RawTransactionID { return networkid.RawTransactionID(uuid.NewString()) } diff --git a/pkg/connector/dbmeta.go b/pkg/connector/dbmeta.go index 5e49d52..4c04ea8 100644 --- a/pkg/connector/dbmeta.go +++ b/pkg/connector/dbmeta.go @@ -2,6 +2,7 @@ package connector import ( "maunium.net/go/mautrix/bridgev2/database" + "maunium.net/go/mautrix/id" "github.com/beeper/reddit/pkg/redditchat" ) @@ -12,23 +13,32 @@ func (rc *RedditConnector) GetDBMetaTypes() database.MetaTypes { Portal: func() any { return &PortalMetadata{} }, Ghost: nil, Message: nil, - Reaction: nil, + Reaction: func() any { return &ReactionMetadata{} }, } } +type ReactionMetadata struct { + RemoteEventID id.EventID `json:"remote_event_id,omitempty"` +} + // UserLoginMetadata is persisted alongside each Reddit-connected user. // It carries the Matrix credentials minted from Reddit, the serialized Reddit // session cookies (so we can refresh the chat token without re-login), the // chat token expiry, and the current Matrix sync cursor for the polling loop. type UserLoginMetadata struct { - Credentials redditchat.Credentials `json:"credentials"` - CookiesJSON string `json:"cookies_json,omitempty"` - ChatTokenExpiry int64 `json:"chat_token_expiry,omitempty"` - NextBatch string `json:"next_batch,omitempty"` - Username string `json:"username,omitempty"` + Credentials redditchat.Credentials `json:"credentials"` + CookiesJSON string `json:"cookies_json,omitempty"` + ChatTokenExpiry int64 `json:"chat_token_expiry,omitempty"` + NextBatch string `json:"next_batch,omitempty"` + Username string `json:"username,omitempty"` + RoomStateVersion int `json:"room_state_version,omitempty"` + // Native per-account state outlives a deleted local portal. Persist it with + // the sync cursor so unchanged invitations do not resurrect ignored DMs. + HiddenRooms map[id.RoomID]bool `json:"hidden_rooms,omitempty"` } type PortalMetadata struct { + State *RoomState `json:"state,omitempty"` // Preset remembers whether the room was created as "reddit_dm" or // "private_chat" so we can stamp the same preset on outgoing room creations // and avoid round-tripping the Reddit homeserver for state. diff --git a/pkg/connector/directmedia.go b/pkg/connector/directmedia.go new file mode 100644 index 0000000..0f4c152 --- /dev/null +++ b/pkg/connector/directmedia.go @@ -0,0 +1,54 @@ +package connector + +import ( + "context" + "strings" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/networkid" + "maunium.net/go/mautrix/id" + "maunium.net/go/mautrix/mediaproxy" +) + +var _ bridgev2.DirectMediableNetwork = (*RedditConnector)(nil) + +func (rc *RedditConnector) SetUseDirectMedia() { rc.directMedia = true } + +// Store only the account and stable native MXC identity. Bridgev2 signs this +// opaque ID; CDN URLs, access tokens and attachment keys never go into it. +func (r *RedditClient) directMediaURI(ctx context.Context, uri id.ContentURIString) (id.ContentURIString, error) { + parsed, err := uri.Parse() + if err != nil || parsed.Homeserver != "reddit.com" || parsed.FileID == "" || strings.ContainsRune(parsed.FileID, 0) { + return "", mediaproxy.ErrInvalidMediaIDSyntax + } + return r.main.Bridge.Matrix.GenerateContentURI(ctx, networkid.MediaID("1\x00"+string(r.userLogin.ID)+"\x00"+parsed.FileID)) +} + +func (rc *RedditConnector) Download(ctx context.Context, mediaID networkid.MediaID, _ map[string]string) (mediaproxy.GetMediaResponse, error) { + parts := strings.Split(string(mediaID), "\x00") + if len(parts) != 3 || parts[0] != "1" || parts[1] == "" || parts[2] == "" { + return nil, mediaproxy.ErrInvalidMediaIDSyntax + } + login, err := rc.Bridge.GetExistingUserLoginByID(ctx, networkid.UserLoginID(parts[1])) + if err != nil { + return nil, err + } else if login == nil { + return nil, mautrix.MNotFound.WithMessage("Reddit media account no longer exists") + } + client, ok := login.Client.(*RedditClient) + if !ok || !client.IsLoggedIn() { + return nil, mautrix.MNotFound.WithMessage("Reddit media account is not logged in") + } + // Resolve through the current login on every request, including after token + // renewal. The existing bounded downloader follows Reddit's current redirect + // and sanitizes errors so a signed CDN URL cannot escape through diagnostics. + data, err := client.downloadRedditMedia(ctx, id.ContentURI{Homeserver: "reddit.com", FileID: parts[2]}.CUString(), nil) + if err != nil { + return nil, err + } + if _, err = imageMIME(data); err != nil { + return nil, err + } + return mediaproxy.GetMediaResponseRawData(data), nil +} diff --git a/pkg/connector/directmedia_test.go b/pkg/connector/directmedia_test.go new file mode 100644 index 0000000..9b19a05 --- /dev/null +++ b/pkg/connector/directmedia_test.go @@ -0,0 +1,49 @@ +package connector + +import ( + "bytes" + "context" + "testing" + + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/database" + "maunium.net/go/mautrix/bridgev2/networkid" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +type directMediaTestMatrix struct { + bridgev2.MatrixConnector + ids []networkid.MediaID +} + +func (m *directMediaTestMatrix) GenerateContentURI(_ context.Context, mediaID networkid.MediaID) (id.ContentURIString, error) { + m.ids = append(m.ids, bytes.Clone(mediaID)) + return "mxc://proxy.example/opaque", nil +} + +func TestDirectMediaKeepsStableAccountIdentityWithoutFetching(t *testing.T) { + matrix := &directMediaTestMatrix{} + main := &RedditConnector{Bridge: &bridgev2.Bridge{Matrix: matrix}} + main.SetUseDirectMedia() + for _, owner := range []networkid.UserLoginID{"t2_a", "t2_b"} { + r := &RedditClient{main: main, userLogin: &bridgev2.UserLogin{UserLogin: &database.UserLogin{ID: owner}}} + original := &event.FileInfo{ThumbnailURL: "mxc://reddit.com/thumb"} + content := &event.MessageEventContent{URL: "mxc://reddit.com/image", Info: original} + // No native client or Matrix intent: message conversion must do no IO. + if err := r.receiveMedia(context.Background(), nil, nil, content); err != nil { + t.Fatal(err) + } + if content.URL != "mxc://proxy.example/opaque" || content.Info.ThumbnailURL != content.URL || original.ThumbnailURL != "mxc://reddit.com/thumb" { + t.Fatal("direct media failed or mutated source metadata") + } + for _, foreign := range []id.ContentURIString{"https://cdn.example/image?expires=1", "mxc://other.example/image"} { + if _, err := r.directMediaURI(context.Background(), foreign); err == nil { + t.Fatal("accepted a transient or foreign media URL") + } + } + } + if len(matrix.ids) != 4 || string(matrix.ids[0]) != "1\x00t2_a\x00image" || string(matrix.ids[2]) != "1\x00t2_b\x00image" { + t.Fatal("native identity was lost or shared across accounts") + } +} diff --git a/pkg/connector/emoji.go b/pkg/connector/emoji.go new file mode 100644 index 0000000..ea9f860 --- /dev/null +++ b/pkg/connector/emoji.go @@ -0,0 +1,155 @@ +package connector + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + "time" + + "github.com/rs/zerolog" + "go.mau.fi/util/variationselector" + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/database" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" + + "github.com/beeper/reddit/pkg/redditchat" +) + +// Unicode aliases select corresponding native images. The full native palette +// is available through image-pack import, including reactions without aliases. +var reactionAliases = map[string]string{ + "😂": "joy", "👋": "wave", "😢": "cry", "😵": "dizzy_face", + "🤦": "facepalm", "😳": "flushed", "😬": "grimacing", "😁": "grin", + "😍": "heart_eyes", "🤗": "hug", "😘": "kissing_heart", "😆": "laughing", + "🤑": "money_face", "😐": "neutral_face", "😶": "no_mouth", "😡": "rage", + "😱": "scream", "🤷": "shrug", "😴": "sleep", "🙂": "slightly_smiling", + "😄": "smile", "😭": "sob", "😛": "stuck_out_tongue", "😎": "sunglasses", + "😮": "surprise", "😓": "sweat", "😅": "sweat_smile", "🤔": "thinking_face_hmm", + "👎": "thumbs_down", "👍": "thumbs_up", "😉": "wink", "😋": "yummy", +} + +func nativeReactionKey(value string) (string, bool) { + name := reactionAliases[variationselector.Remove(value)] + for _, asset := range redditchat.ReactionAssets() { + if value == asset.Key || value == ":"+asset.Name+":" || asset.Name == name { + return asset.Key, true + } + } + return "", false +} + +var reactionHTTPClient = &http.Client{ + Timeout: 30 * time.Second, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + if len(via) >= 3 || req.URL.Scheme != "https" || req.URL.Host != "i.redd.it" { + return errors.New("unexpected reaction image redirect") + } + return nil + }, +} + +func (r *RedditClient) reactionImage(ctx context.Context, key string) (string, string, error) { + asset, known := redditchat.ReactionAssetByKey(key) + if !known { + // Preserve unknown future keys visibly, without fetching arbitrary URLs. + return key, "", nil + } + r.main.emojiMu.Lock() + defer r.main.emojiMu.Unlock() + mxc := r.main.Bridge.DB.KV.Get(ctx, database.Key("reddit_emoji_v1/key/"+key)) + if mxc == "" { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://i.redd.it/"+asset.Key, nil) + if err != nil { + return "", "", err + } + resp, err := reactionHTTPClient.Do(req) + if err != nil { + return "", "", mediaError(ctx, "Reddit reaction download", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + err = fmt.Errorf("reddit reaction image is unavailable (HTTP %d)", resp.StatusCode) + if isGoneStatus(resp.StatusCode) { + err = unbridgeable(err) + } + return "", "", err + } + data, err := readMediaBounded(resp.Body, 2*1024*1024) + if err != nil { + return "", "", err + } + mime, err := imageMIME(data) + if err != nil { + return "", "", err + } + // Public emoji assets use unencrypted media, since a Matrix reaction key + // cannot carry attachment decryption metadata. Private images never use this. + uri, file, err := r.main.Bridge.Bot.UploadMedia(ctx, "", data, asset.Key, mime) + if err != nil { + return "", "", mediaError(ctx, "Beeper reaction upload", err) + } + if file != nil || uri == "" { + return "", "", errors.New("reaction upload did not return a public media URI") + } + mxc = string(uri) + r.main.Bridge.DB.KV.Set(ctx, database.Key("reddit_emoji_v1/mxc/"+mxc), key) + r.main.Bridge.DB.KV.Set(ctx, database.Key("reddit_emoji_v1/key/"+key), mxc) + } + return mxc, ":" + asset.Name + ":", nil +} + +// A reaction image that can never be fetched falls back to text. +func (r *RedditClient) reactionEmoji(ctx context.Context, key string) (string, string, error) { + emoji, shortcode, err := r.reactionImage(ctx, key) + if !isUnbridgeable(err) { + return emoji, shortcode, err + } + asset, _ := redditchat.ReactionAssetByKey(key) + zerolog.Ctx(ctx).Warn().Err(err).Str("reaction", asset.Name).Msg("Failed to fetch Reddit reaction image, using text") + for unicode, name := range reactionAliases { + if name == asset.Name { + return unicode, "", nil + } + } + return ":" + asset.Name + ":", "", nil +} + +func (r *RedditClient) resolveOutgoingReaction(ctx context.Context, value string) (string, error) { + if strings.HasPrefix(value, "mxc://") { + key := r.main.Bridge.DB.KV.Get(ctx, database.Key("reddit_emoji_v1/mxc/"+value)) + if _, ok := redditchat.ReactionAssetByKey(key); ok { + return key, nil + } + } else if key, ok := nativeReactionKey(value); ok { + return key, nil + } + return "", errors.New("reddit supports its native reaction palette; choose an imported Reddit reaction or a matching emoji") +} + +var _ bridgev2.StickerImportingNetworkAPI = (*RedditClient)(nil) + +const reactionPackURL = "https://www.reddit.com/chat/" + +func redditReactionPack() *event.ImagePackMetadata { + return &event.ImagePackMetadata{DisplayName: "Reddit reactions", Usage: []event.ImagePackUsage{event.ImagePackUsageEmoji}, BridgedPack: &event.BridgedStickerPack{Network: "reddit", URL: reactionPackURL}} +} +func (r *RedditClient) ListImagePacks(ctx context.Context) ([]*event.ImagePackMetadata, error) { + return []*event.ImagePackMetadata{redditReactionPack()}, nil +} +func (r *RedditClient) DownloadImagePack(ctx context.Context, packURL string) (*bridgev2.ImportedImagePack, error) { + if packURL != reactionPackURL { + return nil, errors.New("unknown Reddit reaction pack") + } + images := make(map[string]*event.ImagePackImage) + for _, asset := range redditchat.ReactionAssets() { + mxc, _, err := r.reactionImage(ctx, asset.Key) + if err != nil { + return nil, err + } + images[asset.Name] = &event.ImagePackImage{URL: id.ContentURIString(mxc)} + } + return &bridgev2.ImportedImagePack{Shortcode: "reddit-reactions", Content: &event.ImagePackEventContent{Images: images, Metadata: *redditReactionPack()}}, nil +} diff --git a/pkg/connector/handlematrix.go b/pkg/connector/handlematrix.go index 0c0e117..cb13f17 100644 --- a/pkg/connector/handlematrix.go +++ b/pkg/connector/handlematrix.go @@ -11,7 +11,6 @@ import ( "maunium.net/go/mautrix/bridgev2/database" "maunium.net/go/mautrix/bridgev2/networkid" "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" ) var ( @@ -27,55 +26,69 @@ func (r *RedditClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.Ma if !r.IsLoggedIn() { return nil, errors.New("not logged in to Reddit") } - roomID := portalIDToRoomID(msg.Portal.ID) - content := msg.Content - if content == nil { - return nil, errors.New("missing message content") + txn, err := r.outgoingTransaction(msg.Portal, msg.Event, msg.InputTransactionID) + if err != nil { + return nil, err + } + content, err := outgoingContent(msg) + if err != nil { + return nil, err + } + // Beeper represents GIF attachments as m.video + fi.mau.gif. Reddit + // accepts the original GIF bytes as an image, not an ordinary video. + if content.GetCapMsgType() == event.CapMsgGIF && content.Info.MimeType == "image/gif" { + content.MsgType = event.MsgImage + copyMediaInfo(content) + content.Info.MauGIF = false + } + switch content.MsgType { + case event.MsgImage: + if err = r.sendMedia(ctx, content); err != nil { + return nil, err + } + case event.MsgText, event.MsgNotice, event.MsgEmote: + default: + return nil, bridgev2.ErrUnsupportedMessageType } - resp, err := r.rc.SendMessage(ctx, roomID, content) + resp, err := r.remote().SendMessage(ctx, portalIDToRoomID(msg.Portal.ID), content, mautrix.ReqSendEvent{TransactionID: txn}) if err != nil { return nil, fmt.Errorf("send to reddit: %w", err) } + if resp.EventID == "" { + return nil, errors.New("reddit returned no message identity") + } return &bridgev2.MatrixMessageResponse{ DB: &database.Message{ - ID: makeMessageID(resp.EventID), - MXID: msg.Event.ID, - SenderID: r.userID, - Timestamp: time.UnixMilli(msg.Event.Timestamp), + ID: makeMessageID(resp.EventID), + SendTxnID: msg.InputTransactionID, + SenderID: r.userID, + Timestamp: time.UnixMilli(msg.Event.Timestamp), + ThreadRoot: makeMessageID(content.RelatesTo.GetThreadParent()), }, }, nil } func (r *RedditClient) HandleMatrixEdit(ctx context.Context, msg *bridgev2.MatrixEdit) error { - if msg.EditTarget == nil { - return errors.New("no edit target") - } - roomID := portalIDToRoomID(msg.Portal.ID) - content := msg.Content - if content == nil { - return errors.New("missing edit content") - } - // Forward the m.replace relation as-is to Reddit's Matrix homeserver. - if content.RelatesTo == nil { - content.RelatesTo = &event.RelatesTo{ - Type: event.RelReplace, - EventID: messageIDToEventID(msg.EditTarget.ID), - } - } - _, err := r.rc.SendMessage(ctx, roomID, content) - return err + // The observed Reddit Chat menu has no edit action. Do not claim support or + // turn an attempted edit into a second message while that contract is unknown. + return bridgev2.ErrEditsNotSupported } func (r *RedditClient) HandleMatrixMessageRemove(ctx context.Context, msg *bridgev2.MatrixMessageRemove) error { - if msg.TargetMessage == nil { - return errors.New("no redaction target") + txn, err := r.outgoingTransaction(msg.Portal, msg.Event, msg.InputTransactionID) + if err != nil { + return err + } + target, err := mappedTarget(msg.Portal, msg.TargetMessage) + if err != nil { + return err } roomID := portalIDToRoomID(msg.Portal.ID) reason := "" if msg.Content != nil { reason = msg.Content.Reason } - _, err := r.matrix().RedactEvent(ctx, roomID, messageIDToEventID(msg.TargetMessage.ID), mautrix.ReqRedact{Reason: reason}) + _, err = r.matrix().RedactEvent(ctx, roomID, target, mautrix.ReqRedact{Reason: reason, TxnID: txn}) return err } @@ -84,33 +97,55 @@ func (r *RedditClient) PreHandleMatrixReaction(ctx context.Context, msg *bridgev if msg.Content != nil && msg.Content.RelatesTo.Key != "" { emoji = msg.Content.RelatesTo.Key } + key, err := r.resolveOutgoingReaction(ctx, emoji) + if err != nil { + return bridgev2.MatrixReactionPreResponse{}, err + } return bridgev2.MatrixReactionPreResponse{ - SenderID: r.userID, - EmojiID: networkid.EmojiID(emoji), - Emoji: emoji, - MaxReactions: 1, + SenderID: r.userID, + EmojiID: networkid.EmojiID(key), + Emoji: emoji, }, nil } func (r *RedditClient) HandleMatrixReaction(ctx context.Context, msg *bridgev2.MatrixReaction) (*database.Reaction, error) { - if msg.TargetMessage == nil { - return nil, errors.New("no reaction target") + txn, err := r.outgoingTransaction(msg.Portal, msg.Event, msg.InputTransactionID) + if err != nil { + return nil, err + } + target, err := mappedTarget(msg.Portal, msg.TargetMessage) + if err != nil { + return nil, err + } + if msg.PreHandleResp == nil || msg.PreHandleResp.EmojiID == "" { + return nil, errors.New("reaction has no emoji") } roomID := portalIDToRoomID(msg.Portal.ID) emoji := string(msg.PreHandleResp.EmojiID) - _, err := r.matrix().SendReaction(ctx, roomID, messageIDToEventID(msg.TargetMessage.ID), emoji) + resp, err := r.matrix().SendMessageEvent(ctx, roomID, event.EventReaction, &event.ReactionEventContent{RelatesTo: event.RelatesTo{Type: event.RelAnnotation, EventID: target, Key: emoji}}, mautrix.ReqSendEvent{TransactionID: txn}) if err != nil { return nil, err } - return &database.Reaction{}, nil + if resp.EventID == "" { + return nil, errors.New("reddit returned no reaction identity") + } + return &database.Reaction{Metadata: &ReactionMetadata{RemoteEventID: resp.EventID}}, nil } func (r *RedditClient) HandleMatrixReactionRemove(ctx context.Context, msg *bridgev2.MatrixReactionRemove) error { - if msg.TargetReaction == nil { + txn, err := r.outgoingTransaction(msg.Portal, msg.Event, msg.InputTransactionID) + if err != nil { + return err + } + if msg.TargetReaction == nil || msg.TargetReaction.Room != msg.Portal.PortalKey { return errors.New("no reaction to remove") } + meta, ok := msg.TargetReaction.Metadata.(*ReactionMetadata) + if !ok || meta.RemoteEventID == "" { + return errors.New("reaction has no Reddit event mapping") + } roomID := portalIDToRoomID(msg.Portal.ID) - _, err := r.matrix().RedactEvent(ctx, roomID, id.EventID(msg.TargetReaction.MXID), mautrix.ReqRedact{}) + _, err = r.matrix().RedactEvent(ctx, roomID, meta.RemoteEventID, mautrix.ReqRedact{TxnID: txn}) return err } @@ -119,15 +154,23 @@ func (r *RedditClient) HandleMatrixReadReceipt(ctx context.Context, msg *bridgev if msg.ExactMessage == nil { return nil } - return r.rc.MarkRead(ctx, roomID, messageIDToEventID(msg.ExactMessage.ID)) + return r.remote().MarkRead(ctx, roomID, messageIDToEventID(msg.ExactMessage.ID)) } func (r *RedditClient) HandleMatrixTyping(ctx context.Context, msg *bridgev2.MatrixTyping) error { + // Reddit requires joined membership for typing. Composing a reply must + // not accept the request; bridgev2 clears this flag after explicit acceptance. + if msg.Portal.MessageRequest { + return nil + } roomID := portalIDToRoomID(msg.Portal.ID) - return r.rc.SetTyping(ctx, roomID, msg.IsTyping, 30*time.Second) + return r.remote().SetTyping(ctx, roomID, msg.IsTyping, 30*time.Second) } func (r *RedditClient) HandleMatrixRoomName(ctx context.Context, msg *bridgev2.MatrixRoomName) (bool, error) { + if msg.Portal.RoomType == database.RoomTypeDM { + return false, bridgev2.ErrRoomMetadataNotAllowed + } roomID := portalIDToRoomID(msg.Portal.ID) if msg.Content == nil { return false, nil diff --git a/pkg/connector/login.go b/pkg/connector/login.go index e1fc67d..56beeac 100644 --- a/pkg/connector/login.go +++ b/pkg/connector/login.go @@ -8,7 +8,6 @@ import ( "net/http" "net/url" "strings" - "sync" "maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2/database" @@ -38,7 +37,7 @@ func (rc *RedditConnector) GetLoginFlows() []bridgev2.LoginFlow { return []bridgev2.LoginFlow{ { Name: "Username & password", - Description: "Log in with your Reddit account. CAPTCHA solving uses an embedded webview.", + Description: "Log in with your Reddit account. Verification runs in the background.", ID: FlowIDPassword, }, } @@ -46,49 +45,21 @@ func (rc *RedditConnector) GetLoginFlows() []bridgev2.LoginFlow { func (rc *RedditConnector) CreateLogin(ctx context.Context, user *bridgev2.User, flowID string) (bridgev2.LoginProcess, error) { if flowID != FlowIDPassword { - return nil, fmt.Errorf("unknown login flow ID: %s", flowID) + return nil, bridgev2.ErrInvalidLoginFlowID } - return &PasswordLogin{main: rc, user: user}, nil + loginCtx, cancel := context.WithCancel(context.Background()) + return &PasswordLogin{user: user, ctx: loginCtx, cancel: cancel, step: StepIDEnterCredentials}, nil } -// PasswordLogin drives the multi-step Reddit login flow. Reddit needs: -// -// 1. username + password (entered in the bridge UI) -// 2. a reCAPTCHA Enterprise token for the password POST (solved in a webview) -// 3. optionally an OTP code (entered in the bridge UI) -// 4. optionally a second reCAPTCHA token for the OTP POST (solved in a webview) -// -// The redditchat library's LoginReddit takes a CaptchaTokenProvider callback -// that blocks until a token is returned. We run LoginReddit on a goroutine and -// pump CaptchaRequest values out / token strings in via channels, advancing the -// bridgev2 LoginStep state machine between each request. +// PasswordLogin maps Reddit's password, verification and OTP operations directly +// onto bridgev2 login steps. No native worker runs between steps. type PasswordLogin struct { - main *RedditConnector - user *bridgev2.User - - username string - password string - - // loginDone signals the LoginReddit goroutine has finished. Its result is - // stored in loginResult / loginErr. - loginDone chan struct{} - loginResult *redditchat.RedditLoginResult - loginErr error - - // captchaReq carries CaptchaRequest values from the library goroutine to - // the SubmitUserInput / SubmitCookies handler. captchaResp returns the - // solved token back to the library goroutine. - captchaReq chan redditchat.CaptchaRequest - captchaResp chan captchaResponse - - cancelOnce sync.Once - cancelFn func() -} - -type captchaResponse struct { - token string - clientVersion string - err error + user *bridgev2.User + ctx context.Context + cancel context.CancelFunc + step string + session *redditchat.RedditSession + username, password, otp string } var ( @@ -97,22 +68,23 @@ var ( _ bridgev2.LoginProcessCookies = (*PasswordLogin)(nil) ) -func (p *PasswordLogin) Cancel() { - p.cancelOnce.Do(func() { - if p.cancelFn != nil { - p.cancelFn() - } - // Unblock anyone waiting on a response so the goroutine can exit. - if p.captchaResp != nil { - select { - case p.captchaResp <- captchaResponse{err: errors.New("login cancelled")}: - default: - } - } - }) +func (p *PasswordLogin) Cancel() { p.cancel() } + +// Keep process cancellation effective even if it races with a provisioning +// worker starting a step. The supplied context also cancels that step's IO. +func (p *PasswordLogin) stepContext(ctx context.Context) (context.Context, func()) { + ctx, cancel := context.WithCancel(ctx) + stop := context.AfterFunc(p.ctx, cancel) + if p.ctx.Err() != nil { + cancel() + } + return ctx, func() { stop(); cancel() } } func (p *PasswordLogin) Start(ctx context.Context) (*bridgev2.LoginStep, error) { + if err := p.ctx.Err(); err != nil { + return nil, err + } return &bridgev2.LoginStep{ Type: bridgev2.LoginStepTypeUserInput, StepID: StepIDEnterCredentials, @@ -134,75 +106,68 @@ func (p *PasswordLogin) Start(ctx context.Context) (*bridgev2.LoginStep, error) }, nil } -// SubmitUserInput handles both the initial credentials step and the OTP step. -// The state machine distinguishes them by inspecting which channels are live. func (p *PasswordLogin) SubmitUserInput(ctx context.Context, input map[string]string) (*bridgev2.LoginStep, error) { - // First call: collect credentials and kick off the login goroutine. - if p.loginDone == nil { - username := strings.TrimSpace(input[FieldUsername]) - password := input[FieldPassword] - if username == "" || password == "" { - return nil, errors.New("username and password are required") - } - p.username = username - p.password = password - p.captchaReq = make(chan redditchat.CaptchaRequest, 1) - p.captchaResp = make(chan captchaResponse, 1) - p.loginDone = make(chan struct{}) - - loginCtx, cancel := context.WithCancel(context.Background()) - p.cancelFn = cancel - go p.runLogin(loginCtx) - - return p.awaitNextStep(ctx) + ctx, done := p.stepContext(ctx) + defer done() + if err := ctx.Err(); err != nil { + return nil, err } - - // Second call: OTP code from the user. - otp := strings.TrimSpace(input[FieldOTPCode]) - if otp == "" { - return nil, errors.New("OTP code is required") + switch p.step { + case StepIDEnterCredentials: + p.username, p.password = strings.TrimSpace(input[FieldUsername]), input[FieldPassword] + if p.username == "" || p.password == "" { + return nil, ErrLoginMissingCredentials + } + var err error + p.session, err = redditchat.NewRedditSession(nil, "", "") + if err == nil { + err = p.session.PrepareLogin(ctx, p.username) + } + if err != nil { + return nil, wrapRedditLoginError(err) + } + return p.buildCaptchaStep(p.session.CaptchaRequest(redditchat.CaptchaStepPassword)), nil + case StepIDEnterOTP: + p.otp = strings.TrimSpace(input[FieldOTPCode]) + if len(p.otp) != 6 || strings.Trim(p.otp, "0123456789") != "" { + return nil, ErrLoginMissingOTP + } + return p.buildCaptchaStep(p.session.CaptchaRequest(redditchat.CaptchaStepOTP)), nil + default: + return nil, errors.New("reddit login is not waiting for user input") } - // The redditchat library reads the OTP from RedditLoginOptions.TOTPCode, - // not via a callback. So we restart the login flow with the OTP populated; - // this re-runs the password POST (with a fresh CAPTCHA) and then the OTP - // POST (with a second CAPTCHA). That's the shape Reddit expects anyway. - return p.restartWithOTP(ctx, otp) } func (p *PasswordLogin) SubmitCookies(ctx context.Context, cookies map[string]string) (*bridgev2.LoginStep, error) { - token := strings.TrimSpace(cookies[FieldRecaptchaToken]) - if token == "" { - return nil, errors.New("missing reCAPTCHA token") + ctx, done := p.stepContext(ctx) + defer done() + if err := ctx.Err(); err != nil { + return nil, err } - clientVersion := strings.TrimSpace(cookies[FieldClientVersion]) - // Send the token to the waiting login goroutine. - select { - case p.captchaResp <- captchaResponse{token: token, clientVersion: clientVersion}: - case <-ctx.Done(): - return nil, ctx.Err() + if p.step != StepIDCaptchaPassword && p.step != StepIDCaptchaOTP { + return nil, errors.New("reddit login is not waiting for verification") } - return p.awaitNextStep(ctx) -} - -// awaitNextStep blocks until the login goroutine either: -// - asks for another CAPTCHA token (return a Cookies step), or -// - asks for an OTP code (return a UserInput step), or -// - finishes (return a Complete step or error). -func (p *PasswordLogin) awaitNextStep(ctx context.Context) (*bridgev2.LoginStep, error) { - select { - case req := <-p.captchaReq: - return p.buildCaptchaStep(req), nil - case <-p.loginDone: - if p.loginErr != nil { - if errors.Is(p.loginErr, errLoginNeedsOTP) { - return p.buildOTPStep(), nil - } - return nil, p.loginErr + captcha := redditchat.CaptchaResult{ + Token: strings.TrimSpace(cookies[FieldRecaptchaToken]), + ClientVersion: strings.TrimSpace(cookies[FieldClientVersion]), + } + if captcha.Token == "" { + return nil, ErrLoginVerificationFailed + } + var err error + if p.step == StepIDCaptchaPassword { + var needsOTP bool + needsOTP, err = p.session.SubmitPassword(ctx, p.username, p.password, captcha) + if err == nil && needsOTP { + return p.buildOTPStep(), nil } - return p.completeStep(ctx) - case <-ctx.Done(): - return nil, ctx.Err() + } else { + err = p.session.SubmitOTP(ctx, p.username, p.password, p.otp, captcha) + } + if err != nil { + return nil, wrapRedditLoginError(err) } + return p.completeStep(ctx) } func (p *PasswordLogin) buildCaptchaStep(req redditchat.CaptchaRequest) *bridgev2.LoginStep { @@ -210,6 +175,7 @@ func (p *PasswordLogin) buildCaptchaStep(req redditchat.CaptchaRequest) *bridgev if req.Step == redditchat.CaptchaStepOTP { stepID = StepIDCaptchaOTP } + p.step = stepID // Wrap the redditchat-provided JS so the Promise it returns resolves to // {recaptcha_token: "..."} — the shape expected by LoginCookiesParams. js := fmt.Sprintf(`(async () => { const token = await %s; return { %q: token }; })()`, @@ -217,10 +183,11 @@ func (p *PasswordLogin) buildCaptchaStep(req redditchat.CaptchaRequest) *bridgev return &bridgev2.LoginStep{ Type: bridgev2.LoginStepTypeCookies, StepID: stepID, - Instructions: "Solve the Reddit CAPTCHA in the webview.", + Instructions: "Verifying with Reddit…", CookiesParams: &bridgev2.LoginCookiesParams{ URL: req.PageURL, UserAgent: redditchat.DefaultRedditUserAgent, + Hidden: true, Fields: []bridgev2.LoginCookieField{ { ID: FieldRecaptchaToken, @@ -232,7 +199,7 @@ func (p *PasswordLogin) buildCaptchaStep(req redditchat.CaptchaRequest) *bridgev { // Sniff the real X-Reddit-Client-Version off the shreddit // page's own /svc/ requests. Optional: when the client can't - // capture it, redditchat falls back to RedditClientVersion. + // capture it, redditchat uses its default client version. ID: FieldClientVersion, Required: false, Sources: []bridgev2.LoginCookieFieldSource{ @@ -250,6 +217,7 @@ func (p *PasswordLogin) buildCaptchaStep(req redditchat.CaptchaRequest) *bridgev } func (p *PasswordLogin) buildOTPStep() *bridgev2.LoginStep { + p.step = StepIDEnterOTP return &bridgev2.LoginStep{ Type: bridgev2.LoginStepTypeUserInput, StepID: StepIDEnterOTP, @@ -267,105 +235,26 @@ func (p *PasswordLogin) buildOTPStep() *bridgev2.LoginStep { } } -// runLogin executes the redditchat login flow on a goroutine. The -// CaptchaTokenProvider blocks until SubmitCookies pumps a token in. -func (p *PasswordLogin) runLogin(ctx context.Context) { - provider := func(ctx context.Context, req redditchat.CaptchaRequest) (redditchat.CaptchaResult, error) { - select { - case p.captchaReq <- req: - case <-ctx.Done(): - return redditchat.CaptchaResult{}, ctx.Err() - } - select { - case resp := <-p.captchaResp: - if resp.err != nil { - return redditchat.CaptchaResult{}, resp.err - } - return redditchat.CaptchaResult{Token: resp.token, ClientVersion: resp.clientVersion}, nil - case <-ctx.Done(): - return redditchat.CaptchaResult{}, ctx.Err() - } +func (p *PasswordLogin) completeStep(ctx context.Context) (*bridgev2.LoginStep, error) { + if err := p.ctx.Err(); err != nil { + return nil, err } - - opts := redditchat.RedditLoginOptions{ - Username: p.username, - Password: p.password, - CaptchaTokenProvider: provider, + defer func() { p.password, p.otp = "", "" }() + token, creds, err := redditchat.CredentialsFromRedditSession(ctx, p.session) + if err != nil { + return nil, wrapRedditLoginError(err) } - result, err := redditchat.LoginReddit(ctx, opts) - - // Detect 2FA-required responses. Reddit returns 202 from the password POST - // when 2FA is needed; redditchat.submitPassword then asks for a second - // captcha token and tries to read the OTP. With TOTPCode unset, it fails - // with "otp required". We surface that as errLoginNeedsOTP so the caller - // restarts with the OTP filled in. - if err != nil && strings.Contains(err.Error(), "otp required") { - err = errLoginNeedsOTP + if err = ctx.Err(); err != nil { + return nil, err } - - p.loginResult = result - p.loginErr = err - close(p.loginDone) -} - -// restartWithOTP restarts the login flow with the OTP code populated. The -// previous goroutine has already exited (with errLoginNeedsOTP) so we can spin -// up a fresh one with the same username/password plus TOTPCode. This -// re-triggers the password CAPTCHA and then the OTP CAPTCHA, but that's the -// shape Reddit's flow expects anyway. -func (p *PasswordLogin) restartWithOTP(ctx context.Context, otp string) (*bridgev2.LoginStep, error) { - p.captchaReq = make(chan redditchat.CaptchaRequest, 1) - p.captchaResp = make(chan captchaResponse, 1) - p.loginDone = make(chan struct{}) - - loginCtx, cancel := context.WithCancel(context.Background()) - p.cancelFn = cancel - - go func() { - provider := func(ctx context.Context, req redditchat.CaptchaRequest) (redditchat.CaptchaResult, error) { - select { - case p.captchaReq <- req: - case <-ctx.Done(): - return redditchat.CaptchaResult{}, ctx.Err() - } - select { - case resp := <-p.captchaResp: - if resp.err != nil { - return redditchat.CaptchaResult{}, resp.err - } - return redditchat.CaptchaResult{Token: resp.token, ClientVersion: resp.clientVersion}, nil - case <-ctx.Done(): - return redditchat.CaptchaResult{}, ctx.Err() - } - } - opts := redditchat.RedditLoginOptions{ - Username: p.username, - Password: p.password, - TOTPCode: otp, - CaptchaTokenProvider: provider, - } - result, err := redditchat.LoginReddit(loginCtx, opts) - p.loginResult = result - p.loginErr = err - close(p.loginDone) - }() - - return p.awaitNextStep(ctx) -} - -func (p *PasswordLogin) completeStep(ctx context.Context) (*bridgev2.LoginStep, error) { - if p.loginResult == nil { - return nil, errors.New("login finished without a result") - } - creds := p.loginResult.Credentials - cookiesJSON, err := serializeCookies(p.loginResult.Session) + cookiesJSON, err := serializeCookies(p.session) if err != nil { return nil, fmt.Errorf("serialize cookies: %w", err) } meta := &UserLoginMetadata{ Credentials: creds, CookiesJSON: cookiesJSON, - ChatTokenExpiry: p.loginResult.Token.Expires, + ChatTokenExpiry: token.Expires, Username: p.username, } loginID := makeUserLoginID(id.UserID(creds.UserID)) @@ -381,6 +270,7 @@ func (p *PasswordLogin) completeStep(ctx context.Context) (*bridgev2.LoginStep, if err != nil { return nil, fmt.Errorf("save user login: %w", err) } + p.step = StepIDComplete go ul.Client.Connect(ul.Log.WithContext(context.Background())) return &bridgev2.LoginStep{ Type: bridgev2.LoginStepTypeComplete, @@ -390,11 +280,6 @@ func (p *PasswordLogin) completeStep(ctx context.Context) (*bridgev2.LoginStep, }, nil } -// errLoginNeedsOTP is an internal sentinel returned by runLogin when the -// initial password POST succeeds with 202 (2FA required). The caller catches -// it and prompts the user for an OTP code via buildOTPStep. -var errLoginNeedsOTP = errors.New("reddit login: needs OTP") - func serializeCookies(session *redditchat.RedditSession) (string, error) { if session == nil || session.Client == nil || session.Client.Jar == nil { return "", nil diff --git a/pkg/connector/loginerrors.go b/pkg/connector/loginerrors.go new file mode 100644 index 0000000..3b9a6d9 --- /dev/null +++ b/pkg/connector/loginerrors.go @@ -0,0 +1,80 @@ +package connector + +import ( + "errors" + "fmt" + "net/http" + + "maunium.net/go/mautrix/bridgev2" + + "github.com/beeper/reddit/pkg/redditchat" +) + +var ( + ErrLoginMissingCredentials = bridgev2.RespError{ + ErrCode: "FI.MAU.REDDIT.MISSING_CREDENTIALS", + Err: "Username and password are required.", + StatusCode: http.StatusBadRequest, + } + ErrLoginMissingOTP = bridgev2.RespError{ + ErrCode: "FI.MAU.REDDIT.MISSING_OTP", + Err: "Enter the 6-digit code from your authenticator app.", + StatusCode: http.StatusBadRequest, + } + ErrLoginVerificationFailed = bridgev2.RespError{ + ErrCode: "FI.MAU.REDDIT.VERIFICATION_FAILED", + Err: "Reddit verification did not complete. Please try again.", + StatusCode: http.StatusBadRequest, + } + ErrLoginInvalidCredentials = bridgev2.RespError{ + ErrCode: "FI.MAU.REDDIT.INVALID_CREDENTIALS", + Err: "Reddit rejected that username or password. Please check them and try again.", + StatusCode: http.StatusUnauthorized, + } + ErrLoginInvalidOTP = bridgev2.RespError{ + ErrCode: "FI.MAU.REDDIT.INVALID_OTP", + Err: "That two-factor code wasn't accepted. Please try again.", + StatusCode: http.StatusUnauthorized, + } + ErrLoginSSORequired = bridgev2.RespError{ + ErrCode: "FI.MAU.REDDIT.SSO_REQUIRED", + Err: "This Reddit account signs in with Google or Apple, which this bridge can't use. Set a Reddit password on reddit.com, then try again.", + StatusCode: http.StatusBadRequest, + } + ErrLoginVerificationBlocked = bridgev2.RespError{ + ErrCode: "FI.MAU.REDDIT.VERIFICATION_BLOCKED", + Err: "Reddit blocked the sign-in with a verification check. Please wait a few minutes and try again.", + StatusCode: http.StatusForbidden, + } + ErrLoginRejected = bridgev2.RespError{ + ErrCode: "FI.MAU.REDDIT.LOGIN_REJECTED", + StatusCode: http.StatusBadRequest, + } + ErrLoginUnknown = bridgev2.RespError{ + ErrCode: "M_UNKNOWN", + Err: "Internal error logging in to Reddit", + StatusCode: http.StatusInternalServerError, + } +) + +// The original error stays in the chain for logs; only the mapped RespError is +// written to the client. +func wrapRedditLoginError(err error) error { + var mapped bridgev2.RespError + var statusErr *redditchat.LoginHTTPError + switch { + case errors.Is(err, redditchat.ErrInvalidCredentials): + mapped = ErrLoginInvalidCredentials + case errors.Is(err, redditchat.ErrInvalidOTP): + mapped = ErrLoginInvalidOTP + case errors.Is(err, redditchat.ErrSSORequired): + mapped = ErrLoginSSORequired + case errors.Is(err, redditchat.ErrBrowserVerificationBlocked): + mapped = ErrLoginVerificationBlocked + case errors.As(err, &statusErr): + mapped = ErrLoginRejected.WithMessage("Reddit rejected the %s request (HTTP %d).", statusErr.Operation, statusErr.StatusCode) + default: + mapped = ErrLoginUnknown + } + return fmt.Errorf("%w: %w", mapped, err) +} diff --git a/pkg/connector/loginerrors_test.go b/pkg/connector/loginerrors_test.go new file mode 100644 index 0000000..f956521 --- /dev/null +++ b/pkg/connector/loginerrors_test.go @@ -0,0 +1,39 @@ +package connector + +import ( + "errors" + "fmt" + "net/http" + "testing" + + "maunium.net/go/mautrix/bridgev2" + + "github.com/beeper/reddit/pkg/redditchat" +) + +func TestWrapRedditLoginErrorMapsToClientErrors(t *testing.T) { + statusErr := &redditchat.LoginHTTPError{Operation: "sign-in", StatusCode: http.StatusTooManyRequests} + for _, tc := range []struct { + name string + err error + want bridgev2.RespError + }{ + {"invalid credentials", fmt.Errorf("%w: %w", redditchat.ErrInvalidCredentials, statusErr), ErrLoginInvalidCredentials}, + {"invalid otp", redditchat.ErrInvalidOTP, ErrLoginInvalidOTP}, + {"sso", redditchat.ErrSSORequired, ErrLoginSSORequired}, + {"verification blocked", redditchat.ErrBrowserVerificationBlocked, ErrLoginVerificationBlocked}, + {"http status", statusErr, ErrLoginRejected}, + {"unknown", errors.New("dial tcp: connection refused"), ErrLoginUnknown}, + } { + t.Run(tc.name, func(t *testing.T) { + err := wrapRedditLoginError(tc.err) + var respErr bridgev2.RespError + if !errors.As(err, &respErr) || respErr.ErrCode != tc.want.ErrCode || respErr.StatusCode != tc.want.StatusCode { + t.Fatalf("got %+v, want %s", respErr, tc.want.ErrCode) + } + if !errors.Is(err, tc.err) { + t.Fatal("original error was dropped from the chain") + } + }) + } +} diff --git a/pkg/connector/media.go b/pkg/connector/media.go new file mode 100644 index 0000000..5407a8e --- /dev/null +++ b/pkg/connector/media.go @@ -0,0 +1,220 @@ +package connector + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "net/http" + "os" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +func imageMIME(data []byte) (string, error) { + mime := http.DetectContentType(data) + switch mime { + case "image/jpeg", "image/png", "image/gif", "image/webp": + return mime, nil + default: + return "", bridgev2.ErrUnsupportedMediaType + } +} + +func readMediaBounded(reader io.Reader, limit int64) ([]byte, error) { + data, err := io.ReadAll(io.LimitReader(reader, limit+1)) + if err != nil { + return nil, errors.New("failed to read attachment") + } + if int64(len(data)) > limit { + return nil, bridgev2.ErrMediaTooLarge + } + return data, nil +} + +// Do not expose network error strings: redirected media URLs may carry bearer +// capabilities. Keep cancellation and a bounded HTTP status for diagnosis. +func mediaError(ctx context.Context, operation string, err error) error { + if ctx.Err() != nil { + return ctx.Err() + } + safe := fmt.Errorf("%s failed", operation) + var httpErr mautrix.HTTPError + if errors.As(err, &httpErr) && httpErr.Response != nil { + safe = fmt.Errorf("%s failed (HTTP %d)", operation, httpErr.Response.StatusCode) + } + if isPermanentHTTPError(err) { + return unbridgeable(safe) + } + return safe +} + +func copyMediaInfo(content *event.MessageEventContent) { + if content.Info == nil { + content.Info = &event.FileInfo{} + } else { + copied := *content.Info + content.Info = &copied + } +} + +func (r *RedditClient) incomingMediaLimit() int64 { + if limit := r.main.mediaUploadLimit.Load(); limit > 0 { + return limit + } + return MaxFileSize +} + +func (r *RedditClient) downloadRedditMedia(ctx context.Context, uri id.ContentURIString, file *event.EncryptedFileInfo) ([]byte, error) { + if file != nil { + uri = file.URL + } + parsed, err := uri.Parse() + if err != nil || parsed.IsEmpty() || parsed.Homeserver != "reddit.com" { + return nil, unbridgeable(errors.New("attachment is not a Reddit media URI")) + } + resp, err := r.remote().DownloadMedia(ctx, parsed) + if err != nil { + if resp != nil && resp.Body != nil { + resp.Body.Close() + } + return nil, mediaError(ctx, "Reddit media download", err) + } + defer resp.Body.Close() + if resp.ContentLength > r.incomingMediaLimit() { + return nil, bridgev2.ErrMediaTooLarge + } + data, err := readMediaBounded(resp.Body, r.incomingMediaLimit()) + if err != nil { + return nil, err + } + if file != nil { + if err = file.DecryptInPlace(data); err != nil { + return nil, unbridgeable(errors.New("reddit attachment failed decryption or integrity verification")) + } + } + return data, nil +} + +func (r *RedditClient) receiveMedia(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI, content *event.MessageEventContent) error { + // Encrypted native files keep the verified reupload path: their keys must + // never be embedded in public media IDs. Fall back there for oversized IDs + // too, since the framework limits the length of signed content URIs. + if r.main.directMedia && content.File == nil && (content.Info == nil || content.Info.ThumbnailFile == nil) { + url, err := r.directMediaURI(ctx, content.URL) + var thumbnail id.ContentURIString + if err == nil && content.Info != nil && content.Info.ThumbnailURL != "" { + thumbnail, err = r.directMediaURI(ctx, content.Info.ThumbnailURL) + } + if err == nil { + content.URL = url + copyMediaInfo(content) + content.Info.ThumbnailURL = thumbnail + return nil + } + } + if intent == nil { + return errors.New("media conversion requires a Matrix sender") + } + data, err := r.downloadRedditMedia(ctx, content.URL, content.File) + if err != nil { + return err + } + mime, err := imageMIME(data) + if err != nil { + return err + } + url, file, err := intent.UploadMedia(ctx, portal.MXID, data, content.GetFileName(), mime) + if err != nil { + return mediaError(ctx, "Beeper media upload", err) + } + content.URL, content.File = url, file + copyMediaInfo(content) + content.Info.Size, content.Info.MimeType = len(data), mime + if content.Info.ThumbnailURL != "" || content.Info.ThumbnailFile != nil { + thumb, err := r.downloadRedditMedia(ctx, content.Info.ThumbnailURL, content.Info.ThumbnailFile) + if err != nil { + return err + } + thumbMIME, err := imageMIME(thumb) + if err != nil { + return err + } + content.Info.ThumbnailURL, content.Info.ThumbnailFile, err = intent.UploadMedia(ctx, portal.MXID, thumb, "thumbnail", thumbMIME) + if err != nil { + return mediaError(ctx, "Beeper thumbnail upload", err) + } + } + return nil +} + +func (r *RedditClient) uploadRedditMedia(ctx context.Context, intent bridgev2.MatrixAPI, uri id.ContentURIString, file *event.EncryptedFileInfo, filename string) (id.ContentURIString, int, string, error) { + config, err := r.remote().MediaConfig(ctx) + if err != nil { + return "", 0, "", mediaError(ctx, "Reddit media limits", err) + } + limit := config.UploadSize + // Fetch at most the provider's largest supported image allowance before + // sniffing MIME. Apply the exact MIME-specific limit before uploading. + for mime, size := range config.RedditUploadSize { + if _, supported := fileCaps[event.MsgImage].MimeTypes[mime]; supported && size > limit { + limit = size + } + } + if limit <= 0 { + return "", 0, "", errors.New("reddit did not provide a valid upload limit") + } + var data []byte + err = intent.DownloadMediaToFile(ctx, uri, file, false, func(f *os.File) error { + var readErr error + data, readErr = readMediaBounded(f, limit) + return readErr + }) + if err != nil { + if errors.Is(err, bridgev2.ErrMediaTooLarge) { + return "", 0, "", err + } + return "", 0, "", mediaError(ctx, "Beeper media download", err) + } + mime, err := imageMIME(data) + if err != nil { + return "", 0, "", err + } + limit = config.UploadSize + if override, ok := config.RedditUploadSize[mime]; ok { + limit = override + } + if int64(len(data)) > limit || limit <= 0 { + return "", 0, "", bridgev2.ErrMediaTooLarge + } + upload, _, err := r.remote().UploadMedia(ctx, filename, mime, bytes.NewReader(data)) + if err != nil { + return "", 0, "", mediaError(ctx, "Reddit media upload", err) + } + if upload.ContentURI.IsEmpty() || upload.ContentURI.Homeserver != "reddit.com" { + return "", 0, "", errors.New("reddit upload returned an invalid media identity") + } + return upload.ContentURI.CUString(), len(data), mime, nil +} + +func (r *RedditClient) sendMedia(ctx context.Context, content *event.MessageEventContent) error { + url, size, mime, err := r.uploadRedditMedia(ctx, r.main.Bridge.Bot, content.URL, content.File, content.GetFileName()) + if err != nil { + return err + } + content.URL, content.File = url, nil + copyMediaInfo(content) + content.Info.Size, content.Info.MimeType = size, mime + if content.Info.ThumbnailURL != "" || content.Info.ThumbnailFile != nil { + content.Info.ThumbnailURL, _, _, err = r.uploadRedditMedia(ctx, r.main.Bridge.Bot, content.Info.ThumbnailURL, content.Info.ThumbnailFile, "thumbnail") + if err != nil { + return err + } + content.Info.ThumbnailFile = nil + } + return nil +} diff --git a/pkg/connector/media_test.go b/pkg/connector/media_test.go new file mode 100644 index 0000000..362d532 --- /dev/null +++ b/pkg/connector/media_test.go @@ -0,0 +1,250 @@ +package connector + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "image" + "image/gif" + "image/png" + "io" + "net/http" + "os" + "strings" + "testing" + + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/crypto/attachment" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +func TestBeeperGIFUsesNativeImageWithoutChangingSource(t *testing.T) { + var encoded bytes.Buffer + if err := gif.Encode(&encoded, image.NewRGBA(image.Rect(0, 0, 2, 2)), nil); err != nil { + t.Fatal(err) + } + intent := &mediaTestIntent{download: encoded.Bytes()} + sent := false + r := testClient(t, func(w http.ResponseWriter, req *http.Request) { + switch req.URL.Path { + case "/_matrix/media/v3/config": + _, _ = io.WriteString(w, `{"m.upload.size":20971520}`) + case "/_matrix/media/v3/upload": + data, _ := io.ReadAll(req.Body) + if !bytes.Equal(data, encoded.Bytes()) || req.Header.Get("Content-Type") != "image/gif" { + t.Error("GIF bytes changed") + } + _, _ = io.WriteString(w, `{"content_uri":"mxc://reddit.com/gif"}`) + default: + if !strings.Contains(req.URL.Path, "/send/m.room.message/") { + t.Error("unexpected GIF request", req.URL.Path) + } + var content event.MessageEventContent + if err := json.NewDecoder(req.Body).Decode(&content); err != nil { + t.Error(err) + } + if content.MsgType != event.MsgImage || content.Info.MimeType != "image/gif" || content.Info.MauGIF || content.URL != "mxc://reddit.com/gif" { + t.Error("wrong native GIF shape", content.MsgType, content.Info) + } + sent = true + _, _ = io.WriteString(w, `{"event_id":"$gif"}`) + } + }) + r.main.Bridge.Bot = intent + content := &event.MessageEventContent{MsgType: event.MsgVideo, Body: "test.gif", URL: "mxc://beeper.example/gif", Info: &event.FileInfo{MimeType: "image/gif", MauGIF: true}} + response, err := r.HandleMatrixMessage(context.Background(), &bridgev2.MatrixMessage{MatrixEventBase: bridgev2.MatrixEventBase[*event.MessageEventContent]{Portal: historyPortal(), Event: &event.Event{ID: "$source"}, Content: content}}) + if err != nil || !sent || response.DB.ID != "$gif" { + t.Fatal("GIF send failed", err) + } + if content.MsgType != event.MsgVideo || !content.Info.MauGIF { + t.Fatal("SDK-owned input changed") + } + caps := r.GetCapabilities(context.Background(), nil) + if caps.File[event.CapMsgGIF].GetMimeSupport("image/gif") != event.CapLevelFullySupported || caps.File[event.CapMsgGIF].GetMimeSupport("video/mp4") != event.CapLevelRejected { + t.Fatal("GIF capability differs from native support") + } +} + +type mediaTestIntent struct { + bridgev2.MatrixAPI + uploaded [][]byte + downloadedURI id.ContentURIString + encryptedDownload bool + download []byte +} + +func (m *mediaTestIntent) UploadMedia(ctx context.Context, roomID id.RoomID, data []byte, filename, mime string) (id.ContentURIString, *event.EncryptedFileInfo, error) { + m.uploaded = append(m.uploaded, append([]byte(nil), data...)) + return "", &event.EncryptedFileInfo{URL: "mxc://beeper.example/encrypted", EncryptedFile: *attachment.NewEncryptedFile()}, nil +} + +func (m *mediaTestIntent) DownloadMediaToFile(ctx context.Context, uri id.ContentURIString, file *event.EncryptedFileInfo, writable bool, callback func(*os.File) error) error { + m.downloadedURI = uri + data := append([]byte(nil), m.download...) + if file != nil { + m.encryptedDownload = true + m.downloadedURI = file.URL + if err := file.DecryptInPlace(data); err != nil { + return err + } + } + f, err := os.CreateTemp("", "reddit-media-test-*") + if err != nil { + return err + } + defer os.Remove(f.Name()) + defer f.Close() + if _, err = f.Write(data); err != nil { + return err + } + if _, err = f.Seek(0, io.SeekStart); err != nil { + return err + } + return callback(f) +} + +func testPNG(t *testing.T) []byte { + t.Helper() + var data bytes.Buffer + if err := png.Encode(&data, image.NewRGBA(image.Rect(0, 0, 1, 1))); err != nil { + t.Fatal(err) + } + return data.Bytes() +} + +func serializeMediaKey(t *testing.T, key *attachment.EncryptedFile) attachment.EncryptedFile { + t.Helper() + data, err := json.Marshal(key) + if err != nil { + t.Fatal(err) + } + var restored attachment.EncryptedFile + if err = json.Unmarshal(data, &restored); err != nil { + t.Fatal(err) + } + return restored +} + +func TestIncomingMediaReuploadsBodyAndThumbnailForEncryptedRoom(t *testing.T) { + plain := testPNG(t) + key := attachment.NewEncryptedFile() + encrypted := bytes.Clone(plain) + key.EncryptInPlace(encrypted) + requests := 0 + r := testClient(t, func(w http.ResponseWriter, req *http.Request) { + requests++ + if !strings.HasPrefix(req.URL.Path, "/_matrix/media/v3/download/reddit.com/") || req.Header.Get("Authorization") != "Bearer test-token" { + t.Error("download did not use the authenticated provider client") + } + if strings.HasSuffix(req.URL.Path, "/image") { + _, _ = w.Write(encrypted) + } else { + _, _ = w.Write(plain) + } + }) + intent := &mediaTestIntent{} + r.main.SetUseDirectMedia() // Native encryption must still use verified reupload. + source := &event.MessageEventContent{MsgType: event.MsgImage, Body: "caption", FileName: "test.png", File: &event.EncryptedFileInfo{URL: "mxc://reddit.com/image", EncryptedFile: serializeMediaKey(t, key)}, Info: &event.FileInfo{ThumbnailURL: "mxc://reddit.com/thumb", MimeType: "image/wrong"}} + converted, err := r.convertMessage(context.Background(), historyPortal(), intent, &event.Event{Content: event.Content{Parsed: source}}) + if err != nil { + t.Fatal(err) + } + content := converted.Parts[0].Content + if requests != 2 || len(intent.uploaded) != 2 || !bytes.Equal(intent.uploaded[0], plain) || !bytes.Equal(intent.uploaded[1], plain) { + t.Fatal("attachment bytes were lost or not decrypted") + } + if content.URL != "" || content.File.URL != "mxc://beeper.example/encrypted" || content.Info.ThumbnailURL != "" || content.Info.ThumbnailFile.URL != "mxc://beeper.example/encrypted" || content.Body != "caption" || content.Info.MimeType != "image/png" { + t.Fatal("media references or caption were not correctly replaced") + } + if source.File.URL != "mxc://reddit.com/image" || source.Info.ThumbnailURL != "mxc://reddit.com/thumb" { + t.Fatal("source media mutated") + } +} + +func TestOutgoingEncryptedImageTransfersBytesBeforeSend(t *testing.T) { + plain := testPNG(t) + key := attachment.NewEncryptedFile() + intent := &mediaTestIntent{download: encryptedCopy(key, plain)} + uploaded := false + r := testClient(t, func(w http.ResponseWriter, req *http.Request) { + switch req.URL.Path { + case "/_matrix/media/v3/config": + _, _ = io.WriteString(w, `{"m.upload.size":20971520,"com.reddit.upload.size":{"image/gif":104857600}}`) + case "/_matrix/media/v3/upload": + data, _ := io.ReadAll(req.Body) + if !bytes.Equal(data, plain) || req.Header.Get("Content-Type") != "image/png" { + t.Error("uploaded incorrect bytes or MIME") + } + uploaded = true + _, _ = io.WriteString(w, `{"content_uri":"mxc://reddit.com/uploaded"}`) + default: + t.Errorf("unexpected media request: %s", req.URL.Path) + } + }) + r.main.Bridge.Bot = intent + content := &event.MessageEventContent{MsgType: event.MsgImage, Body: "caption", FileName: "photo.png", File: &event.EncryptedFileInfo{URL: "mxc://beeper.example/source", EncryptedFile: serializeMediaKey(t, key)}} + if err := r.sendMedia(context.Background(), content); err != nil { + t.Fatal(err) + } + if !uploaded || !intent.encryptedDownload || content.File != nil || content.URL != "mxc://reddit.com/uploaded" || content.Info.Size != len(plain) || content.Body != "caption" { + t.Fatal("outgoing media was not transferred") + } +} + +func TestMediaRejectsOversizedForeignAndNonImageAttachments(t *testing.T) { + r := testClient(t, func(w http.ResponseWriter, req *http.Request) { + w.(http.Flusher).Flush() // Unknown length: enforce the bound while reading. + _, _ = w.Write([]byte("too large")) + }) + r.main.SetMaxFileSize(3) + if _, err := r.downloadRedditMedia(context.Background(), "mxc://reddit.com/image", nil); !errors.Is(err, bridgev2.ErrMediaTooLarge) { + t.Fatalf("oversized download: %v", err) + } + if _, err := r.downloadRedditMedia(context.Background(), "mxc://foreign.example/image", nil); err == nil { + t.Fatal("foreign media accepted") + } + if _, err := imageMIME([]byte("not an image")); !errors.Is(err, bridgev2.ErrUnsupportedMediaType) { + t.Fatal("non-image accepted") + } +} + +func TestProviderUploadRejectionCannotSendBrokenImageEvent(t *testing.T) { + intent := &mediaTestIntent{download: testPNG(t)} + r := testClient(t, func(w http.ResponseWriter, req *http.Request) { + switch req.URL.Path { + case "/_matrix/media/v3/config": + _, _ = io.WriteString(w, `{"m.upload.size":20971520}`) + case "/_matrix/media/v3/upload": + w.WriteHeader(403) + _, _ = io.WriteString(w, `{"errcode":"M_FORBIDDEN","error":"Media upload forbidden"}`) + default: + t.Error("message was sent after upload failed") + } + }) + r.main.Bridge.Bot = intent + msg := outgoingTestMessage() + msg.Content = &event.MessageEventContent{MsgType: event.MsgImage, Body: "image.png", URL: "mxc://beeper.example/source"} + if resp, err := r.HandleMatrixMessage(context.Background(), msg); err == nil || resp != nil || !strings.Contains(err.Error(), "HTTP 403") { + t.Fatalf("upload rejection was not surfaced: %v", err) + } +} + +func TestMediaErrorsDoNotExposeCapabilityURLs(t *testing.T) { + err := mediaError(context.Background(), "media transfer", errors.New("Get https://storage.example/private?token=secret: failure")) + if strings.Contains(err.Error(), "secret") || strings.Contains(err.Error(), "storage.example") { + t.Fatal("capability leaked through error") + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if !errors.Is(mediaError(ctx, "download", errors.New("failed")), context.Canceled) { + t.Fatal("cancellation was hidden") + } +} + +func encryptedCopy(key *attachment.EncryptedFile, plain []byte) []byte { + encrypted := bytes.Clone(plain) + key.EncryptInPlace(encrypted) + return encrypted +} diff --git a/pkg/connector/membership.go b/pkg/connector/membership.go new file mode 100644 index 0000000..324a6d8 --- /dev/null +++ b/pkg/connector/membership.go @@ -0,0 +1,71 @@ +package connector + +import ( + "context" + "errors" + "strings" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/networkid" + "maunium.net/go/mautrix/event" +) + +var _ bridgev2.MembershipHandlingNetworkAPI = (*RedditClient)(nil) + +func (r *RedditClient) HandleMatrixMembership(ctx context.Context, msg *bridgev2.MatrixMembershipChange) (*bridgev2.MatrixMembershipResult, error) { + if msg == nil || msg.Portal == nil || msg.Portal.ID == "" { + return nil, errors.New("membership change has no portal identity") + } + if msg.Type.IsSelf && msg.OrigSender != nil { + return nil, bridgev2.ErrMembershipNotSupported + } + if msg.Portal.Receiver != "" && msg.Portal.Receiver != r.userLogin.ID { + return nil, errors.New("chat belongs to another login") + } + if !r.IsLoggedIn() { + return nil, bridgev2.ErrNotLoggedIn + } + switch msg.Type { + case bridgev2.ProfileChange: + return nil, nil + case bridgev2.Leave, bridgev2.RejectInvite: + // This also preserves Reddit's hide-instead-of-leave semantics for DMs. + return nil, r.HandleMatrixDeleteChat(ctx, &bridgev2.MatrixDeleteChat{ + Portal: msg.Portal, Content: &event.BeeperChatDeleteEventContent{}, + }) + case bridgev2.AcceptInvite: + return nil, r.HandleMatrixAcceptMessageRequest(ctx, &bridgev2.MatrixAcceptMessageRequest{Portal: msg.Portal}) + case bridgev2.Invite, bridgev2.Kick, bridgev2.RevokeInvite: + default: + return nil, bridgev2.ErrMembershipNotSupported + } + roomID := portalIDToRoomID(msg.Portal.ID) + state, key, err := r.resolveRoomState(ctx, roomID, &mautrix.SyncJoinedRoom{}, &roomSnapshots{}) + if err != nil { + return nil, err + } + if key != msg.Portal.PortalKey || state.Type != "private_group" { + return nil, bridgev2.ErrMembershipNotSupported + } + var targetID networkid.UserID + switch target := msg.Target.(type) { + case *bridgev2.Ghost: + targetID = target.ID + case *bridgev2.UserLogin: + targetID = networkid.UserID(target.ID) + } + if !strings.HasPrefix(string(targetID), "t2_") || strings.Trim(string(targetID)[3:], "0123456789abcdefghijklmnopqrstuvwxyz") != "" || len(targetID) <= 3 { + return nil, errors.New("membership target is not a Reddit account ID") + } + if targetID == r.userID { + return nil, errors.New("use accept or leave for your own membership") + } + targetMXID := userIDToMatrix(targetID, "") + if msg.Type == bridgev2.Invite { + err = r.remote().Invite(ctx, roomID, targetMXID) + } else { + _, err = r.matrix().KickUser(ctx, roomID, &mautrix.ReqKickUser{UserID: targetMXID}) + } + return nil, err +} diff --git a/pkg/connector/outgoing.go b/pkg/connector/outgoing.go new file mode 100644 index 0000000..a914ef9 --- /dev/null +++ b/pkg/connector/outgoing.go @@ -0,0 +1,87 @@ +package connector + +import ( + "errors" + + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/database" + "maunium.net/go/mautrix/bridgev2/networkid" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +// Local bridges supply the network transaction ID through bridgev2. For an +// appservice event, the Matrix event ID is the stable native request key. +func (r *RedditClient) outgoingTransaction(portal *bridgev2.Portal, evt *event.Event, input networkid.RawTransactionID) (string, error) { + if portal == nil || portal.ID == "" || evt == nil || evt.ID == "" { + return "", errors.New("outgoing event has no portal or event identity") + } + if portal.Receiver != "" && portal.Receiver != r.userLogin.ID { + return "", errors.New("outgoing portal belongs to another login") + } + if input != "" { + return string(input), nil + } + return string(evt.ID), nil +} + +func mappedTarget(portal *bridgev2.Portal, target *database.Message) (id.EventID, error) { + if target == nil || target.ID == "" { + return "", errors.New("message target has no Reddit mapping") + } + if target.Room != portal.PortalKey { + return "", errors.New("message target belongs to another portal") + } + return messageIDToEventID(target.ID), nil +} + +func outgoingContent(msg *bridgev2.MatrixMessage) (*event.MessageEventContent, error) { + if msg.Content == nil { + return nil, errors.New("missing message content") + } + // Copy the content before replacing relations; the SDK still owns the source. + content := *msg.Content + // Reddit's web chat displays plain text. Forwarding Matrix HTML makes the + // native bubble blank, so retain the plain body as the visible fallback. + content.Format = "" + content.FormattedBody = "" + content.RelatesTo = nil + content.NewContent = nil + if msg.Content.RelatesTo.GetReplaceID() != "" { + return nil, bridgev2.ErrEditsNotSupported + } + var root id.EventID + if msg.ThreadRoot != nil { + mappedRoot, err := mappedTarget(msg.Portal, msg.ThreadRoot) + if err != nil { + return nil, err + } + root = mappedRoot + if msg.ThreadRoot.ThreadRoot != "" { + root = messageIDToEventID(msg.ThreadRoot.ThreadRoot) + } + } else if msg.Content.RelatesTo.GetThreadParent() != "" { + return nil, errors.New("thread root has not been mapped to Reddit") + } + if msg.ReplyTo != nil { + target, err := mappedTarget(msg.Portal, msg.ReplyTo) + if err != nil { + return nil, err + } + if root == "" { + root = target + if msg.ReplyTo.ThreadRoot != "" { + root = messageIDToEventID(msg.ReplyTo.ThreadRoot) + } + } + // Reddit rejects a bare m.in_reply_to. Its native Reply in thread + // action sends m.thread with a fallback reply, including for the + // first response to a top-level message. + content.RelatesTo = (&event.RelatesTo{}).SetThread(root, target) + } else if msg.Content.RelatesTo.GetNonFallbackReplyTo() != "" { + return nil, errors.New("reply target has not been mapped to Reddit") + } else if root != "" { + content.RelatesTo = (&event.RelatesTo{}).SetThread(root, root) + } + return &content, nil +} diff --git a/pkg/connector/outgoing_test.go b/pkg/connector/outgoing_test.go new file mode 100644 index 0000000..a64e609 --- /dev/null +++ b/pkg/connector/outgoing_test.go @@ -0,0 +1,88 @@ +package connector + +import ( + "context" + "encoding/json" + "io" + "net/http" + "strings" + "testing" + + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/database" + "maunium.net/go/mautrix/event" +) + +func outgoingTestMessage() *bridgev2.MatrixMessage { + p := historyPortal() + return &bridgev2.MatrixMessage{MatrixEventBase: bridgev2.MatrixEventBase[*event.MessageEventContent]{ + Portal: p, Event: &event.Event{ID: "$beeper-event", Timestamp: 1234}, + Content: &event.MessageEventContent{MsgType: event.MsgText, Body: "test", RelatesTo: (&event.RelatesTo{}).SetReplyTo("$beeper-target")}, + }, ReplyTo: &database.Message{ID: "$reddit-target", Room: p.PortalKey}} +} + +func TestOutgoingThreadTranslationAndMissingMapping(t *testing.T) { + msg := outgoingTestMessage() + msg.ThreadRoot = &database.Message{ID: "$reddit-root", Room: msg.Portal.PortalKey} + msg.Content.RelatesTo = (&event.RelatesTo{}).SetThread("$beeper-root", "$beeper-target") + content, err := outgoingContent(msg) + if err != nil || content.RelatesTo.GetThreadParent() != "$reddit-root" || content.RelatesTo.GetReplyTo() != "$reddit-target" || !content.RelatesTo.IsFallingBack { + t.Fatalf("thread/reply conversion failed: %v", err) + } + msg.ThreadRoot = nil + if _, err = outgoingContent(msg); err == nil { + t.Fatal("unmapped thread was flattened") + } +} + +func TestReplyToThreadMessageKeepsNativeRoot(t *testing.T) { + msg := outgoingTestMessage() + msg.ReplyTo.ThreadRoot = "$existing-root" + content, err := outgoingContent(msg) + if err != nil { + t.Fatal(err) + } + if content.RelatesTo.GetThreadParent() != "$existing-root" || content.RelatesTo.GetReplyTo() != "$reddit-target" || !content.RelatesTo.IsFallingBack { + t.Fatal("reply created a nested thread instead of retaining the native root") + } +} + +func TestReactionRemovalUsesPersistedProviderIdentity(t *testing.T) { + var removePath string + r := testClient(t, func(w http.ResponseWriter, req *http.Request) { + if strings.Contains(req.URL.Path, "/send/m.reaction/") { + _, _ = io.WriteString(w, `{"event_id":"$reddit-reaction"}`) + } else { + removePath = req.URL.Path + _, _ = io.WriteString(w, `{"event_id":"$reddit-redaction"}`) + } + }) + p := historyPortal() + resp, err := r.HandleMatrixReaction(context.Background(), &bridgev2.MatrixReaction{ + MatrixEventBase: bridgev2.MatrixEventBase[*event.ReactionEventContent]{Portal: p, Event: &event.Event{ID: "$beeper-reaction"}}, + TargetMessage: &database.Message{ID: "$reddit-target", Room: p.PortalKey}, + PreHandleResp: &bridgev2.MatrixReactionPreResponse{EmojiID: "👍"}, + }) + if err != nil { + t.Fatal(err) + } + serialized, err := json.Marshal(resp.Metadata) + if err != nil { + t.Fatal(err) + } + var restored ReactionMetadata + if err = json.Unmarshal(serialized, &restored); err != nil { + t.Fatal(err) + } + remove := &bridgev2.MatrixReactionRemove{MatrixEventBase: bridgev2.MatrixEventBase[*event.RedactionEventContent]{Portal: p, Event: &event.Event{ID: "$remove"}}, TargetReaction: &database.Reaction{Room: p.PortalKey, MXID: "$beeper-reaction", Metadata: &restored}} + if err = r.HandleMatrixReactionRemove(context.Background(), remove); err != nil { + t.Fatal(err) + } + if !strings.Contains(removePath, "/redact/$reddit-reaction/") || strings.Contains(removePath, "$beeper-reaction") { + t.Fatalf("wrong deletion target: %s", removePath) + } + remove.TargetReaction.Metadata = &ReactionMetadata{} + if err = r.HandleMatrixReactionRemove(context.Background(), remove); err == nil { + t.Fatal("missing remote identity was silently substituted") + } +} diff --git a/pkg/connector/reactionhistory.go b/pkg/connector/reactionhistory.go new file mode 100644 index 0000000..045084d --- /dev/null +++ b/pkg/connector/reactionhistory.go @@ -0,0 +1,97 @@ +package connector + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/networkid" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +// Reddit's messages endpoint excludes reactions. Fetch the separate relation +// stream to its explicit terminal cursor, including short and empty pages. +// Validate the complete result before applying any of it to an existing chat. +func (r *RedditClient) fetchReactionEvents(ctx context.Context, key networkid.PortalKey, target networkid.MessageID) ([]*event.Event, error) { + if target == "" || (key.Receiver != "" && key.Receiver != r.userLogin.ID) { + return nil, errors.New("invalid reaction history target or owner") + } + var events []*event.Event + cursor := "" + seen := map[string]bool{} + for { + page, err := r.remote().Reactions(ctx, portalIDToRoomID(key.ID), messageIDToEventID(target), cursor) + if isPermanentHTTPError(err) { + return nil, unbridgeable(fmt.Errorf("fetch Reddit reactions: %w", err)) + } else if err != nil { + return nil, fmt.Errorf("fetch Reddit reactions: %w", err) + } + for _, evt := range page.Chunk { + if evt == nil || evt.Type != event.EventReaction || evt.ID == "" || (evt.RoomID != "" && makePortalID(evt.RoomID) != key.ID) { + return nil, unbridgeable(errors.New("reaction history has an invalid event identity")) + } + local, server, err := evt.Sender.Parse() + if err != nil || server != "reddit.com" || !strings.HasPrefix(local, "t2_") { + return nil, unbridgeable(errors.New("reaction history has an invalid sender")) + } + if err = evt.Content.ParseRaw(event.EventReaction); err != nil && !errors.Is(err, event.ErrContentAlreadyParsed) { + return nil, unbridgeable(err) + } + relation := evt.Content.AsReaction().RelatesTo + if relation.Type != event.RelAnnotation || makeMessageID(relation.EventID) != target || relation.Key == "" { + return nil, unbridgeable(errors.New("reaction history belongs to another message")) + } + if redaction := evt.Unsigned.RedactedBecause; redaction != nil { + if redaction.Type != event.EventRedaction || redaction.Redacts != evt.ID || (redaction.RoomID != "" && makePortalID(redaction.RoomID) != key.ID) { + return nil, unbridgeable(errors.New("reaction history has an invalid removal identity")) + } + } + events = append(events, evt) + } + if page.NextBatch == "" { + return events, nil + } + if page.NextBatch == cursor || seen[page.NextBatch] { + return nil, unbridgeable(errors.New("reddit reaction history cursor did not advance")) + } + seen[page.NextBatch] = true + cursor = page.NextBatch + } +} + +func (r *RedditClient) backfillReactions(ctx context.Context, key networkid.PortalKey, target networkid.MessageID) ([]*bridgev2.BackfillReaction, error) { + events, err := r.fetchReactionEvents(ctx, key, target) + if err != nil { + return nil, err + } + var reactions []*bridgev2.BackfillReaction + seen := map[string]id.EventID{} + for _, evt := range events { + if evt.Unsigned.RedactedBecause != nil { + continue + } + key := evt.Content.AsReaction().RelatesTo.Key + identity := string(evt.Sender) + "\x00" + key + if previous := seen[identity]; previous != "" { + if previous != evt.ID { + return nil, unbridgeable(errors.New("reaction history has conflicting active identities")) + } + continue + } + seen[identity] = evt.ID + emoji, shortcode, err := r.reactionEmoji(ctx, key) + if err != nil { + return nil, err + } + reaction := &bridgev2.BackfillReaction{Sender: r.makeSender(evt.Sender), EmojiID: networkid.EmojiID(key), Emoji: emoji, Timestamp: time.UnixMilli(evt.Timestamp), DBMetadata: &ReactionMetadata{RemoteEventID: evt.ID}} + if shortcode != "" { + reaction.ExtraContent = map[string]any{"com.beeper.reaction.shortcode": shortcode} + } + reactions = append(reactions, reaction) + } + return reactions, nil +} diff --git a/pkg/connector/reactionhistory_test.go b/pkg/connector/reactionhistory_test.go new file mode 100644 index 0000000..3846cd3 --- /dev/null +++ b/pkg/connector/reactionhistory_test.go @@ -0,0 +1,54 @@ +package connector + +import ( + "context" + "fmt" + "io" + "net/http" + "testing" +) + +func TestReactionHistoryPaginatesAndDoesNotReviveRemovedContent(t *testing.T) { + calls := 0 + r := testClient(t, func(w http.ResponseWriter, req *http.Request) { + calls++ + if req.URL.Path != "/_matrix/client/v1/rooms/!room:reddit.com/relations/$target/m.annotation/m.reaction" || req.URL.Query().Get("dir") != "b" { + t.Fatal("wrong relations endpoint") + } + switch req.URL.Query().Get("from") { + case "": + _, _ = io.WriteString(w, `{"next_batch":"p1","chunk":[]}`) + case "p1": + _, _ = io.WriteString(w, `{"next_batch":"p2","chunk":[{"type":"m.reaction","event_id":"$removed","sender":"@t2_b:reddit.com","content":{"m.relates_to":{"event_id":"$target","rel_type":"m.annotation","key":"new-native-key"}},"unsigned":{"redacted_because":{"type":"m.room.redaction","event_id":"$remove","redacts":"$removed"}}}]}`) + case "p2": + _, _ = io.WriteString(w, `{"chunk":[{"type":"m.reaction","event_id":"$active","sender":"@t2_b:reddit.com","origin_server_ts":1234,"content":{"m.relates_to":{"event_id":"$target","rel_type":"m.annotation","key":"new-native-key"}}}]}`) + default: + t.Fatal("unexpected continuation") + } + }) + reactions, err := r.backfillReactions(context.Background(), historyPortal().PortalKey, "$target") + if err != nil || calls != 3 || len(reactions) != 1 { + t.Fatalf("incomplete or resurrected reactions: %+v %v, calls=%d", reactions, err, calls) + } + if reactions[0].DBMetadata.(*ReactionMetadata).RemoteEventID != "$active" || reactions[0].Timestamp.UnixMilli() != 1234 { + t.Fatal("historical reaction lost its durable removal identity or timestamp") + } +} + +func TestReactionHistoryRejectsWrongIdentityAndCursorCycles(t *testing.T) { + for i, body := range []string{ + `{"next_batch":"repeat","chunk":[]}`, + `{"chunk":[null]}`, + `{"chunk":[{"type":"m.reaction","event_id":"$r","room_id":"!wrong:reddit.com","sender":"@t2_b:reddit.com","content":{"m.relates_to":{"event_id":"$target","rel_type":"m.annotation","key":"key"}}}]}`, + `{"chunk":[{"type":"m.reaction","event_id":"$r","sender":"@t2_b:reddit.com","content":{"m.relates_to":{"event_id":"$wrong","rel_type":"m.annotation","key":"key"}}}]}`, + `{"chunk":[{"type":"m.reaction","event_id":"$r","sender":"@t2_b:foreign.example","content":{"m.relates_to":{"event_id":"$target","rel_type":"m.annotation","key":"key"}}}]}`, + `{"chunk":[{"type":"m.reaction","event_id":"$r","sender":"@t2_b:reddit.com","content":{"m.relates_to":{"event_id":"$target","rel_type":"m.annotation","key":"key"}},"unsigned":{"redacted_because":{"type":"m.room.redaction","redacts":"$wrong"}}}]}`, + } { + t.Run(fmt.Sprint(i), func(t *testing.T) { + r := testClient(t, func(w http.ResponseWriter, req *http.Request) { _, _ = io.WriteString(w, body) }) + if _, err := r.fetchReactionEvents(context.Background(), historyPortal().PortalKey, "$target"); err == nil { + t.Fatal("unsafe reaction history was accepted") + } + }) + } +} diff --git a/pkg/connector/reactions.go b/pkg/connector/reactions.go new file mode 100644 index 0000000..729c87e --- /dev/null +++ b/pkg/connector/reactions.go @@ -0,0 +1,36 @@ +package connector + +import ( + "context" + "errors" + "time" + + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/networkid" + "maunium.net/go/mautrix/bridgev2/simplevent" + "maunium.net/go/mautrix/event" +) + +func (r *RedditClient) convertReaction(ctx context.Context, key networkid.PortalKey, evt *event.Event) (*simplevent.Reaction, error) { + content, ok := evt.Content.Parsed.(*event.ReactionEventContent) + if !ok || content.RelatesTo.EventID == "" || content.RelatesTo.Type != event.RelAnnotation || content.RelatesTo.Key == "" { + return nil, unbridgeable(errors.New("reaction is missing its target")) + } + emoji, shortcode, err := r.reactionEmoji(ctx, content.RelatesTo.Key) + if err != nil { + return nil, err + } + reaction := &simplevent.Reaction{ + EventMeta: simplevent.EventMeta{ + Type: bridgev2.RemoteEventReaction, PortalKey: key, + Sender: r.makeSender(evt.Sender), Timestamp: time.UnixMilli(evt.Timestamp), + }, + ReactionDBMeta: &ReactionMetadata{RemoteEventID: evt.ID}, + EmojiID: networkid.EmojiID(content.RelatesTo.Key), Emoji: emoji, + TargetMessage: makeMessageID(content.RelatesTo.EventID), + } + if shortcode != "" { + reaction.ExtraContent = map[string]any{"com.beeper.reaction.shortcode": shortcode} + } + return reaction, nil +} diff --git a/pkg/connector/receipts.go b/pkg/connector/receipts.go new file mode 100644 index 0000000..3d748a7 --- /dev/null +++ b/pkg/connector/receipts.go @@ -0,0 +1,105 @@ +package connector + +import ( + "context" + "errors" + "slices" + "strings" + "time" + + "github.com/rs/zerolog" + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/networkid" + "maunium.net/go/mautrix/bridgev2/simplevent" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +// Reddit's native sync contains m.read and m.read.private with thread_id and +// ts. Bridgev2's receipt interface represents public room receipts, so private +// or thread-specific receipts must not be widened to that interface. +func (r *RedditClient) convertReadReceipts(key networkid.PortalKey, evt *event.Event) ([]*simplevent.Receipt, error) { + if evt.RoomID != "" && makePortalID(evt.RoomID) != key.ID { + return nil, errors.New("read receipt belongs to another room") + } + if key.Receiver != "" && key.Receiver != r.userLogin.ID { + return nil, errors.New("read receipt portal belongs to another login") + } + if err := evt.Content.ParseRaw(event.EphemeralEventReceipt); err != nil && !errors.Is(err, event.ErrContentAlreadyParsed) { + return nil, err + } + content, ok := evt.Content.Parsed.(*event.ReceiptEventContent) + if !ok || content == nil { + return nil, errors.New("invalid read receipt content") + } + bySender := make(map[id.UserID]*simplevent.Receipt) + for target, receipts := range *content { + for sender, receipt := range receipts[event.ReceiptTypeRead] { + if receipt.ThreadID != "" && receipt.ThreadID != event.ReadReceiptThreadMain { + continue + } + localpart, server, err := sender.Parse() + if err != nil || server != "reddit.com" || !strings.HasPrefix(localpart, "t2_") || target == "" { + return nil, errors.New("read receipt has an invalid sender or target identity") + } + converted := bySender[sender] + if converted == nil { + converted = &simplevent.Receipt{EventMeta: simplevent.EventMeta{ + Type: bridgev2.RemoteEventReadReceipt, PortalKey: key, Sender: r.makeSender(sender), + }} + bySender[sender] = converted + } + converted.Targets = append(converted.Targets, makeMessageID(target)) + if receipt.Timestamp.After(converted.Timestamp) { + converted.Timestamp = receipt.Timestamp + } + // ReadUpTo remains zero: the receipt timestamp is when the user read + // something, not the creation time of the last message they read. + } + } + senders := make([]id.UserID, 0, len(bySender)) + for sender := range bySender { + senders = append(senders, sender) + } + slices.Sort(senders) + out := make([]*simplevent.Receipt, 0, len(senders)) + for _, sender := range senders { + receipt := bySender[sender] + slices.Sort(receipt.Targets) + out = append(out, receipt) + } + return out, nil +} + +func (r *RedditClient) handleEphemeralEvents(ctx context.Context, key networkid.PortalKey, events []*event.Event) { + for _, evt := range events { + if evt == nil { + continue + } + if evt.Type == event.EphemeralEventTyping { + typings, err := r.convertTyping(key, evt) + if err != nil { + zerolog.Ctx(ctx).Err(err).Stringer("portal_key", key).Msg("Failed to convert Reddit typing event, skipping") + continue + } + for _, typing := range typings { + r.userLogin.QueueRemoteEvent(typing) + } + continue + } + if evt.Type != event.EphemeralEventReceipt { + continue + } + receipts, err := r.convertReadReceipts(key, evt) + if err != nil { + zerolog.Ctx(ctx).Err(err).Stringer("portal_key", key).Msg("Failed to convert Reddit read receipt, skipping") + continue + } + for _, receipt := range receipts { + if receipt.Timestamp.IsZero() { + receipt.Timestamp = time.Now() + } + r.userLogin.QueueRemoteEvent(receipt) + } + } +} diff --git a/pkg/connector/redactions.go b/pkg/connector/redactions.go new file mode 100644 index 0000000..77a9a18 --- /dev/null +++ b/pkg/connector/redactions.go @@ -0,0 +1,121 @@ +package connector + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/networkid" + "maunium.net/go/mautrix/bridgev2/simplevent" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +func validateNativeEvent(key networkid.PortalKey, evt *event.Event) error { + if evt == nil || evt.ID == "" || (evt.RoomID != "" && makePortalID(evt.RoomID) != key.ID) { + return errors.New("reddit event has an invalid room or event identity") + } + local, server, err := evt.Sender.Parse() + if err != nil || server != "reddit.com" || !strings.HasPrefix(local, "t2_") || len(local) <= 3 { + return errors.New("reddit event has an invalid sender") + } + return nil +} + +func redactionTarget(evt *event.Event) (id.EventID, error) { + if evt == nil || evt.Type != event.EventRedaction { + return "", errors.New("expected a Reddit redaction") + } + if err := evt.Content.ParseRaw(event.EventRedaction); err != nil && !errors.Is(err, event.ErrContentAlreadyParsed) { + return "", err + } + target := evt.Redacts + if content, ok := evt.Content.Parsed.(*event.RedactionEventContent); ok && content.Redacts != "" { + if target != "" && target != content.Redacts { + return "", errors.New("reddit redaction has conflicting targets") + } + target = content.Redacts + } + if target == "" { + return "", errors.New("reddit redaction has no target") + } + return target, nil +} + +func validateTombstone(key networkid.PortalKey, target *event.Event) error { + if err := validateNativeEvent(key, target); err != nil { + return err + } + removal := target.Unsigned.RedactedBecause + if err := validateNativeEvent(key, removal); err != nil { + return fmt.Errorf("invalid Reddit deletion proof: %w", err) + } + removedID, err := redactionTarget(removal) + if err != nil || removedID != target.ID { + return errors.New("reddit deletion proof belongs to another event") + } + if target.Type != event.EventMessage && target.Type != event.EventReaction { + return errors.New("unsupported deleted Reddit event type") + } + return nil +} + +// Reddit redactions identify an event, while bridgev2 reaction removals identify +// a message, sender and emoji. Resolve that native identity through Reddit's +// event endpoint; the framework owns the corresponding local lookup/removal. +func (r *RedditClient) convertRedaction(ctx context.Context, key networkid.PortalKey, removal *event.Event) (bridgev2.RemoteEvent, error) { + if err := validateNativeEvent(key, removal); err != nil { + return nil, unbridgeable(err) + } + targetID, err := redactionTarget(removal) + if err != nil { + return nil, unbridgeable(err) + } + target, err := r.remote().GetEvent(ctx, portalIDToRoomID(key.ID), targetID) + if isPermanentHTTPError(err) { + return nil, unbridgeable(fmt.Errorf("resolve deleted Reddit event: %w", err)) + } else if err != nil { + return nil, fmt.Errorf("resolve deleted Reddit event: %w", err) + } + if target == nil || target.ID != targetID { + return nil, unbridgeable(errors.New("reddit returned another deletion target")) + } + if err = validateTombstone(key, target); err != nil { + return nil, unbridgeable(err) + } + if proof := target.Unsigned.RedactedBecause; proof.ID != removal.ID || proof.Sender != removal.Sender || proof.Timestamp != removal.Timestamp { + return nil, unbridgeable(errors.New("reddit returned a different deletion proof")) + } + removed, err := r.convertDeletedEvent(key, target) + return removed, unbridgeable(err) +} + +func (r *RedditClient) convertDeletedEvent(key networkid.PortalKey, target *event.Event) (bridgev2.RemoteEvent, error) { + if key.Receiver != "" && key.Receiver != r.userLogin.ID { + return nil, errors.New("deletion portal belongs to another login") + } + if err := validateTombstone(key, target); err != nil { + return nil, err + } + removal := target.Unsigned.RedactedBecause + meta := simplevent.EventMeta{PortalKey: key, Sender: r.makeSender(removal.Sender), Timestamp: time.UnixMilli(removal.Timestamp)} + if target.Type == event.EventMessage { + meta.Type = bridgev2.RemoteEventMessageRemove + return &simplevent.MessageRemove{EventMeta: meta, TargetMessage: makeMessageID(target.ID)}, nil + } + if err := target.Content.ParseRaw(event.EventReaction); err != nil && !errors.Is(err, event.ErrContentAlreadyParsed) { + return nil, err + } + relation := target.Content.AsReaction().RelatesTo + if relation.Type != event.RelAnnotation || relation.EventID == "" || relation.Key == "" { + return nil, errors.New("deleted Reddit reaction has no target or emoji") + } + meta.Type = bridgev2.RemoteEventReactionRemove + // Reaction removal identifies the original reactor, even for a moderator's + // redaction. The SDK selects the intent and local reaction to remove. + meta.Sender = r.makeSender(target.Sender) + return &simplevent.Reaction{EventMeta: meta, TargetMessage: makeMessageID(relation.EventID), EmojiID: networkid.EmojiID(relation.Key)}, nil +} diff --git a/pkg/connector/redactions_test.go b/pkg/connector/redactions_test.go new file mode 100644 index 0000000..eff9540 --- /dev/null +++ b/pkg/connector/redactions_test.go @@ -0,0 +1,94 @@ +package connector + +import ( + "context" + "encoding/json" + "net/http" + "testing" + + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/event" +) + +func deletionFixture(t *testing.T) *event.Event { + t.Helper() + var evt event.Event + err := json.Unmarshal([]byte(`{"type":"m.room.message","event_id":"$deleted","room_id":"!room:reddit.com","sender":"@t2_b:reddit.com","origin_server_ts":1000,"content":{"msgtype":"m.text","body":"retained deleted content"},"unsigned":{"redacted_because":{"type":"m.room.redaction","event_id":"$removal","room_id":"!room:reddit.com","sender":"@t2_b:reddit.com","origin_server_ts":2000,"redacts":"$deleted","content":{}}}}`), &evt) + if err != nil { + t.Fatal(err) + } + return &evt +} + +func TestDeletedContentNeverConvertsToTextOrMedia(t *testing.T) { + r := testClient(t, func(w http.ResponseWriter, req *http.Request) { t.Error("deleted content must not fetch media") }) + evt := deletionFixture(t) + for _, content := range []event.Content{evt.Content, {Raw: map[string]any{}}, {Parsed: &event.MessageEventContent{MsgType: event.MsgImage, Body: "deleted image", URL: "mxc://reddit.com/removed"}}} { + evt.Content = content + msg, err := r.convertMessage(context.Background(), historyPortal(), nil, evt) + if err != nil || len(msg.Parts) != 1 || !msg.Parts[0].DontBridge || msg.Parts[0].Content.Body != "" { + t.Fatal("retained deleted content was exposed", err) + } + } +} + +func TestDeletionRejectsWrongRoomOwnerAndProof(t *testing.T) { + for _, tc := range []string{"room", "owner", "proof", "foreign sender", "missing proof", "conflicting targets"} { + t.Run(tc, func(t *testing.T) { + r := testClient(t, func(w http.ResponseWriter, req *http.Request) { t.Error("unexpected request") }) + key := historyPortal().PortalKey + evt := deletionFixture(t) + switch tc { + case "room": + evt.RoomID = "!other:reddit.com" + case "owner": + key.Receiver = "t2_other" + case "proof": + evt.Unsigned.RedactedBecause.Redacts = "$other" + case "foreign sender": + evt.Sender = "@t2_b:foreign.example" + case "missing proof": + evt.Unsigned.RedactedBecause = nil + case "conflicting targets": + evt.Unsigned.RedactedBecause.Content = event.Content{Parsed: &event.RedactionEventContent{Redacts: "$other"}} + } + if _, err := r.convertDeletedEvent(key, evt); err == nil { + t.Fatal("unsafe deletion accepted") + } + }) + } +} + +func TestRedactionResolvesItsNativeTarget(t *testing.T) { + target := deletionFixture(t) + r := testClient(t, func(w http.ResponseWriter, req *http.Request) { + if req.URL.Path != "/_matrix/client/v3/rooms/!room:reddit.com/event/$deleted" { + t.Error("wrong native target", req.URL.Path) + } + _ = json.NewEncoder(w).Encode(target) + }) + removal, err := r.convertRedaction(context.Background(), historyPortal().PortalKey, target.Unsigned.RedactedBecause) + if err != nil || removal.(bridgev2.RemoteMessageRemove).GetTargetMessage() != "$deleted" { + t.Fatal(removal, err) + } + unrelated := *target.Unsigned.RedactedBecause + unrelated.ID = "$another-removal" + if _, err = r.convertRedaction(context.Background(), historyPortal().PortalKey, &unrelated); err == nil { + t.Fatal("different deletion proof accepted") + } +} + +func TestHistorySkipsRemovalEventsWithoutSendingOrChangingMappings(t *testing.T) { + target := deletionFixture(t) + r := testClient(t, func(w http.ResponseWriter, req *http.Request) { + if req.URL.Path != "/_matrix/client/v3/rooms/!room:reddit.com/messages" { + t.Error("history unexpectedly fetched a deletion or media", req.URL.Path) + } + _ = json.NewEncoder(w).Encode(map[string]any{"chunk": []*event.Event{target.Unsigned.RedactedBecause, target}}) + }) + // No local DB or Matrix intent: fetching history must only produce data. + resp, err := r.FetchMessages(context.Background(), bridgev2.FetchMessagesParams{Portal: historyPortal()}) + if err != nil || resp.HasMore || len(resp.Messages) != 1 || !resp.Messages[0].Parts[0].DontBridge || len(resp.Messages[0].Reactions) != 0 { + t.Fatal("history did not suppress deleted content", err) + } +} diff --git a/pkg/connector/requests.go b/pkg/connector/requests.go new file mode 100644 index 0000000..983c552 --- /dev/null +++ b/pkg/connector/requests.go @@ -0,0 +1,154 @@ +package connector + +import ( + "context" + "encoding/json" + "errors" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/database" + "maunium.net/go/mautrix/bridgev2/networkid" + "maunium.net/go/mautrix/bridgev2/simplevent" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" + + "github.com/beeper/reddit/pkg/redditchat" +) + +var _ bridgev2.MessageRequestAcceptingNetworkAPI = (*RedditClient)(nil) +var _ bridgev2.DeleteChatHandlingNetworkAPI = (*RedditClient)(nil) + +// One full sync is shared by every room in a sync pass that needs it. +type roomSnapshots struct { + full *redditchat.SyncResponse +} + +// Reddit's full sync supplies missing room state during provisioning and +// native unhide. Retain only requested rooms and leave the poll cursor alone. +func (r *RedditClient) fetchRoomSnapshots(ctx context.Context, snapshots *roomSnapshots, rooms []id.RoomID) (*redditchat.SyncResponse, error) { + if snapshots.full == nil { + full, err := r.remote().Sync(ctx, "", 0) + if err != nil { + return nil, err + } + snapshots.full = full + } + full := snapshots.full + state := &redditchat.SyncResponse{} + state.Rooms.Join = make(map[id.RoomID]*mautrix.SyncJoinedRoom) + state.Rooms.Invite = make(map[id.RoomID]*mautrix.SyncInvitedRoom) + state.Rooms.Peek = make(map[id.RoomID]*mautrix.SyncJoinedRoom) + for _, room := range rooms { + if joined := full.Rooms.Join[room]; joined != nil { + state.Rooms.Join[room] = joined + } else if invited := full.Rooms.Invite[room]; invited != nil { + state.Rooms.Invite[room] = invited + } + if peek := full.Rooms.Peek[room]; peek != nil { + state.Rooms.Peek[room] = peek + } + } + return state, nil +} + +// Reddit's Ignore action hides a DM for this account; group requests are left. +// Bridgev2 owns local room cleanup after the native operation succeeds. +func (r *RedditClient) HandleMatrixDeleteChat(ctx context.Context, msg *bridgev2.MatrixDeleteChat) error { + if msg == nil || msg.Portal == nil || msg.Portal.ID == "" || msg.Content == nil { + return errors.New("chat deletion has no portal identity or content") + } + if msg.Portal.Receiver != "" && msg.Portal.Receiver != r.userLogin.ID { + return errors.New("chat belongs to another login") + } + if msg.Content.DeleteForEveryone { + return errors.New("reddit chats cannot be deleted for everyone") + } + roomID := portalIDToRoomID(msg.Portal.ID) + state, key, err := r.resolveRoomState(ctx, roomID, &mautrix.SyncJoinedRoom{}, &roomSnapshots{}) + if err != nil { + return err + } + if key != msg.Portal.PortalKey { + return errors.New("chat ownership does not match Reddit room state") + } + switch state.Type { + case "direct": + return r.matrix().SetRoomAccountData(ctx, roomID, "com.reddit.hidden_chat", map[string]bool{"hidden": true}) + case "private_group": + _, err = r.matrix().LeaveRoom(ctx, roomID) + return err + default: + return errors.New("unsupported Reddit chat type for deletion") + } +} + +func (r *RedditClient) inviteSnapshot(invited *mautrix.SyncInvitedRoom) (*mautrix.SyncJoinedRoom, error) { + if invited == nil { + return nil, errors.New("missing Reddit invitation state") + } + joined := &mautrix.SyncJoinedRoom{State: mautrix.SyncEventsList{Events: append([]*event.Event(nil), invited.State.Events...)}} + var hasType, ownInvite, isDirect bool + for _, evt := range invited.State.Events { + if evt == nil || evt.StateKey == nil { + continue + } + if evt.Type.Type == "com.reddit.chat.type" { + hasType = true + } + if evt.Type == event.StateMember && *evt.StateKey == string(userIDToMatrix(r.userID, "")) { + data, err := json.Marshal(&evt.Content) + if err != nil { + return nil, err + } + var member event.MemberEventContent + if err = json.Unmarshal(data, &member); err != nil { + return nil, err + } + ownInvite, isDirect = member.Membership == event.MembershipInvite, member.IsDirect + } + } + if !ownInvite { + return nil, errors.New("reddit invitation does not identify the logged-in account") + } + // Native DM membership carries is_direct. If stripped invite state omits + // com.reddit.chat.type, this is an explicit identity hint, not a member-count + // guess. Unknown non-DM types remain unclassified and cannot be auto-joined. + if !hasType && isDirect { + key := "" + joined.State.Events = append(joined.State.Events, &event.Event{Type: event.Type{Type: "com.reddit.chat.type", Class: event.StateEventType}, StateKey: &key, Content: event.Content{Raw: map[string]any{"type": "direct"}}}) + } + return joined, nil +} + +func (r *RedditClient) HandleMatrixAcceptMessageRequest(ctx context.Context, msg *bridgev2.MatrixAcceptMessageRequest) error { + if msg == nil || msg.Portal == nil || msg.Portal.ID == "" { + return errors.New("message request has no portal identity") + } + if msg.Portal.Receiver != "" && msg.Portal.Receiver != r.userLogin.ID { + return errors.New("message request belongs to another login") + } + roomID := portalIDToRoomID(msg.Portal.ID) + joined, err := r.matrix().JoinRoomByID(ctx, roomID) + if err != nil { + return err + } + if joined.RoomID != "" && joined.RoomID != roomID { + return errors.New("reddit accepted a different conversation") + } + return nil +} + +func (r *RedditClient) pendingRequestEvent(state *RoomState, key networkid.PortalKey) *simplevent.ChatResync { + info := r.chatInfoFromState(state) + return &simplevent.ChatResync{ + EventMeta: simplevent.EventMeta{Type: bridgev2.RemoteEventChatResync, PortalKey: key, CreatePortal: true}, + ChatInfo: info, + CheckNeedsBackfillFunc: func(_ context.Context, latest *database.Message) (bool, error) { + // Populate an existing empty request room too. Subsequent resyncs + // must not repeatedly fetch the same preview; older history has its + // own SDK queue task and provider cursor. + return info.CanBackfill && latest == nil, nil + }, + } +} diff --git a/pkg/connector/roomstate.go b/pkg/connector/roomstate.go new file mode 100644 index 0000000..9a03d5a --- /dev/null +++ b/pkg/connector/roomstate.go @@ -0,0 +1,262 @@ +package connector + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "maps" + + "go.mau.fi/util/ptr" + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/database" + "maunium.net/go/mautrix/bridgev2/networkid" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +// RoomState is the current projection of Reddit's sync state. Reddit supplies +// state events in the timeline too, and doesn't implement joined_members. +// Persist this projection so incremental syncs don't change a portal's identity. +type RoomState struct { + Type string `json:"type"` + Name *string `json:"name,omitempty"` + Topic *string `json:"topic,omitempty"` + Members map[id.UserID]RoomMember `json:"members"` + JoinedCount *int `json:"joined_count,omitempty"` + InvitedCount *int `json:"invited_count,omitempty"` + PowerLevels *event.PowerLevelsEventContent `json:"power_levels,omitempty"` +} + +type RoomMember struct { + Name string `json:"name,omitempty"` + Membership event.Membership `json:"membership"` +} + +var errMissingChatType = errors.New("reddit sync is missing chat type; refusing to guess a recipient") + +func mergeRoomState(previous *RoomState, joined *mautrix.SyncJoinedRoom) (*RoomState, error) { + next := &RoomState{Members: make(map[id.UserID]RoomMember)} + if previous != nil { + *next = *previous + next.Members = maps.Clone(previous.Members) + if next.Members == nil { + next.Members = make(map[id.UserID]RoomMember) + } + } + lists := [][]*event.Event{joined.State.Events, joined.Timeline.Events} + if joined.StateAfter != nil { + lists = append(lists, joined.StateAfter.Events) + } + for _, list := range lists { + for _, evt := range list { + if evt == nil || evt.StateKey == nil { + continue + } + data, err := json.Marshal(&evt.Content) + if err != nil { + return nil, fmt.Errorf("encode room state: %w", err) + } + switch evt.Type.Type { + case "com.reddit.chat.type": + var content struct { + Type string `json:"type"` + } + if err = json.Unmarshal(data, &content); err != nil { + return nil, err + } + if content.Type != "direct" && content.Type != "private_group" { + return nil, errors.New("unsupported Reddit chat type") + } + if next.Type != "" && next.Type != content.Type { + return nil, errors.New("reddit chat type changed; portal migration required") + } + next.Type = content.Type + case "m.room.member": + var content event.MemberEventContent + if err = json.Unmarshal(data, &content); err != nil { + return nil, err + } + uid := id.UserID(*evt.StateKey) + if _, _, err = uid.Parse(); err != nil { + return nil, errors.New("invalid Reddit room member ID") + } + if content.Membership == event.MembershipJoin || content.Membership == event.MembershipInvite { + member := next.Members[uid] + member.Membership = content.Membership + if content.Displayname != "" { + member.Name = content.Displayname + } + next.Members[uid] = member + } else if content.Membership == event.MembershipLeave || content.Membership == event.MembershipBan { + delete(next.Members, uid) + } else { + return nil, errors.New("unsupported Reddit member state") + } + case "m.room.name": + var content event.RoomNameEventContent + if err = json.Unmarshal(data, &content); err != nil { + return nil, err + } + next.Name = ptr.Ptr(content.Name) + case "m.room.topic": + var content event.TopicEventContent + if err = json.Unmarshal(data, &content); err != nil { + return nil, err + } + next.Topic = ptr.Ptr(content.Topic) + case "m.room.power_levels": + var content event.PowerLevelsEventContent + if err = json.Unmarshal(data, &content); err != nil { + return nil, err + } + next.PowerLevels = &content + } + } + } + if joined.Summary.JoinedMemberCount != nil { + next.JoinedCount = joined.Summary.JoinedMemberCount + } + if joined.Summary.InvitedMemberCount != nil { + next.InvitedCount = joined.Summary.InvitedMemberCount + } + return next, nil +} + +func (r *RedditClient) roomState(ctx context.Context, roomID id.RoomID, joined *mautrix.SyncJoinedRoom) (*RoomState, networkid.PortalKey, error) { + r.roomMu.Lock() + defer r.roomMu.Unlock() + if r.rooms == nil { + r.rooms = make(map[id.RoomID]*RoomState) + } + previous := r.rooms[roomID] + existing, err := r.savedRoomPortal(ctx, roomID) + if err != nil { + return nil, networkid.PortalKey{}, err + } + if previous == nil && existing != nil { + if meta, ok := existing.Metadata.(*PortalMetadata); ok && meta != nil { + previous = meta.State + } + } + next, err := mergeRoomState(previous, joined) + if err != nil { + return nil, networkid.PortalKey{}, unbridgeable(err) + } + if next.Type == "" { + return nil, networkid.PortalKey{}, unbridgeable(errMissingChatType) + } + key := r.makePortalKey(roomID, next.Type == "direct") + if existing != nil && existing.PortalKey != key { + return nil, key, unbridgeable(errors.New("saved portal ownership does not match Reddit chat type")) + } + r.rooms[roomID] = next + return next, key, nil +} + +// DMs and split groups belong to this login; ordinary groups have an empty +// receiver. Only the latter may fall back to a shared persisted projection. +// Reading the SDK database also makes reload independent of its portal cache. +func (r *RedditClient) savedRoomPortal(ctx context.Context, roomID id.RoomID) (*database.Portal, error) { + key := networkid.PortalKey{ID: makePortalID(roomID), Receiver: r.userLogin.ID} + portal, err := r.main.Bridge.DB.Portal.GetByKey(ctx, key) + if err != nil || portal != nil || r.main.Bridge.Config.SplitPortals { + return portal, err + } + key.Receiver = "" + return r.main.Bridge.DB.Portal.GetByKey(ctx, key) +} + +// A portal row can exist without saved state, e.g. after a failed resync, and +// incremental syncs omit the chat type. Fetch the snapshot outside roomMu. +func (r *RedditClient) resolveRoomState(ctx context.Context, roomID id.RoomID, joined *mautrix.SyncJoinedRoom, snapshots *roomSnapshots) (*RoomState, networkid.PortalKey, error) { + state, key, err := r.roomState(ctx, roomID, joined) + if !errors.Is(err, errMissingChatType) { + return state, key, err + } + full, err := r.fetchRoomSnapshots(ctx, snapshots, []id.RoomID{roomID}) + if err != nil { + return nil, networkid.PortalKey{}, err + } + snapshot := full.Rooms.Join[roomID] + if snapshot == nil && full.Rooms.Invite[roomID] == nil { + return nil, networkid.PortalKey{}, unbridgeable(errors.New("reddit did not return the requested conversation state")) + } else if snapshot == nil { + if snapshot, err = r.inviteSnapshot(full.Rooms.Invite[roomID]); err != nil { + return nil, networkid.PortalKey{}, unbridgeable(err) + } + } + return r.roomState(ctx, roomID, snapshot) +} + +func (r *RedditClient) chatInfoFromState(state *RoomState) *bridgev2.ChatInfo { + info := &bridgev2.ChatInfo{Name: state.Name, Topic: state.Topic, CanBackfill: true, Type: ptr.Ptr(database.RoomTypeGroupDM)} + if own, known := state.Members[userIDToMatrix(r.userID, "")]; known { + request := own.Membership == event.MembershipInvite + info.MessageRequest = &request + // Invited DM and group accounts can read /messages and reaction + // relations without joining. Return the native preview through normal + // backfill; the request flag and remote membership remain pending. + info.CanBackfill = !request || state.Type == "direct" || state.Type == "private_group" + } + if state.Type == "direct" { + info.Type = ptr.Ptr(database.RoomTypeDM) + } + members := &bridgev2.ChatMemberList{MemberMap: make(bridgev2.ChatMemberMap)} + if state.Type == "private_group" && state.PowerLevels != nil { + // The SDK maps native permissions and each member to its Matrix user. + members.PowerLevels = &bridgev2.PowerLevelOverrides{ + Events: map[event.Type]int{event.StateRoomName: state.PowerLevels.GetEventLevel(event.StateRoomName)}, + Invite: ptr.Ptr(state.PowerLevels.Invite()), + Kick: ptr.Ptr(state.PowerLevels.Kick()), + } + } + for mxid, member := range state.Members { + name := member.Name + if name == "" { + name = string(makeUserID(mxid)) + } + formatted := r.main.Config.FormatDisplayname(DisplaynameParams{Username: stripUserPrefix(name), UserID: string(makeUserID(mxid))}) + membership := member.Membership + if mxid == userIDToMatrix(r.userID, "") && membership == event.MembershipInvite { + // The owner must be joined on Beeper to see and accept the request. + // Native membership remains invite in the saved projection; only the + // explicit accept handler joins Reddit. The SDK excludes invitations + // from a new Matrix room's initial member list. + membership = event.MembershipJoin + } + mapped := bridgev2.ChatMember{EventSender: r.makeSender(mxid), Membership: membership, UserInfo: &bridgev2.UserInfo{Name: &formatted}} + if members.PowerLevels != nil { + mapped.PowerLevel = ptr.Ptr(state.PowerLevels.GetUserLevel(mxid)) + } + members.MemberMap.Set(mapped) + if state.Type == "direct" && makeUserID(mxid) != r.userID { + members.OtherUserID = makeUserID(mxid) + if info.Name == nil { + info.Name = &formatted + } + } + } + members.TotalMemberCount = len(members.MemberMap) + if state.JoinedCount != nil && state.InvitedCount != nil { + members.TotalMemberCount = *state.JoinedCount + *state.InvitedCount + members.IsFull = members.TotalMemberCount == len(members.MemberMap) + } + info.Members = members + info.ExtraUpdates = func(ctx context.Context, portal *bridgev2.Portal) bool { + meta, ok := portal.Metadata.(*PortalMetadata) + if !ok || meta == nil { + meta = &PortalMetadata{} + portal.Metadata = meta + } + meta.State = state + if state.Type == "direct" { + meta.Preset = redditDMPreset + } else { + meta.Preset = "private_chat" + } + return true + } + return info +} diff --git a/pkg/connector/roomstate_test.go b/pkg/connector/roomstate_test.go new file mode 100644 index 0000000..c4f5f9a --- /dev/null +++ b/pkg/connector/roomstate_test.go @@ -0,0 +1,92 @@ +package connector + +import ( + "encoding/json" + "net/http" + "testing" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/bridgev2/database" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +func decodeRoom(t *testing.T, data string) *mautrix.SyncJoinedRoom { + t.Helper() + var joined mautrix.SyncJoinedRoom + if err := json.Unmarshal([]byte(data), &joined); err != nil { + t.Fatal(err) + } + return &joined +} + +func TestRoomStateFromNativeTimelineSurvivesIncrementalSync(t *testing.T) { + // Same field layout as the read-only live sync capture; synthetic identities. + joined := decodeRoom(t, `{"summary":{"m.joined_member_count":2,"m.invited_member_count":0},"state":{"events":[]},"timeline":{"events":[ + {"type":"m.room.create","state_key":"","content":{"creator":"@t2_b:reddit.com","room_version":"9"}}, + {"type":"m.room.member","state_key":"@t2_b:reddit.com","content":{"membership":"join","displayname":"counterpart"}}, + {"type":"m.room.member","state_key":"@t2_a:reddit.com","content":{"membership":"invite","is_direct":true,"displayname":"owner"}}, + {"type":"com.reddit.chat.type","state_key":"","content":{"type":"direct","participants":["@t2_a:reddit.com","@t2_b:reddit.com"]}}, + {"type":"m.room.member","state_key":"@t2_a:reddit.com","content":{"membership":"join","displayname":"owner"}} + ]}}`) + state, err := mergeRoomState(nil, joined) + if err != nil { + t.Fatal(err) + } + r := testClient(t, func(w http.ResponseWriter, req *http.Request) { + t.Error("room hydration must not call unsupported endpoints") + }) + info := r.chatInfoFromState(state) + if *info.Type != database.RoomTypeDM || *info.Name != "counterpart" || info.Members.OtherUserID != "t2_b" || !info.Members.IsFull || len(info.Members.MemberMap) != 2 { + t.Fatalf("incorrect native DM projection: %+v %+v", info, info.Members) + } + data, err := json.Marshal(&PortalMetadata{State: state}) + if err != nil { + t.Fatal(err) + } + var restored PortalMetadata + if err = json.Unmarshal(data, &restored); err != nil { + t.Fatal(err) + } + updated, err := mergeRoomState(restored.State, decodeRoom(t, `{"timeline":{"events":[{"type":"m.room.message","content":{"body":"next"}}]}}`)) + if err != nil { + t.Fatal(err) + } + if updated.Type != "direct" || len(updated.Members) != 2 { + t.Fatalf("incremental sync lost identity: %+v", updated) + } + firstKey := r.makePortalKey("!room:reddit.com", state.Type == "direct") + if key := r.makePortalKey("!room:reddit.com", updated.Type == "direct"); key != firstKey { + t.Fatal("incremental sync changed portal key") + } +} + +func TestRoomStateDoesNotMutatePreviousAndHonorsLatestState(t *testing.T) { + previous := &RoomState{Type: "private_group", Members: map[id.UserID]RoomMember{"@t2_b:reddit.com": {Name: "counterpart", Membership: event.MembershipJoin}}} + updated, err := mergeRoomState(previous, decodeRoom(t, `{"timeline":{"events":[{"type":"m.room.name","state_key":"","content":{"name":"old"}}]},"state_after":{"events":[{"type":"m.room.name","state_key":"","content":{"name":"new"}},{"type":"m.room.member","state_key":"@t2_b:reddit.com","content":{"membership":"leave"}}]}}`)) + if err != nil { + t.Fatal(err) + } + if len(previous.Members) != 1 || previous.Name != nil { + t.Fatal("merge mutated the prior snapshot") + } + if len(updated.Members) != 0 || *updated.Name != "new" { + t.Fatalf("latest state ignored: %+v", updated) + } + r := testClient(t, func(w http.ResponseWriter, req *http.Request) {}) + r.main.Bridge.Config.SplitPortals = true + if *r.chatInfoFromState(updated).Type != database.RoomTypeGroupDM { + t.Fatal("split group was misclassified as a DM") + } +} + +func TestRoomStateRejectsUnknownAndChangedType(t *testing.T) { + for _, data := range []string{ + `{"state":{"events":[{"type":"com.reddit.chat.type","state_key":"","content":{"type":"unknown-kind"}}]}}`, + `{"state":{"events":[{"type":"com.reddit.chat.type","state_key":"","content":{"type":"private_group"}}]}}`, + } { + if _, err := mergeRoomState(&RoomState{Type: "direct"}, decodeRoom(t, data)); err == nil { + t.Fatal("unknown or changed identity was accepted") + } + } +} diff --git a/pkg/connector/startchat.go b/pkg/connector/startchat.go index 3ef9cb1..114d84d 100644 --- a/pkg/connector/startchat.go +++ b/pkg/connector/startchat.go @@ -29,7 +29,7 @@ func (r *RedditClient) ResolveIdentifier(ctx context.Context, identifier string, if identifier == "" { return nil, errors.New("empty identifier") } - results, err := r.rc.SearchUsers(ctx, identifier) + results, err := r.remote().SearchUsers(ctx, identifier) if err != nil { return nil, fmt.Errorf("search users: %w", err) } @@ -37,14 +37,12 @@ func (r *RedditClient) ResolveIdentifier(ctx context.Context, identifier string, for _, hit := range results.Results { display := hit.DisplayName if strings.EqualFold(stripUserPrefix(display), identifier) || strings.EqualFold(string(hit.UserID), "@"+identifier+":reddit.com") { + if match != nil && match.UserID != hit.UserID { + return nil, fmt.Errorf("multiple Reddit users matched %q", identifier) + } match = &userDirectoryHit{UserID: hit.UserID, Name: display, Avatar: string(hit.AvatarURL)} - break } } - if match == nil && len(results.Results) > 0 { - first := results.Results[0] - match = &userDirectoryHit{UserID: first.UserID, Name: first.DisplayName, Avatar: string(first.AvatarURL)} - } if match == nil { return nil, fmt.Errorf("no Reddit user matched %q", identifier) } @@ -62,7 +60,7 @@ func (r *RedditClient) ResolveIdentifier(ctx context.Context, identifier string, if !createChat { return resp, nil } - roomID, _, err := r.rc.CreateOrGetDM(ctx, match.UserID) + roomID, _, err := r.remote().CreateOrGetDM(ctx, match.UserID) if err != nil { return nil, fmt.Errorf("create dm: %w", err) } @@ -79,7 +77,7 @@ func (r *RedditClient) ResolveIdentifier(ctx context.Context, identifier string, // SearchUsers returns multiple matches for a query. func (r *RedditClient) SearchUsers(ctx context.Context, query string) ([]*bridgev2.ResolveIdentifierResponse, error) { query = strings.TrimSpace(query) - results, err := r.rc.SearchUsers(ctx, query) + results, err := r.remote().SearchUsers(ctx, query) if err != nil { return nil, err } @@ -113,7 +111,7 @@ func (r *RedditClient) CreateGroup(ctx context.Context, params *bridgev2.GroupCr for _, uid := range params.Participants { mxids = append(mxids, userIDToMatrix(uid, "")) } - roomID, err := r.rc.CreateGroup(ctx, params.Name.Name, mxids) + roomID, err := r.remote().CreateGroup(ctx, params.Name.Name, mxids) if err != nil { return nil, fmt.Errorf("create group: %w", err) } diff --git a/pkg/connector/tomatrix.go b/pkg/connector/tomatrix.go index 40c01ea..22abddf 100644 --- a/pkg/connector/tomatrix.go +++ b/pkg/connector/tomatrix.go @@ -10,22 +10,45 @@ import ( ) // convertMessage turns a Reddit-side m.room.message event into a bridgev2 -// ConvertedMessage. Because Reddit chat is itself Matrix-backed, the content -// shape on both sides is the same Matrix event format; we mostly forward it. -// Media URIs that point at Reddit's homeserver are still served over Matrix -// so consumers (Beeper homeserver) can fetch them via the standard media API. +// ConvertedMessage. Event IDs belong to different servers: relation targets +// must be resolved by bridgev2 rather than forwarding Reddit IDs to Beeper. func (r *RedditClient) convertMessage(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI, src *event.Event) (*bridgev2.ConvertedMessage, error) { if src == nil { return nil, errors.New("nil source event") } - content, ok := src.Content.Parsed.(*event.MessageEventContent) - if !ok { - // Fall back to raw content. - content = &event.MessageEventContent{} - _ = src.Content.ParseRaw(event.EventMessage) - if parsed, ok := src.Content.Parsed.(*event.MessageEventContent); ok { - content = parsed + if src.Unsigned.RedactedBecause != nil { + if portal == nil { + return nil, errors.New("deleted message requires a portal") } + if err := validateTombstone(portal.PortalKey, src); err != nil { + return nil, unbridgeable(err) + } + return &bridgev2.ConvertedMessage{Parts: []*bridgev2.ConvertedMessagePart{{ + ID: "", Type: event.EventMessage, Content: &event.MessageEventContent{}, DontBridge: true, + }}}, nil + } + if err := src.Content.ParseRaw(event.EventMessage); err != nil && !errors.Is(err, event.ErrContentAlreadyParsed) { + return nil, unbridgeable(err) + } + original, ok := src.Content.Parsed.(*event.MessageEventContent) + if !ok || original == nil { + return nil, unbridgeable(errors.New("reddit message has invalid content")) + } + if original.RelatesTo.GetReplaceID() != "" { + return nil, unbridgeable(errors.New("reddit edit requires reconciliation; refusing to create a duplicate message")) + } + content := *original + content.RemoveReplyFallback() + content.RelatesTo = nil + content.NewContent = nil + switch content.MsgType { + case event.MsgImage: + if err := r.receiveMedia(ctx, portal, intent, &content); err != nil { + return nil, err + } + case event.MsgText, event.MsgNotice, event.MsgEmote: + default: + return nil, bridgev2.ErrUnsupportedMessageType } out := &bridgev2.ConvertedMessage{ @@ -33,19 +56,19 @@ func (r *RedditClient) convertMessage(ctx context.Context, portal *bridgev2.Port { ID: networkid.PartID(""), Type: event.EventMessage, - Content: content, + Content: &content, }, }, } // Translate m.relates_to into bridgev2's reply field so threading works // across the bridge. - if content.RelatesTo != nil { - if content.RelatesTo.InReplyTo != nil && content.RelatesTo.InReplyTo.EventID != "" { - out.ReplyTo = &networkid.MessageOptionalPartID{ - MessageID: makeMessageID(content.RelatesTo.InReplyTo.EventID), - } - } + if target := original.RelatesTo.GetNonFallbackReplyTo(); target != "" { + out.ReplyTo = &networkid.MessageOptionalPartID{MessageID: makeMessageID(target)} + } + if root := original.RelatesTo.GetThreadParent(); root != "" { + mappedRoot := makeMessageID(root) + out.ThreadRoot = &mappedRoot } return out, nil } diff --git a/pkg/connector/tomatrix_test.go b/pkg/connector/tomatrix_test.go new file mode 100644 index 0000000..abf32ec --- /dev/null +++ b/pkg/connector/tomatrix_test.go @@ -0,0 +1,37 @@ +package connector + +import ( + "context" + "testing" + + "maunium.net/go/mautrix/event" +) + +func TestIncomingThreadUsesBridgeMappingsAndPreservesSource(t *testing.T) { + source := &event.MessageEventContent{MsgType: event.MsgText, Body: "reply", RelatesTo: (&event.RelatesTo{}).SetThread("$reddit-root", "$reddit-fallback")} + evt := &event.Event{Content: event.Content{Parsed: source}} + r := &RedditClient{} + out, err := r.convertMessage(context.Background(), historyPortal(), nil, evt) + if err != nil { + t.Fatal(err) + } + if out.ThreadRoot == nil || *out.ThreadRoot != "$reddit-root" || out.ReplyTo != nil || out.Parts[0].Content.RelatesTo != nil { + t.Fatal("provider relations leaked into Matrix content or fallback became an explicit reply") + } + if source.RelatesTo.GetThreadParent() != "$reddit-root" { + t.Fatal("conversion changed original event") + } + source.RelatesTo.SetReplyTo("$reddit-reply") + out, err = r.convertMessage(context.Background(), historyPortal(), nil, evt) + if err != nil || out.ReplyTo == nil || out.ReplyTo.MessageID != "$reddit-reply" { + t.Fatal("explicit in-thread reply was lost") + } +} + +func TestIncomingEditCannotBecomeSecondMessage(t *testing.T) { + r := &RedditClient{} + _, err := r.convertMessage(context.Background(), historyPortal(), nil, &event.Event{Content: event.Content{Parsed: &event.MessageEventContent{MsgType: event.MsgText, Body: "edited", RelatesTo: (&event.RelatesTo{}).SetReplace("$old")}}}) + if err == nil { + t.Fatal("edit was converted into an ordinary new message") + } +} diff --git a/pkg/connector/typing.go b/pkg/connector/typing.go new file mode 100644 index 0000000..7184d63 --- /dev/null +++ b/pkg/connector/typing.go @@ -0,0 +1,73 @@ +package connector + +import ( + "errors" + "slices" + "strings" + "time" + + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/networkid" + "maunium.net/go/mautrix/bridgev2/simplevent" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +// Reddit sends a room-wide m.typing snapshot, while bridgev2 accepts one +// sender per event. Keep only the ephemeral native snapshot to emit stops for +// users missing from the next update. Nothing here owns delivery or persistence. +func (r *RedditClient) convertTyping(key networkid.PortalKey, evt *event.Event) ([]*simplevent.Typing, error) { + if evt.RoomID != "" && makePortalID(evt.RoomID) != key.ID { + return nil, errors.New("typing event belongs to another room") + } + if key.Receiver != "" && key.Receiver != r.userLogin.ID { + return nil, errors.New("typing portal belongs to another login") + } + if err := evt.Content.ParseRaw(event.EphemeralEventTyping); err != nil && !errors.Is(err, event.ErrContentAlreadyParsed) { + return nil, err + } + content, ok := evt.Content.Parsed.(*event.TypingEventContent) + if !ok || content == nil { + return nil, errors.New("invalid typing content") + } + users := make([]id.UserID, 0, len(content.UserIDs)) + for _, user := range content.UserIDs { + localpart, server, err := user.Parse() + if err != nil || server != "reddit.com" || !strings.HasPrefix(localpart, "t2_") || len(localpart) <= 3 { + return nil, errors.New("typing event has an invalid user identity") + } + if networkid.UserLoginID(localpart) != r.userLogin.ID { + users = append(users, user) + } + } + slices.Sort(users) + users = slices.Compact(users) + r.typingMu.Lock() + defer r.typingMu.Unlock() + var out []*simplevent.Typing + appendTyping := func(user id.UserID, timeout time.Duration) { + out = append(out, &simplevent.Typing{ + EventMeta: simplevent.EventMeta{Type: bridgev2.RemoteEventTyping, PortalKey: key, Sender: r.makeSender(user)}, + Timeout: timeout, + }) + } + for _, previous := range r.typing[key] { + if !slices.Contains(users, previous) { + appendTyping(previous, 0) + } + } + for _, user := range users { + // Native snapshots do not carry a timeout. Bound stale indicators if a + // stop update is lost; repeated snapshots refresh the SDK's indicator. + appendTyping(user, 30*time.Second) + } + if len(users) == 0 { + delete(r.typing, key) + } else { + if r.typing == nil { + r.typing = make(map[networkid.PortalKey][]id.UserID) + } + r.typing[key] = users + } + return out, nil +} diff --git a/pkg/connector/unbridgeable.go b/pkg/connector/unbridgeable.go new file mode 100644 index 0000000..9515d79 --- /dev/null +++ b/pkg/connector/unbridgeable.go @@ -0,0 +1,48 @@ +package connector + +import ( + "errors" + "net/http" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/bridgev2" +) + +// unbridgeableError marks native data that can never be bridged, so skipping it +// is safe. Any other failure, such as a network error, must be retried. +type unbridgeableError struct{ error } + +func (e unbridgeableError) Unwrap() error { return e.error } + +func unbridgeable(err error) error { + if err == nil { + return nil + } + return unbridgeableError{err} +} + +func isUnbridgeable(err error) bool { + return errors.As(err, new(unbridgeableError)) || + errors.Is(err, bridgev2.ErrUnsupportedMessageType) || + errors.Is(err, bridgev2.ErrUnsupportedMediaType) || + errors.Is(err, bridgev2.ErrMediaTooLarge) +} + +// The server refused this specific request, so repeating it cannot succeed. +// Beyond not-found, require a Matrix errcode: Reddit's edge also returns +// temporary HTML blocks. Expired sessions, timeouts and rate limits retry. +func isPermanentHTTPError(err error) bool { + var httpErr mautrix.HTTPError + if !errors.As(err, &httpErr) || httpErr.Response == nil || isUnauthorized(err) { + return false + } + code := httpErr.Response.StatusCode + if isGoneStatus(code) { + return true + } + return httpErr.RespError != nil && code >= 400 && code < 500 && code != http.StatusRequestTimeout && code != http.StatusTooManyRequests +} + +func isGoneStatus(code int) bool { + return code == http.StatusNotFound || code == http.StatusGone +} diff --git a/pkg/redditchat/README.md b/pkg/redditchat/README.md index 1944d49..ac981d5 100644 --- a/pkg/redditchat/README.md +++ b/pkg/redditchat/README.md @@ -1,182 +1,33 @@ -# Reddit Chat Go +# Reddit chat protocol client -Go wrapper for Reddit's Matrix-backed chat service using mautrix-go. +This package implements the Reddit endpoints used by the bridge. It uses +`maunium.net/go/mautrix` for Reddit's Matrix-shaped chat protocol; bridgev2 owns +local message delivery and mappings. -Note: the GitHub project is `github.com/mautrix/go`, but its `go.mod` declares the canonical Go import path as `maunium.net/go/mautrix`. Importing `github.com/mautrix/go` directly fails module path validation, so this library uses the canonical mautrix-go import path. +## Authentication -## Auth +The connector drives the native operations through bridgev2 login steps: -Reddit chat stores Matrix credentials in `https://www.reddit.com` local storage after the chat app loads. The same credentials can also be minted directly from a logged-in Reddit web session by POSTing to `/svc/shreddit/token` with the `csrf_token` cookie. +1. Create a `RedditSession` and call `PrepareLogin` with the username. +2. Present `CaptchaRequest(CaptchaStepPassword)` in the framework's hidden + verification webview, then pass its result to `SubmitPassword`. +3. If Reddit requires two-factor authentication, collect the user's code and + a fresh `CaptchaRequest(CaptchaStepOTP)`, then call `SubmitOTP` on the same + session. Do not repeat the password login. +4. Call `CredentialsFromRedditSession` to mint chat credentials. The connector + persists the session cookies for renewal and validates the account on refresh. -Supported extraction paths: +Each operation accepts a context and performs only that step's HTTP requests. +There is no background login worker or browser-debug connection. OTP codes are +provided by the user; the bridge does not accept authenticator secrets. -- `LoginReddit(ctx, opts)` implements Reddit's current web login flow and returns Reddit session cookies plus Matrix credentials. -- `NewFromRedditLogin(ctx, opts)` logs in, mints Matrix credentials, and returns a ready chat client. -- `CredentialsFromRedditSession(ctx, session)` POSTs `/svc/shreddit/token`, decodes the chat JWT into a Matrix user ID, calls Matrix `whoami` for the device ID, and returns `Credentials`. -- `RedditSessionFromPlaywrightStorageState(path)` imports Reddit session cookies from a Playwright storage-state JSON file without using the old chat localStorage credentials. -- `CredentialsFromPlaywrightStorageState(path)` reads a Playwright storage-state JSON file. -- `CredentialsFromChromeDebugURL(ctx, url)` attaches to a live Chrome/Chromium debug endpoint, opens `https://www.reddit.com/chat/`, and reads the same local storage keys through CDP. -- `SeedChromeFromPlaywrightStorageState(ctx, url, path)` seeds a live Chrome/Chromium debug endpoint from a Playwright storage-state JSON file, then the CDP extractor can read from the real browser session. +## Chat protocol -Chrome debug endpoints may be passed as `http://127.0.0.1:9222` or a browser websocket URL. +Reddit uses `matrix.redditspace.com`, native `t2_` user IDs, opaque event IDs +and provider pagination cursors. Its request previews also appear in +`rooms.peek`. Media downloads use the native `/media/v3/download` endpoint; +thread pages use Reddit's sequenced relations API. These differences are +handled here and by the connector, without overriding bridgev2 delivery. -Login API notes: - -- Reddit serves a deterministic JavaScript verification page before the login UI; the library follows that page transition. -- The username/password step is `POST /svc/shreddit/account/login/check_is_oidc_required`, then `POST /svc/shreddit/account/login`. -- Accounts with app-based 2FA receive HTTP 202 from the password step; the OTP step is `POST /svc/shreddit/account/login/otp`. -- Reddit requires a reCAPTCHA Enterprise token for password and OTP submits. The library accepts a `CaptchaTokenProvider` callback and does not include CAPTCHA solving, media extraction, or bypass logic. -- Reddit's current login flow does not POST an image/audio CAPTCHA answer to Reddit. Google reCAPTCHA runs in the browser and returns an opaque, short-lived `recaptcha_token`; that token is the value Reddit receives. -- If no provider is configured, `LoginReddit` returns `CaptchaRequiredError` with the site key, action, and page URL needed by a caller-managed CAPTCHA flow. -- `StaticCaptchaTokenProvider(tokens...)` can be used when the caller already has one or more fresh reCAPTCHA tokens. Accounts with 2FA need two tokens: one for the password step and one for the OTP step. -- `CaptchaTokenProviderFromChromeDebugURL(url)` uses a real Chrome/Chromium debug session, opens the Reddit login page, runs Google reCAPTCHA Enterprise on Reddit's origin, and returns the resulting token. If Google presents an interactive challenge, the user handles it in that browser. -- `CaptchaRequest.Step` is `password` or `otp`, so UI code can show why a token is being requested. -- For an embedded WebView provider, load `CaptchaRequest.PageURL`, then evaluate `CaptchaRequest.EnterpriseExecuteJavaScript()` on that page and return the resulting token. Username, password, and 2FA do not need to be entered in the WebView. -- TOTP generation is built in through `GenerateTOTP` and `RedditLoginOptions.TOTPSecret`. - -Minimal login shape: - -```go -client, session, err := redditchat.NewFromRedditLogin(ctx, redditchat.RedditLoginOptions{ - Username: username, - Password: password, - TOTPSecret: totpSecret, - CaptchaTokenProvider: redditchat.CaptchaTokenProviderFromChromeDebugURL("http://127.0.0.1:9222"), -}) -_ = client -_ = session -``` - -The Chrome-backed provider needs a running browser with remote debugging enabled, for example: - -```sh -google-chrome --remote-debugging-port=9222 --user-data-dir=/tmp/reddit-login-captcha -``` - -The important detail is origin: the Reddit reCAPTCHA site key must be executed from `https://www.reddit.com/login/`. A local HTML page or unrelated WebView origin will usually produce an invalid token. - -WebView provider shape: - -```go -CaptchaTokenProvider: func(ctx context.Context, req redditchat.CaptchaRequest) (string, error) { - webview.Navigate(req.PageURL) - token, err := webview.EvaluateJavaScript(ctx, req.EnterpriseExecuteJavaScript()) - if err != nil { - return "", err - } - return token, nil -} -``` - -The WebView is only for reCAPTCHA. `LoginReddit` still submits username, password, and TOTP through the reversed Reddit HTTP endpoints. - -## API Surface - -The package exposes mautrix-backed helpers for: - -- `LoginReddit`, `NewFromRedditLogin`, `CredentialsFromRedditSession`, `RedditSessionFromPlaywrightStorageState` -- `CaptchaTokenProviderFromChromeDebugURL`, `CaptchaTokenFromChromeDebugURL`, `StaticCaptchaTokenProvider` -- `Whoami`, `Capabilities`, `Sync`, `Messages`, `GetEvent`, `JoinedRooms` -- `SearchUsers`, `RoomsWithUser`, `DirectRoomsWithUser` -- `CreateDM`, `CreateOrGetDM`, `CreateGroup`, `Invite`, `AcceptInvite`, `Leave` -- `SendText`, `SendNotice`, `SendMessage` -- `SetTyping`, `MarkRead` -- `MediaConfig`, `UploadMedia`, `SendFile`, `SendImage`, `SendVideo`, `SendAudio`, `SendMedia` -- `SendMXCFile`, `SendMXCImage`, `SendMXCVideo`, `SendMXCAudio`, `SendMXCMedia` -- `DownloadMedia`, `DownloadMediaBytes` - -Raw mautrix access is available through `Client.Mautrix()`. - -## Smoke Command - -Authenticate both saved test sessions and sync: - -```sh -go run ./cmd/redditchat-smoke -``` - -Derive Matrix credentials from Reddit session cookies instead of chat localStorage: - -```sh -go run ./cmd/redditchat-smoke -session-auth -``` - -Use Chrome debug protocol instead of saved storage state: - -```sh -go run ./cmd/redditchat-smoke -cdp-a http://127.0.0.1:9222 -cdp-b http://127.0.0.1:9223 -``` - -Seed real Chrome debug sessions from the saved test states, then authenticate through CDP: - -```sh -go run ./cmd/redditchat-smoke \ - -seed-cdp \ - -cdp-a http://127.0.0.1:9222 \ - -cdp-b http://127.0.0.1:9223 -``` - -Send a text message through the verified DM room: - -```sh -go run ./cmd/redditchat-smoke \ - -room '!7qgWTmcqCdRXuLbvlk3ggseaFXdE5jltGuMrhpSbq6o:reddit.com' \ - -message 'hello from mautrix' -``` - -Get or create a DM, or create a group room, then send the smoke message: - -```sh -go run ./cmd/redditchat-smoke -create-dm -go run ./cmd/redditchat-smoke -create-group -``` - -Send media through the Matrix media API: - -```sh -go run ./cmd/redditchat-smoke \ - -room '!7qgWTmcqCdRXuLbvlk3ggseaFXdE5jltGuMrhpSbq6o:reddit.com' \ - -image /tmp/reddit-chat-smoke.png -``` - -Send an already-hosted Reddit MXC media event: - -```sh -go run ./cmd/redditchat-smoke \ - -room '!7qgWTmcqCdRXuLbvlk3ggseaFXdE5jltGuMrhpSbq6o:reddit.com' \ - -image-url 'mxc://reddit.com/' \ - -media-name image.jpg \ - -media-content-type image/jpeg -``` - -Print Reddit Matrix media limits: - -```sh -go run ./cmd/redditchat-smoke -media-config -``` - -## Current Live Evidence - -- Both test accounts authenticate via Reddit's Matrix tokens. -- Reddit login capture reversed the current web endpoints: password login uses `/svc/shreddit/account/login`; 2FA uses `/svc/shreddit/account/login/otp`; chat token refresh uses `POST /svc/shreddit/token`. -- `-session-auth` was verified with a post-login state that had no chat localStorage bootstrap: account A minted fresh Matrix credentials as `@t2_2foi1of47y:reddit.com` device `63491a13e9d7e54e91096051125ff818`, and account B also minted fresh credentials from Reddit session cookies. Both synced successfully. -- Both test accounts now have verified email addresses in Reddit settings. -- `Sync` works for both accounts. -- Reddit's web client uses `preset: "reddit_dm"` for direct chats and `preset: "private_chat"` for group rooms. The wrapper now matches those presets. -- The web client checks `/_matrix/client/v3/rooms?with_user=...&type=direct&include=state,timeline` before creating a DM. `CreateOrGetDM` implements the same reuse flow. -- `CreateDM` worked previously with a minimal mautrix `CreateRoom` request; repeated smoke runs now reuse the existing DM to avoid room-creation quota. -- Account B accepted the invite via `JoinRoomByID`. -- Account A sent a text message via mautrix in the verified DM room and account B observed it via `Sync`. -- Real Chromium CDP sessions were launched on ports 9222 and 9223, seeded from the saved states, and used for auth/sync through `CredentialsFromChromeDebugURL`. -- CDP-backed send/receive worked in room `!7qgWTmcqCdRXuLbvlk3ggseaFXdE5jltGuMrhpSbq6o:reddit.com`; sent event `$bY18t2ZghWQqGKKUy3K2nn52UcwTlatxDjPiNaTi6lA` was observed by account B. -- The latest `CreateOrGetDM` smoke reused that room and sent event `$9Pie9l3KG8pohOoV_vQayz-7nHoVt058QYW5DnjSm1s`, observed by account B. -- `SendMXCImage` with a `mxc://reddit.com/...` URI sent event `$KpRv0U9TGl_0hYbM9Rd9xHSzOXBvzD4Q-95o80OqZMU`, observed by account B. - -New-device note: a message send using a freshly minted session-auth Matrix device reached the existing DM room but Reddit returned `M_FORBIDDEN: User is flagged for spam`. The same accounts can still sync, and earlier established devices can send in the DM. This appears to be another Reddit account/device trust decision rather than a missing Matrix API call. - -Group note: `CreateGroup` is implemented as a non-direct Matrix room creation with invites. Live creation is currently blocked by Reddit rate limits/account-establishment gates on the test accounts: - -- After email verification and chat reload, account A receives `M_LIMIT_EXCEEDED: Limit exceeded for number of invites attempted` with `rate.score_invitation_limit`. -- Earlier runs also hit `rate.score_room_creation_limit` on account A and `rate.score_invitation_limit_ln` when account B was used as the creator. - -Media note: Reddit currently returns `M_FORBIDDEN: Media upload forbidden` for standard Matrix media upload from these sessions, even after both accounts were email verified and the chat app was reloaded. `MediaConfig` reports `m.upload.size: 20971520` and `com.reddit.upload.size: {"image/gif":104857600}`, so the endpoint is present. A headed browser capture of the real Reddit chat UI selected `/tmp/reddit-chat-smoke.png` and the UI itself posted to `https://matrix.redditspace.com/_matrix/media/v3/upload?filename=reddit-chat-smoke.png`, the same endpoint mautrix uses. Reddit returned HTTP 403 and displayed "An error occurred while uploading the image. Please try again." Reddit also rejects HTTPS media URLs and foreign MXC origins in message content, but accepts `mxc://reddit.com/...` media events. A synthetic Reddit-origin MXC URI sends and renders as an image event; a fake media ID will not download actual bytes. +Local protocol tests use synthetic responses and do not establish end-to-end +acceptance. diff --git a/pkg/redditchat/auth.go b/pkg/redditchat/auth.go deleted file mode 100644 index a6be8c6..0000000 --- a/pkg/redditchat/auth.go +++ /dev/null @@ -1,256 +0,0 @@ -package redditchat - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "os" - "time" - - "github.com/chromedp/cdproto/cdp" - "github.com/chromedp/cdproto/network" - "github.com/chromedp/chromedp" -) - -type storageState struct { - Cookies []struct { - Name string `json:"name"` - Value string `json:"value"` - Domain string `json:"domain"` - Path string `json:"path"` - Expires float64 `json:"expires"` - HTTPOnly bool `json:"httpOnly"` - Secure bool `json:"secure"` - SameSite string `json:"sameSite"` - } `json:"cookies"` - Origins []struct { - Origin string `json:"origin"` - LocalStorage []struct { - Name string `json:"name"` - Value string `json:"value"` - } `json:"localStorage"` - } `json:"origins"` -} - -func CredentialsFromPlaywrightStorageState(path string) (Credentials, error) { - state, err := readStorageState(path) - if err != nil { - return Credentials{}, err - } - for _, origin := range state.Origins { - if origin.Origin != "https://www.reddit.com" { - continue - } - kv := make(map[string]string, len(origin.LocalStorage)) - for _, item := range origin.LocalStorage { - kv[item.Name] = item.Value - } - return credentialsFromLocalStorage(kv) - } - return Credentials{}, errors.New("reddit chat: https://www.reddit.com localStorage not found in storage state") -} - -func SeedChromeFromPlaywrightStorageState(ctx context.Context, debugURL, statePath string) error { - state, err := readStorageState(statePath) - if err != nil { - return err - } - localStorage := map[string]string{} - for _, origin := range state.Origins { - if origin.Origin != "https://www.reddit.com" { - continue - } - for _, item := range origin.LocalStorage { - localStorage[item.Name] = item.Value - } - break - } - if len(localStorage) == 0 { - return errors.New("reddit chat: no reddit localStorage entries found to seed") - } - allocCtx, cancel := chromedp.NewRemoteAllocator(ctx, debugURL) - defer cancel() - tabCtx, cancel := chromedp.NewContext(allocCtx) - defer cancel() - - cookies := make([]*network.CookieParam, 0, len(state.Cookies)) - for _, cookie := range state.Cookies { - if cookie.Domain == "" { - continue - } - param := &network.CookieParam{ - Name: cookie.Name, - Value: cookie.Value, - Domain: cookie.Domain, - Path: cookie.Path, - Secure: cookie.Secure, - HTTPOnly: cookie.HTTPOnly, - } - switch cookie.SameSite { - case "Strict": - param.SameSite = network.CookieSameSiteStrict - case "Lax": - param.SameSite = network.CookieSameSiteLax - case "None": - param.SameSite = network.CookieSameSiteNone - } - if cookie.Expires > 0 { - expires := cdp.TimeSinceEpoch(time.Unix(int64(cookie.Expires), 0)) - param.Expires = &expires - } - cookies = append(cookies, param) - } - localStorageJSON, err := json.Marshal(localStorage) - if err != nil { - return err - } - actions := []chromedp.Action{network.Enable()} - if len(cookies) > 0 { - actions = append(actions, network.SetCookies(cookies)) - } - actions = append(actions, - chromedp.Navigate("https://www.reddit.com/"), - chromedp.WaitReady("body", chromedp.ByQuery), - chromedp.Evaluate(fmt.Sprintf(`(() => { - const items = %s; - for (const [key, value] of Object.entries(items)) localStorage.setItem(key, value); - return true; - })()`, string(localStorageJSON)), nil), - ) - return chromedp.Run(tabCtx, actions...) -} - -func CredentialsFromChromeDebugURL(ctx context.Context, debugURL string) (Credentials, error) { - if debugURL == "" { - debugURL = "http://127.0.0.1:9222" - } - allocCtx, cancel := chromedp.NewRemoteAllocator(ctx, debugURL) - defer cancel() - tabCtx, cancel := chromedp.NewContext(allocCtx) - defer cancel() - - localStorage := map[string]string{} - err := chromedp.Run(tabCtx, - chromedp.Navigate(DefaultChatURL), - chromedp.WaitReady("body", chromedp.ByQuery), - chromedp.Sleep(5*time.Second), - chromedp.Evaluate(`(() => { - const keys = [ - "chat:matrix-user-id", - "chat:matrix-device-id", - "chat:matrix-access-token", - "chat:access-token" - ]; - const out = {}; - for (const key of keys) out[key] = localStorage.getItem(key) || ""; - return out; - })()`, &localStorage), - ) - if err != nil { - return Credentials{}, err - } - return credentialsFromLocalStorage(localStorage) -} - -func CaptchaTokenProviderFromChromeDebugURL(debugURL string) CaptchaTokenProvider { - return func(ctx context.Context, req CaptchaRequest) (CaptchaResult, error) { - token, err := CaptchaTokenFromChromeDebugURL(ctx, debugURL, req) - if err != nil { - return CaptchaResult{}, err - } - return CaptchaResult{Token: token}, nil - } -} - -func CaptchaTokenFromChromeDebugURL(ctx context.Context, debugURL string, req CaptchaRequest) (string, error) { - if debugURL == "" { - debugURL = "http://127.0.0.1:9222" - } - if req.SiteKey == "" { - req.SiteKey = RedditLoginCaptchaSiteKey - } - if req.Action == "" { - req.Action = RedditLoginCaptchaAction - } - if req.PageURL == "" { - req.PageURL = DefaultRedditURL + "/login/" - } - allocCtx, cancel := chromedp.NewRemoteAllocator(ctx, debugURL) - defer cancel() - tabCtx, cancel := chromedp.NewContext(allocCtx) - defer cancel() - - var token string - err := chromedp.Run(tabCtx, - chromedp.Navigate(req.PageURL), - chromedp.WaitReady("body", chromedp.ByQuery), - chromedp.Sleep(2*time.Second), - chromedp.ActionFunc(func(ctx context.Context) error { - var blocked bool - if err := chromedp.Evaluate(`document.body && document.body.innerText.includes("You've been blocked by network security")`, &blocked).Do(ctx); err != nil { - return err - } - if blocked { - return ErrBrowserVerificationBlocked - } - return nil - }), - chromedp.Evaluate(req.EnterpriseExecuteJavaScript(), &token), - ) - if err != nil { - return "", err - } - if token == "" { - return "", errors.New("reddit login: browser reCAPTCHA did not return a token") - } - return token, nil -} - -func readStorageState(path string) (storageState, error) { - data, err := os.ReadFile(path) - if err != nil { - return storageState{}, err - } - var state storageState - if err := json.Unmarshal(data, &state); err != nil { - return storageState{}, err - } - return state, nil -} - -func credentialsFromLocalStorage(kv map[string]string) (Credentials, error) { - userID := parseJSONString(kv["chat:matrix-user-id"]) - deviceID := parseJSONString(kv["chat:matrix-device-id"]) - token := parseJSONString(kv["chat:matrix-access-token"]) - if token == "" && kv["chat:access-token"] != "" { - var tokenObj struct { - Token string `json:"token"` - } - _ = json.Unmarshal([]byte(kv["chat:access-token"]), &tokenObj) - token = tokenObj.Token - } - if userID == "" { - return Credentials{}, fmt.Errorf("reddit chat: Matrix user ID missing from localStorage") - } - if token == "" { - return Credentials{}, fmt.Errorf("reddit chat: Matrix access token missing from localStorage") - } - return Credentials{ - Homeserver: DefaultHomeserver, - AccessToken: token, - UserID: userID, - DeviceID: deviceID, - }, nil -} - -func parseJSONString(raw string) string { - if raw == "" { - return "" - } - var parsed string - if json.Unmarshal([]byte(raw), &parsed) == nil { - return parsed - } - return raw -} diff --git a/pkg/redditchat/client.go b/pkg/redditchat/client.go index 62d469c..d742e68 100644 --- a/pkg/redditchat/client.go +++ b/pkg/redditchat/client.go @@ -145,8 +145,29 @@ func (c *Client) Capabilities(ctx context.Context) (*mautrix.RespCapabilities, e return c.Matrix.Capabilities(ctx) } -func (c *Client) Sync(ctx context.Context, since string, timeoutMS int) (*mautrix.RespSync, error) { - return c.Matrix.SyncRequest(ctx, timeoutMS, since, redditSyncFilter(), false, event.PresenceOffline) +// SyncResponse includes Reddit's preview rooms, where pending DM account data +// (including Ignore) arrives. These are not joined rooms. +type SyncResponse struct { + NextBatch string `json:"next_batch"` + Rooms struct { + mautrix.RespSyncRooms + Peek map[id.RoomID]*mautrix.SyncJoinedRoom `json:"peek"` + } `json:"rooms"` +} + +func (c *Client) Sync(ctx context.Context, since string, timeoutMS int) (*SyncResponse, error) { + // Reddit omits m.reaction events whenever an inline filter is supplied, + // including an empty {} filter. Same-cursor native captures confirmed that + // leaving the parameter absent returns the complete reaction stream. + req := mautrix.ReqSync{Timeout: timeoutMS, Since: since, SetPresence: event.PresenceOffline} + var resp SyncResponse + _, err := c.Matrix.MakeFullRequest(ctx, mautrix.FullRequest{ + Method: http.MethodGet, + URL: c.Matrix.BuildURLWithQuery(mautrix.ClientURLPath{"v3", "sync"}, req.BuildQuery()), + ResponseJSON: &resp, + MaxAttempts: 1, // The connector's sync loop owns native retries. + }) + return &resp, err } func (c *Client) JoinedRooms(ctx context.Context) (*mautrix.RespJoinedRooms, error) { @@ -162,6 +183,13 @@ func (c *Client) GetEvent(ctx context.Context, roomID id.RoomID, eventID id.Even return c.Matrix.GetEvent(ctx, roomID, eventID) } +func (c *Client) Reactions(ctx context.Context, roomID id.RoomID, eventID id.EventID, from string) (*mautrix.RespGetRelations, error) { + return c.Matrix.GetRelations(ctx, roomID, eventID, &mautrix.ReqGetRelations{ + RelationType: event.RelAnnotation, EventType: event.EventReaction, + Dir: mautrix.DirectionBackward, From: from, Limit: 100, + }) +} + func (c *Client) SearchUsers(ctx context.Context, term string) (*UserDirectorySearchResponse, error) { var resp UserDirectorySearchResponse _, err := c.Matrix.MakeRequest(ctx, http.MethodPost, c.Matrix.BuildClientURL("v3", "user_directory", "search"), map[string]string{ @@ -297,8 +325,8 @@ func (c *Client) SendNotice(ctx context.Context, roomID id.RoomID, body string) return c.Matrix.SendNotice(ctx, roomID, body) } -func (c *Client) SendMessage(ctx context.Context, roomID id.RoomID, content *event.MessageEventContent) (*mautrix.RespSendEvent, error) { - return c.Matrix.SendMessageEvent(ctx, roomID, event.EventMessage, content) +func (c *Client) SendMessage(ctx context.Context, roomID id.RoomID, content *event.MessageEventContent, extra ...mautrix.ReqSendEvent) (*mautrix.RespSendEvent, error) { + return c.Matrix.SendMessageEvent(ctx, roomID, event.EventMessage, content, extra...) } func (c *Client) UploadMedia(ctx context.Context, filename, contentType string, r io.Reader) (*mautrix.RespMediaUpload, int, error) { @@ -325,11 +353,28 @@ func (c *Client) MediaConfig(ctx context.Context) (*MediaConfigResponse, error) } func (c *Client) DownloadMedia(ctx context.Context, uri id.ContentURI) (*http.Response, error) { - return c.Matrix.Download(ctx, uri) + if uri.IsEmpty() || uri.Homeserver != "reddit.com" { + return nil, errors.New("reddit chat: invalid media identity") + } + // Reddit Chat's retained images use media/v3/download. The generic SDK's + // authenticated client/v1/media/download route returns 404 on Reddit. + _, resp, err := c.Matrix.MakeFullRequestWithResp(ctx, mautrix.FullRequest{ + Method: http.MethodGet, + URL: c.Matrix.BuildURL(mautrix.MediaURLPath{"v3", "download", uri.Homeserver, uri.FileID}), + DontReadResponse: true, + }) + return resp, err } func (c *Client) DownloadMediaBytes(ctx context.Context, uri id.ContentURI) ([]byte, error) { - return c.Matrix.DownloadBytes(ctx, uri) + resp, err := c.DownloadMedia(ctx, uri) + if resp != nil && resp.Body != nil { + defer resp.Body.Close() + } + if err != nil { + return nil, err + } + return io.ReadAll(resp.Body) } func (c *Client) SendFile(ctx context.Context, roomID id.RoomID, filename, contentType string, r io.Reader) (*mautrix.RespSendEvent, error) { @@ -480,7 +525,3 @@ func existingRoomIDFromError(err error) id.RoomID { } return "" } - -func redditSyncFilter() string { - return `{"room":{"timeline":{"unread_thread_notifications":true,"not_types":["com.reddit.review_open","com.reddit.review_close"],"lazy_load_members":true},"state":{"lazy_load_members":true}}}` -} diff --git a/pkg/redditchat/login.go b/pkg/redditchat/login.go index 25d5de2..34bea12 100644 --- a/pkg/redditchat/login.go +++ b/pkg/redditchat/login.go @@ -3,11 +3,7 @@ package redditchat import ( "bytes" "context" - "crypto/hmac" - "crypto/sha1" - "encoding/base32" "encoding/base64" - "encoding/binary" "encoding/json" "errors" "fmt" @@ -18,10 +14,6 @@ import ( "net/url" "regexp" "strings" - "sync" - "time" - - "github.com/rs/zerolog" ) const ( @@ -43,8 +35,11 @@ const ( ) var ( - ErrCaptchaRequired = errors.New("reddit login: captcha token provider required") ErrBrowserVerificationBlocked = errors.New("reddit login: browser verification blocked") + ErrSessionUnavailable = errors.New("reddit login: saved session is unavailable") + ErrInvalidCredentials = errors.New("reddit login: username or password rejected") + ErrInvalidOTP = errors.New("reddit login: otp code rejected") + ErrSSORequired = errors.New("reddit login: account requires SSO login") ) type CaptchaRequest struct { @@ -59,12 +54,6 @@ type CaptchaResult struct { ClientVersion string } -type CaptchaTokenProvider func(ctx context.Context, req CaptchaRequest) (CaptchaResult, error) - -func (r CaptchaRequest) EnterpriseScriptURL() string { - return "https://www.google.com/recaptcha/enterprise.js?render=" + url.QueryEscape(firstNonEmpty(r.SiteKey, RedditLoginCaptchaSiteKey)) -} - func (r CaptchaRequest) EnterpriseExecuteJavaScript() string { siteKey, _ := json.Marshal(firstNonEmpty(r.SiteKey, RedditLoginCaptchaSiteKey)) action, _ := json.Marshal(firstNonEmpty(r.Action, RedditLoginCaptchaAction)) @@ -86,42 +75,13 @@ func (r CaptchaRequest) EnterpriseExecuteJavaScript() string { })()`, siteKey, action) } -type CaptchaRequiredError struct { - Request CaptchaRequest -} - -func (e *CaptchaRequiredError) Error() string { - return fmt.Sprintf("reddit login: captcha token required for site_key=%s action=%s page=%s", e.Request.SiteKey, e.Request.Action, e.Request.PageURL) -} - -func (e *CaptchaRequiredError) Unwrap() error { - return ErrCaptchaRequired -} - -type RedditLoginOptions struct { - Username string - Password string - TOTPCode string - TOTPSecret string - CaptchaTokenProvider CaptchaTokenProvider - HTTPClient *http.Client - UserAgent string - BaseURL string -} - -type RedditLoginResult struct { - Session *RedditSession - Credentials Credentials - Token RedditChatToken -} - type RedditSession struct { Client *http.Client BaseURL string UserAgent string CSRFToken string // ClientVersion is the X-Reddit-Client-Version sniffed from the login - // webview (see captchaToken). Empty until the first captcha step completes. + // webview. Empty until the first captcha step completes. ClientVersion string } @@ -137,68 +97,6 @@ type RedditChatToken struct { Expires int64 `json:"expires"` } -func LoginReddit(ctx context.Context, opts RedditLoginOptions) (*RedditLoginResult, error) { - if opts.Username == "" { - return nil, errors.New("reddit login: missing username") - } - if opts.Password == "" { - return nil, errors.New("reddit login: missing password") - } - session, err := NewRedditSession(opts.HTTPClient, opts.BaseURL, opts.UserAgent) - if err != nil { - return nil, err - } - loginPage, err := session.prepareLogin(ctx) - if err != nil { - return nil, err - } - if err := session.checkOIDCRequired(ctx, opts.Username, loginPage); err != nil { - return nil, err - } - if err := session.submitPassword(ctx, opts, loginPage); err != nil { - return nil, err - } - token, creds, err := CredentialsFromRedditSession(ctx, session) - if err != nil { - return nil, err - } - return &RedditLoginResult{ - Session: session, - Credentials: creds, - Token: token, - }, nil -} - -func NewFromRedditLogin(ctx context.Context, opts RedditLoginOptions) (*Client, *RedditSession, error) { - result, err := LoginReddit(ctx, opts) - if err != nil { - return nil, nil, err - } - client, err := New(result.Credentials) - if err != nil { - return nil, nil, err - } - return client, result.Session, nil -} - -func StaticCaptchaTokenProvider(tokens ...string) CaptchaTokenProvider { - var mu sync.Mutex - var next int - return func(ctx context.Context, req CaptchaRequest) (CaptchaResult, error) { - mu.Lock() - defer mu.Unlock() - if next >= len(tokens) { - return CaptchaResult{}, &CaptchaRequiredError{Request: req} - } - token := strings.TrimSpace(tokens[next]) - next++ - if token == "" { - return CaptchaResult{}, errors.New("reddit login: empty captcha token") - } - return CaptchaResult{Token: token}, nil - } -} - func NewRedditSession(httpClient *http.Client, baseURL, userAgent string) (*RedditSession, error) { if httpClient == nil { jar, err := cookiejar.New(nil) @@ -227,51 +125,6 @@ func NewRedditSession(httpClient *http.Client, baseURL, userAgent string) (*Redd }, nil } -func RedditSessionFromPlaywrightStorageState(path string) (*RedditSession, error) { - state, err := readStorageState(path) - if err != nil { - return nil, err - } - session, err := NewRedditSession(nil, "", "") - if err != nil { - return nil, err - } - u, err := url.Parse(session.BaseURL) - if err != nil { - return nil, err - } - cookies := make([]*http.Cookie, 0, len(state.Cookies)) - now := time.Now() - for _, item := range state.Cookies { - if !strings.Contains(item.Domain, "reddit.com") { - continue - } - cookie := &http.Cookie{ - Name: item.Name, - Value: item.Value, - Path: item.Path, - Domain: item.Domain, - Secure: item.Secure, - HttpOnly: item.HTTPOnly, - } - if item.Expires > 0 { - cookie.Expires = time.Unix(int64(item.Expires), 0) - if cookie.Expires.Before(now) { - continue - } - } - if !validCookieValue(cookie.Value) { - continue - } - cookies = append(cookies, cookie) - if item.Name == "csrf_token" { - session.CSRFToken = item.Value - } - } - session.Client.Jar.SetCookies(u, cookies) - return session, nil -} - func CredentialsFromRedditSession(ctx context.Context, session *RedditSession) (RedditChatToken, Credentials, error) { token, err := session.RefreshChatToken(ctx) if err != nil { @@ -289,11 +142,11 @@ func CredentialsFromRedditSession(ctx context.Context, session *RedditSession) ( func (s *RedditSession) RefreshChatToken(ctx context.Context) (RedditChatToken, error) { if s == nil { - return RedditChatToken{}, errors.New("reddit login: nil session") + return RedditChatToken{}, ErrSessionUnavailable } csrf := s.csrfToken() if csrf == "" { - return RedditChatToken{}, errors.New("reddit login: missing csrf_token cookie") + return RedditChatToken{}, fmt.Errorf("%w: missing csrf_token cookie", ErrSessionUnavailable) } form := url.Values{"csrf_token": {csrf}} req, err := s.newRequest(ctx, http.MethodPost, "/svc/shreddit/token", strings.NewReader(form.Encode())) @@ -305,20 +158,6 @@ func (s *RedditSession) RefreshChatToken(ctx context.Context) (RedditChatToken, req.Header.Set("Referer", s.BaseURL+"/chat/") req.Header.Set("X-Original-Referer", s.BaseURL+"/chat/") req.Header.Set("X-Reddit-Client-Version", s.clientVersion()) - // Diagnostic: confirm the session looks authenticated before the token POST. - log := zerolog.Ctx(ctx) - if log != nil { - var cookieNames []string - if u, perr := url.Parse(s.BaseURL); perr == nil && s.Client != nil && s.Client.Jar != nil { - for _, c := range s.Client.Jar.Cookies(u) { - cookieNames = append(cookieNames, c.Name) - } - } - log.Debug(). - Bool("has_csrf", csrf != ""). - Strs("cookie_names", cookieNames). - Msg("Refreshing reddit chat token") - } var token RedditChatToken if err := s.doJSON(req, &token); err != nil { return RedditChatToken{}, err @@ -342,7 +181,7 @@ func (s *RedditSession) prepareLogin(ctx context.Context) (string, error) { return "", redditStatusError(resp, body) } text := string(body) - if strings.Contains(text, "Please wait for verification") { + if strings.Contains(text, "Please wait for verification") || findFirst(text, `name=["'](js_challenge)["']`) != "" { nextPath, err := solveJSChallenge(text) if err != nil { return "", err @@ -377,7 +216,7 @@ func (s *RedditSession) prepareLogin(ctx context.Context) (string, error) { return text, nil } -func (s *RedditSession) checkOIDCRequired(ctx context.Context, username, loginPage string) error { +func (s *RedditSession) checkOIDCRequired(ctx context.Context, username string) error { body, err := json.Marshal(map[string]string{ "userIdentifier": username, "csrf_token": s.csrfToken(), @@ -399,87 +238,70 @@ func (s *RedditSession) checkOIDCRequired(ctx context.Context, username, loginPa return err } if resp.IsSSO { - return errors.New("reddit login: account requires SSO login") + return ErrSSORequired } return nil } -func (s *RedditSession) submitPassword(ctx context.Context, opts RedditLoginOptions, loginPage string) error { - captcha, err := opts.captchaToken(ctx, s.BaseURL, CaptchaStepPassword) - if err != nil { +// PrepareLogin initializes the session once; subsequent CAPTCHA and OTP steps +// continue with the same cookies and CSRF token. +func (s *RedditSession) PrepareLogin(ctx context.Context, username string) error { + if _, err := s.prepareLogin(ctx); err != nil { return err } + return s.checkOIDCRequired(ctx, username) +} + +func (s *RedditSession) CaptchaRequest(step CaptchaStep) CaptchaRequest { + return CaptchaRequest{SiteKey: RedditLoginCaptchaSiteKey, Action: RedditLoginCaptchaAction, + PageURL: s.BaseURL + "/login/", Step: step} +} + +func (s *RedditSession) loginForm(username, password string, captcha CaptchaResult) url.Values { if captcha.ClientVersion != "" { s.ClientVersion = captcha.ClientVersion } - form := url.Values{ - "username": {opts.Username}, - "password": {opts.Password}, - "recaptcha_token": {captcha.Token}, - "recaptcha_use_checkbox": {"false"}, - "recaptcha_action": {RedditLoginCaptchaAction}, - "csrf_token": {s.csrfToken()}, + return url.Values{ + "username": {username}, "password": {password}, + "recaptcha_token": {captcha.Token}, "recaptcha_use_checkbox": {"false"}, + "recaptcha_action": {RedditLoginCaptchaAction}, "csrf_token": {s.csrfToken()}, } - resp, body, err := s.postLoginForm(ctx, "/svc/shreddit/account/login", form) +} + +// SubmitPassword reports whether Reddit requires an OTP. It does not restart +// authentication or wait for user input: bridgev2 owns the intervening steps. +func (s *RedditSession) SubmitPassword(ctx context.Context, username, password string, captcha CaptchaResult) (needsOTP bool, err error) { + resp, body, err := s.postLoginForm(ctx, "/svc/shreddit/account/login", s.loginForm(username, password, captcha)) if err != nil { - return err + return false, err } switch resp.StatusCode { case http.StatusOK: - return checkLoginResponseBody(ctx, body) + return false, checkLoginResponseBody(body, ErrInvalidCredentials) case http.StatusAccepted: - otp, err := opts.otpCode(time.Now()) - if err != nil { - return err - } - captcha, err = opts.captchaToken(ctx, s.BaseURL, CaptchaStepOTP) - if err != nil { - return err - } - if captcha.ClientVersion != "" { - s.ClientVersion = captcha.ClientVersion - } - form := url.Values{ - "appOtp": {otp}, - "recaptcha_token": {captcha.Token}, - "recaptcha_use_checkbox": {"false"}, - "recaptcha_action": {RedditLoginCaptchaAction}, - "username": {opts.Username}, - "password": {opts.Password}, - "csrf_token": {s.csrfToken()}, - } - resp, body, err := s.postLoginForm(ctx, "/svc/shreddit/account/login/otp", form) - if err != nil { - return err - } - if resp.StatusCode != http.StatusOK { - return redditStatusError(resp, body) - } - return nil + return true, nil + case http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden: + return false, fmt.Errorf("%w: %w", ErrInvalidCredentials, redditStatusError(resp, body)) default: - return redditStatusError(resp, body) + return false, redditStatusError(resp, body) } } -func (opts RedditLoginOptions) captchaToken(ctx context.Context, baseURL string, step CaptchaStep) (CaptchaResult, error) { - req := CaptchaRequest{ - SiteKey: RedditLoginCaptchaSiteKey, - Action: RedditLoginCaptchaAction, - PageURL: strings.TrimRight(firstNonEmpty(baseURL, DefaultRedditURL), "/") + "/login/", - Step: step, - } - if opts.CaptchaTokenProvider == nil { - return CaptchaResult{}, &CaptchaRequiredError{Request: req} - } - result, err := opts.CaptchaTokenProvider(ctx, req) +func (s *RedditSession) SubmitOTP(ctx context.Context, username, password, otp string, captcha CaptchaResult) error { + form := s.loginForm(username, password, captcha) + form.Set("appOtp", otp) + resp, body, err := s.postLoginForm(ctx, "/svc/shreddit/account/login/otp", form) if err != nil { - return CaptchaResult{}, err + return err } - result.Token = strings.TrimSpace(result.Token) - if result.Token == "" { - return CaptchaResult{}, errors.New("reddit login: empty captcha token") + switch resp.StatusCode { + case http.StatusOK: + return checkLoginResponseBody(body, ErrInvalidOTP) + case http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden: + return fmt.Errorf("%w: %w", ErrInvalidOTP, redditStatusError(resp, body)) + default: + return redditStatusError(resp, body) } - return result, nil } func (s *RedditSession) postLoginForm(ctx context.Context, path string, form url.Values) (*http.Response, []byte, error) { @@ -535,7 +357,7 @@ func (s *RedditSession) doJSON(req *http.Request, out any) error { return redditStatusError(resp, body) } if err := json.Unmarshal(body, out); err != nil { - return fmt.Errorf("reddit login: decode %s: %w", req.URL.String(), err) + return fmt.Errorf("reddit login: decode response: %w", err) } return nil } @@ -570,54 +392,14 @@ func (s *RedditSession) csrfToken() string { return "" } -func (opts RedditLoginOptions) otpCode(now time.Time) (string, error) { - if opts.TOTPCode != "" { - return opts.TOTPCode, nil - } - if opts.TOTPSecret == "" { - return "", errors.New("reddit login: otp required; provide TOTPCode or TOTPSecret") - } - return GenerateTOTP(opts.TOTPSecret, now) -} - -func GenerateTOTP(secret string, now time.Time) (string, error) { - key, err := decodeBase32Secret(secret) - if err != nil { - return "", err - } - counter := uint64(now.Unix() / 30) - var msg [8]byte - binary.BigEndian.PutUint64(msg[:], counter) - sum := hmac.New(sha1.New, key) - _, _ = sum.Write(msg[:]) - hash := sum.Sum(nil) - offset := hash[len(hash)-1] & 0x0f - code := (int(hash[offset])&0x7f)<<24 | - (int(hash[offset+1])&0xff)<<16 | - (int(hash[offset+2])&0xff)<<8 | - (int(hash[offset+3]) & 0xff) - return fmt.Sprintf("%06d", code%1000000), nil -} - -func decodeBase32Secret(secret string) ([]byte, error) { - cleaned := strings.ToUpper(strings.TrimSpace(secret)) - cleaned = strings.TrimRight(cleaned, "=") - if cleaned == "" { - return nil, errors.New("reddit login: empty TOTP secret") - } - if rem := len(cleaned) % 8; rem != 0 { - cleaned += strings.Repeat("=", 8-rem) - } - key, err := base32.StdEncoding.DecodeString(cleaned) - if err != nil { - return nil, fmt.Errorf("reddit login: decode TOTP secret: %w", err) - } - return key, nil -} - func solveJSChallenge(text string) (string, error) { seed := findFirst(text, `await\(async e=>e\+e\)\("([^"]+)"\)`) - token := findFirst(text, `name="token" value="([^"]+)"`) + tokenName := "jsc_token" + token := findFirst(text, `name="jsc_token" value="([^"]+)"`) + if token == "" { + tokenName = "token" + token = findFirst(text, `name="token" value="([^"]+)"`) + } action := findFirst(text, `]+action="([^"]+)"`) if seed == "" || token == "" { return "", errors.New("reddit login: javascript verification challenge not found") @@ -628,7 +410,7 @@ func solveJSChallenge(text string) (string, error) { values := url.Values{ "solution": {seed + seed}, "js_challenge": {"1"}, - "token": {token}, + tokenName: {token}, "jsc_orig_r": {findFirst(text, `name="jsc_orig_r" value="([^"]*)"`)}, } return action + "?" + values.Encode(), nil @@ -697,17 +479,7 @@ func matrixWhoamiDevice(ctx context.Context, httpClient *http.Client, token stri return whoami.DeviceID, nil } -func checkLoginResponseBody(ctx context.Context, body []byte) error { - if log := zerolog.Ctx(ctx); log != nil { - preview := strings.TrimSpace(string(body)) - if len(preview) > 300 { - preview = preview[:300] - } - log.Debug(). - Int("body_len", len(body)). - Str("body_preview", preview). - Msg("Reddit password step returned 200") - } +func checkLoginResponseBody(body []byte, rejected error) error { var parsed struct { Success *bool `json:"success"` Error json.RawMessage `json:"error"` @@ -721,33 +493,37 @@ func checkLoginResponseBody(ctx context.Context, body []byte) error { (len(parsed.Error) > 0 && string(parsed.Error) != "null") || (len(parsed.Reason) > 0 && string(parsed.Reason) != "null") if failed { - msg := strings.TrimSpace(string(body)) - if len(msg) > 500 { - msg = msg[:500] - } - return fmt.Errorf("reddit login: password step rejected: %s", msg) + return rejected } return nil } +type LoginHTTPError struct { + Operation string + StatusCode int +} + +func (e *LoginHTTPError) Error() string { + return fmt.Sprintf("reddit login: %s request failed: status=%d", e.Operation, e.StatusCode) +} + func redditStatusError(resp *http.Response, body []byte) error { - msg := strings.TrimSpace(string(body)) - if len(msg) > 500 { - msg = msg[:500] - } - if msg == "" { - msg = resp.Status - } - contentType := resp.Header.Get("Content-Type") - location := resp.Header.Get("Location") - extra := "" - for _, h := range []string{"Cf-Ray", "Cf-Mitigated", "X-Ratelimit-Remaining"} { - if v := resp.Header.Get(h); v != "" { - extra += fmt.Sprintf(" %s=%q", strings.ToLower(h), v) + // Authentication responses, redirect URLs and headers may contain session material. + // Keep errors safe to forward to bridge logs and client login notices. + operation := "sign-in" + if resp.Request != nil && resp.Request.URL != nil { + switch resp.Request.URL.Path { + case "/svc/shreddit/account/login/otp": + operation = "two-factor verification" + case "/svc/shreddit/token": + operation = "chat session" + case "/svc/shreddit/account/login/check_is_oidc_required": + operation = "account lookup" + case "/svc/shreddit/update-recaptcha": + operation = "verification setup" } } - return fmt.Errorf("reddit login: %s %s failed: status=%d content-type=%q location=%q%s body=%s", - resp.Request.Method, resp.Request.URL.String(), resp.StatusCode, contentType, location, extra, msg) + return &LoginHTTPError{Operation: operation, StatusCode: resp.StatusCode} } func findFirst(text string, patterns ...string) string { @@ -760,13 +536,3 @@ func findFirst(text string, patterns ...string) string { } return "" } - -func validCookieValue(value string) bool { - for i := 0; i < len(value); i++ { - c := value[i] - if c < 0x21 || c == '"' || c == ';' || c == '\\' || c == 0x7f { - return false - } - } - return true -} diff --git a/pkg/redditchat/login_challenge_test.go b/pkg/redditchat/login_challenge_test.go new file mode 100644 index 0000000..c4d02f6 --- /dev/null +++ b/pkg/redditchat/login_challenge_test.go @@ -0,0 +1,54 @@ +package redditchat + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "testing" +) + +func TestPrepareLoginHandlesVerificationPageVariants(t *testing.T) { + for _, tc := range []struct { + name, tokenName, heading string + }{ + {"current_without_heading", "jsc_token", ""}, + {"legacy", "token", "Please wait for verification"}, + } { + t.Run(tc.name, func(t *testing.T) { + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + if r.Method != http.MethodGet || r.URL.Path != "/login/" { + t.Errorf("unexpected login request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusBadRequest) + return + } + if r.URL.Query().Get("js_challenge") == "1" { + q := r.URL.Query() + if q.Get(tc.tokenName) != "test-challenge-token" || q.Get("solution") != "seedseed" || q.Get("jsc_orig_r") != "/login/" { + t.Error("verification form was not preserved") + w.WriteHeader(http.StatusBadRequest) + return + } + http.SetCookie(w, &http.Cookie{Name: "csrf_token", Value: "test-csrf", Path: "/"}) + _, _ = w.Write([]byte("Login form")) + return + } + _, _ = fmt.Fprintf(w, `%s
`, tc.heading, tc.tokenName) + })) + defer server.Close() + session, err := NewRedditSession(nil, server.URL, "test-client") + if err != nil { + t.Fatal(err) + } + _, err = session.prepareLogin(context.Background()) + if err != nil { + t.Fatalf("preparing login failed: %v", err) + } + if requests != 2 || session.CSRFToken != "test-csrf" { + t.Fatalf("login preparation incomplete: requests=%d csrf_present=%v", requests, session.CSRFToken != "") + } + }) + } +} diff --git a/pkg/redditchat/login_errors_test.go b/pkg/redditchat/login_errors_test.go new file mode 100644 index 0000000..cf44291 --- /dev/null +++ b/pkg/redditchat/login_errors_test.go @@ -0,0 +1,50 @@ +package redditchat + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" +) + +func TestLoginRejectionsMatchSentinels(t *testing.T) { + submitPassword := func(s *RedditSession) error { + _, err := s.SubmitPassword(context.Background(), "user", "pass", CaptchaResult{Token: "token"}) + return err + } + submitOTP := func(s *RedditSession) error { + return s.SubmitOTP(context.Background(), "user", "pass", "123456", CaptchaResult{Token: "token"}) + } + checkSSO := func(s *RedditSession) error { + return s.checkOIDCRequired(context.Background(), "user") + } + for _, tc := range []struct { + name string + status int + body string + call func(*RedditSession) error + want error + }{ + {"password status", http.StatusUnauthorized, "", submitPassword, ErrInvalidCredentials}, + {"password body", http.StatusOK, `{"success":false}`, submitPassword, ErrInvalidCredentials}, + {"otp status", http.StatusBadRequest, "", submitOTP, ErrInvalidOTP}, + {"otp body", http.StatusOK, `{"success":false}`, submitOTP, ErrInvalidOTP}, + {"sso", http.StatusOK, `{"is_sso":true}`, checkSSO, ErrSSORequired}, + } { + t.Run(tc.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(tc.status) + _, _ = w.Write([]byte(tc.body)) + })) + defer server.Close() + session, err := NewRedditSession(nil, server.URL, "test-client") + if err != nil { + t.Fatal(err) + } + if err = tc.call(session); !errors.Is(err, tc.want) { + t.Fatalf("got %v, want %v", err, tc.want) + } + }) + } +} diff --git a/pkg/redditchat/login_redaction_test.go b/pkg/redditchat/login_redaction_test.go new file mode 100644 index 0000000..8f77ee2 --- /dev/null +++ b/pkg/redditchat/login_redaction_test.go @@ -0,0 +1,56 @@ +package redditchat + +import ( + "net/http" + "strings" + "testing" +) + +func TestLoginResponsesDoNotExposeSessionMaterial(t *testing.T) { + const secret = "synthetic-login-secret-do-not-log" + for _, tc := range []struct { + name string + body string + wantErr bool + }{ + {"success", `{"success":true,"session":"` + secret + `"}`, false}, + {"failure", `{"success":false,"error":"` + secret + `"}`, true}, + {"reason", `{"reason":"` + secret + `"}`, true}, + {"non_json", secret, false}, + } { + t.Run(tc.name, func(t *testing.T) { + err := checkLoginResponseBody([]byte(tc.body), ErrInvalidCredentials) + if (err != nil) != tc.wantErr { + t.Fatalf("unexpected rejection result: %v", err) + } + if err != nil && strings.Contains(err.Error(), secret) { + t.Fatal("authentication response leaked into logs or client error") + } + }) + } +} + +func TestLoginHTTPErrorDoesNotExposeResponseOrRedirect(t *testing.T) { + const secret = "synthetic-redirect-secret-do-not-log" + req, err := http.NewRequest(http.MethodPost, "https://www.reddit.com/login?token="+secret, nil) + if err != nil { + t.Fatal(err) + } + resp := &http.Response{ + StatusCode: http.StatusForbidden, + Status: secret, + Request: req, + Header: http.Header{ + "Location": {"https://www.reddit.com/?token=" + secret}, + "Content-Type": {secret}, + "Cf-Ray": {secret}, + }, + } + result := redditStatusError(resp, []byte(secret)) + if strings.Contains(result.Error(), secret) { + t.Fatal("authentication HTTP error leaked response or URL data") + } + if !strings.Contains(result.Error(), "status=403") { + t.Fatal("HTTP status was lost") + } +} diff --git a/pkg/redditchat/reactions.go b/pkg/redditchat/reactions.go new file mode 100644 index 0000000..a7a117b --- /dev/null +++ b/pkg/redditchat/reactions.go @@ -0,0 +1,35 @@ +package redditchat + +import ( + _ "embed" + "encoding/json" +) + +// Native Reddit Chat's reaction picker, observed 2026-10-06. Reactions use +// static i.redd.it asset basenames, not Unicode. Keep this separate from the +// connector's optional Unicode aliases and reject unrecognized outbound keys. +// +//go:embed reactions.json +var reactionCatalogJSON []byte + +type ReactionAsset struct { + Name string `json:"name"` + Key string `json:"key"` +} + +func ReactionAssets() []ReactionAsset { + var assets []ReactionAsset + if err := json.Unmarshal(reactionCatalogJSON, &assets); err != nil { + panic(err) // Invalid embedded build input, never provider data. + } + return assets +} + +func ReactionAssetByKey(key string) (ReactionAsset, bool) { + for _, asset := range ReactionAssets() { + if asset.Key == key { + return asset, true + } + } + return ReactionAsset{}, false +} diff --git a/pkg/redditchat/reactions.json b/pkg/redditchat/reactions.json new file mode 100644 index 0000000..ed02dc5 --- /dev/null +++ b/pkg/redditchat/reactions.json @@ -0,0 +1,186 @@ +[ + { + "name": "care", + "key": "8r21ukpfa7081.gif" + }, + { + "name": "cry", + "key": "mag7v6tfa7081.gif" + }, + { + "name": "disapproval", + "key": "iuqmp7ufa7081.gif" + }, + { + "name": "dizzy_face", + "key": "zn7iubvfa7081.gif" + }, + { + "name": "doge", + "key": "ytv3x0sfa7081.png" + }, + { + "name": "facepalm", + "key": "qzl5vyxfa7081.gif" + }, + { + "name": "feels_bad_man", + "key": "mi2jolzfa7081.gif" + }, + { + "name": "feels_good_man", + "key": "k7ry7t1ga7081.gif" + }, + { + "name": "flip_out", + "key": "tspuf53ga7081.gif" + }, + { + "name": "flushed", + "key": "evwks24ga7081.gif" + }, + { + "name": "give_upvote", + "key": "dfxygs4ga7081.gif" + }, + { + "name": "grimacing", + "key": "ax7wu47ga7081.gif" + }, + { + "name": "grin", + "key": "uy83aa8ga7081.gif" + }, + { + "name": "heart_eyes", + "key": "t2r5xc9ga7081.gif" + }, + { + "name": "heart_eyes_rainbow", + "key": "sjs1a2fyt7081.gif" + }, + { + "name": "hug", + "key": "ksz4fmaga7081.gif" + }, + { + "name": "joy", + "key": "jvuspmbga7081.gif" + }, + { + "name": "kissing_heart", + "key": "mp9zclcga7081.gif" + }, + { + "name": "laughing", + "key": "9ut7iedga7081.gif" + }, + { + "name": "money_face", + "key": "ul2w17ega7081.gif" + }, + { + "name": "neutral_face", + "key": "0ku6twega7081.gif" + }, + { + "name": "no_mouth", + "key": "cdnhvqfga7081.gif" + }, + { + "name": "put_back", + "key": "b5s6cohga7081.gif" + }, + { + "name": "rage", + "key": "av9z8iiga7081.gif" + }, + { + "name": "scream", + "key": "00brcfjga7081.gif" + }, + { + "name": "shrug", + "key": "pleyoikga7081.gif" + }, + { + "name": "sleep", + "key": "m7uy86lga7081.gif" + }, + { + "name": "slightly_smiling", + "key": "8kw138jyt7081.gif" + }, + { + "name": "smile", + "key": "wbrgz1nga7081.gif" + }, + { + "name": "snoo", + "key": "fsg2a1oga7081.gif" + }, + { + "name": "sob", + "key": "8zx5ixoga7081.gif" + }, + { + "name": "stuck_out_tongue", + "key": "t5bqwxdyt7081.gif" + }, + { + "name": "sunglasses", + "key": "xjls1pqga7081.gif" + }, + { + "name": "surprise", + "key": "7rc03trga7081.gif" + }, + { + "name": "sweat", + "key": "d45pfmsga7081.gif" + }, + { + "name": "sweat_smile", + "key": "g6akcitga7081.gif" + }, + { + "name": "table", + "key": "t1djdguga7081.gif" + }, + { + "name": "table_flip", + "key": "19b5q4vga7081.gif" + }, + { + "name": "thinking_face_hmm", + "key": "4zhlw4iyt7081.gif" + }, + { + "name": "thumbs_down", + "key": "3s0glewga7081.gif" + }, + { + "name": "thumbs_up", + "key": "7url39xga7081.gif" + }, + { + "name": "trollface", + "key": "d2kn6yxga7081.gif" + }, + { + "name": "upvote", + "key": "foyijyyga7081.gif" + }, + { + "name": "wave", + "key": "2o3aooqfa7081.gif" + }, + { + "name": "wink", + "key": "3i2arwzga7081.gif" + }, + { + "name": "yummy", + "key": "79opsq0ha7081.gif" + } +] diff --git a/pkg/redditchat/sync_test.go b/pkg/redditchat/sync_test.go new file mode 100644 index 0000000..98266e8 --- /dev/null +++ b/pkg/redditchat/sync_test.go @@ -0,0 +1,61 @@ +package redditchat + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "maunium.net/go/mautrix/event" +) + +func TestSyncDecodesRedditPreviewWithoutJoining(t *testing.T) { + var response SyncResponse + err := json.Unmarshal([]byte(`{"next_batch":"next","rooms":{"join":{"!joined:reddit.com":{}},"invite":{"!pending:reddit.com":{"invite_state":{"events":[]}}},"peek":{"!pending:reddit.com":{"account_data":{"events":[{"type":"com.reddit.hidden_chat","content":{"hidden":true}}]}}}}}`), &response) + if err != nil { + t.Fatal(err) + } + if response.NextBatch != "next" || len(response.Rooms.Join) != 1 || len(response.Rooms.Invite) != 1 || len(response.Rooms.Peek) != 1 { + t.Fatalf("native sync sections were lost or combined: %+v", response) + } + if response.Rooms.Join["!pending:reddit.com"] != nil || len(response.Rooms.Peek["!pending:reddit.com"].AccountData.Events) != 1 { + t.Fatal("preview became a join or lost account data") + } +} + +// Exercise the actual SDK request and JSON decoder: Reddit suppresses +// reactions even for filter={}, without returning an HTTP error. +func TestSyncReceivesReactionsWithoutInlineFilter(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + if req.URL.Path != "/_matrix/client/v3/sync" || req.URL.Query().Get("since") != "previous" { + t.Error("sync lost its endpoint or durable cursor") + } + if req.URL.Query().Has("filter") { + _, _ = io.WriteString(w, `{"next_batch":"next","rooms":{"join":{"!room:reddit.com":{"timeline":{"events":[]}}}}}`) + return + } + _, _ = io.WriteString(w, `{"next_batch":"next","rooms":{"join":{"!room:reddit.com":{"timeline":{"events":[{"type":"m.reaction","event_id":"$reaction","sender":"@t2_b:reddit.com","content":{"m.relates_to":{"event_id":"$message","rel_type":"m.annotation","key":"jvuspmbga7081.gif"}}},{"type":"m.room.redaction","event_id":"$removal","sender":"@t2_b:reddit.com","redacts":"$reaction","content":{}}]}}}}}`) + })) + defer srv.Close() + client, err := New(Credentials{Homeserver: srv.URL, AccessToken: "test", UserID: "@t2_a:reddit.com"}) + if err != nil { + t.Fatal(err) + } + resp, err := client.Sync(context.Background(), "previous", 0) + if err != nil { + t.Fatal(err) + } + room := resp.Rooms.Join["!room:reddit.com"] + if room == nil || len(room.Timeline.Events) != 2 || resp.NextBatch != "next" { + t.Fatal("successful sync silently lost reaction events") + } + reaction, removal := room.Timeline.Events[0], room.Timeline.Events[1] + if reaction.Type != event.EventReaction || removal.Type != event.EventRedaction || removal.Redacts != reaction.ID { + t.Fatal("decoded event types or removal identity cannot reach the connector") + } + if err = reaction.Content.ParseRaw(reaction.Type); err != nil || reaction.Content.AsReaction().RelatesTo.Key != "jvuspmbga7081.gif" { + t.Fatal("reaction relation was lost during decoding", err) + } +} diff --git a/pkg/redditchat/threads.go b/pkg/redditchat/threads.go new file mode 100644 index 0000000..ef8f581 --- /dev/null +++ b/pkg/redditchat/threads.go @@ -0,0 +1,108 @@ +package redditchat + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "strconv" + "strings" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +type sequencedEvents struct { + Events []json.RawMessage `json:"events"` +} + +func parseSequencedEvent(raw json.RawMessage) (*event.Event, string, error) { + var sequence struct { + ID string `json:"seq_id"` + } + var evt event.Event + if err := json.Unmarshal(raw, &sequence); err != nil { + return nil, "", err + } + if err := json.Unmarshal(raw, &evt); err != nil { + return nil, "", err + } + return &evt, sequence.ID, nil +} + +// ThreadMessages follows the sequenced /events contract used by Reddit Chat. +// The ordinary Matrix /relations endpoint returns no thread replies. Resolve +// the stable event ID to a native sequence, then paginate its child stream. +func (c *Client) ThreadMessages(ctx context.Context, room id.RoomID, root id.EventID, from string, limit int) (*mautrix.RespMessages, error) { + if room == "" || root == "" { + return nil, errors.New("thread history requires a room and root") + } + if limit <= 0 { + limit = 100 + } + limit = min(limit, 100) + query := map[string]string{"event_id": string(root), "dir": "b", "before": "1", "after": "1"} + var contextEvents sequencedEvents + _, err := c.Matrix.MakeRequest(ctx, http.MethodGet, c.Matrix.BuildURLWithQuery(mautrix.ClientURLPath{"v3", "rooms", room, "events"}, query), nil, &contextEvents) + if err != nil { + return nil, err + } + parent := "" + for _, raw := range contextEvents.Events { + evt, sequence, err := parseSequencedEvent(raw) + if err != nil { + return nil, err + } + if evt.ID != root { + continue + } + if parent != "" || (evt.RoomID != "" && evt.RoomID != room) { + return nil, errors.New("thread root has conflicting identity") + } + if _, err = strconv.ParseUint(sequence, 10, 64); err != nil { + return nil, errors.New("thread root has an invalid native sequence") + } + parent = sequence + } + if parent == "" { + return nil, errors.New("thread root was not returned by Reddit") + } + query = map[string]string{"parent": parent, "dir": "b", "before": strconv.Itoa(limit)} + var boundary uint64 + if from != "" { + boundary, err = strconv.ParseUint(from, 10, 64) + if err != nil { + return nil, errors.New("invalid thread history cursor") + } + query["seq"], query["before"], query["after"] = from, strconv.Itoa(limit-1), "0" + } + var page sequencedEvents + _, err = c.Matrix.MakeRequest(ctx, http.MethodGet, c.Matrix.BuildURLWithQuery(mautrix.ClientURLPath{"v3", "rooms", room, "events"}, query), nil, &page) + if err != nil { + return nil, err + } + result := &mautrix.RespMessages{Start: from} + var oldest uint64 + for i, raw := range page.Events { + evt, sequence, err := parseSequencedEvent(raw) + if err != nil { + return nil, err + } + child, ok := strings.CutPrefix(sequence, parent+".") + seq, parseErr := strconv.ParseUint(child, 10, 64) + if !ok || parseErr != nil || (from != "" && seq > boundary) || (evt.RoomID != "" && evt.RoomID != room) { + return nil, errors.New("thread history event is outside the requested stream") + } + if i == 0 || seq < oldest { + oldest = seq + } + result.Chunk = append(result.Chunk, evt) + } + // Reddit's native client continues at the preceding sequence, including on + // short pages. Sequence zero is the explicit beginning of the child stream. + if len(page.Events) > 0 && oldest > 0 { + result.End = strconv.FormatUint(oldest-1, 10) + } + return result, nil +} diff --git a/pkg/redditchat/threads_test.go b/pkg/redditchat/threads_test.go new file mode 100644 index 0000000..3be8b8b --- /dev/null +++ b/pkg/redditchat/threads_test.go @@ -0,0 +1,112 @@ +package redditchat + +import ( + "context" + "fmt" + "io" + "net/http" + "net/http/httptest" + "testing" +) + +func threadTestClient(t *testing.T, handler http.HandlerFunc) *Client { + t.Helper() + srv := httptest.NewServer(handler) + t.Cleanup(srv.Close) + client, err := New(Credentials{Homeserver: srv.URL, AccessToken: "test", UserID: "@t2_a:reddit.com"}) + if err != nil { + t.Fatal(err) + } + return client +} + +func TestThreadHistoryUsesNativeSequencePagination(t *testing.T) { + calls := 0 + client := threadTestClient(t, func(w http.ResponseWriter, req *http.Request) { + calls++ + q := req.URL.Query() + if req.URL.Path != "/_matrix/client/v3/rooms/!room:reddit.com/events" || q.Get("dir") != "b" { + t.Error("wrong thread endpoint") + } + if q.Get("event_id") == "$root" { + if q.Get("before") != "1" || q.Get("after") != "1" { + t.Error("wrong root context request") + } + _, _ = io.WriteString(w, `{"events":[{"event_id":"$adjacent","seq_id":"25"},{"event_id":"$root","seq_id":"24"}]}`) + return + } + if q.Get("parent") != "24" { + t.Error("thread parent must be the native root sequence") + } + if !q.Has("seq") { + if q.Get("before") != "1" || q.Has("after") { + t.Error("wrong latest page request") + } + _, _ = io.WriteString(w, `{"last_seq_id":"24.1","events":[{"event_id":"$second","seq_id":"24.1","type":"m.room.message","content":{"msgtype":"m.text","body":"second"}}]}`) + } else { + if q.Get("seq") != "0" || q.Get("before") != "0" || q.Get("after") != "0" { + t.Error("continuation must include the requested sequence") + } + _, _ = io.WriteString(w, `{"last_seq_id":"24.1","events":[{"event_id":"$first","seq_id":"24.0","type":"m.room.message","content":{"msgtype":"m.text","body":"first"}}]}`) + } + }) + page, err := client.ThreadMessages(context.Background(), "!room:reddit.com", "$root", "", 1) + if err != nil || len(page.Chunk) != 1 || page.Chunk[0].ID != "$second" || page.End != "0" { + t.Fatalf("wrong latest page: %+v, %v", page, err) + } + page, err = client.ThreadMessages(context.Background(), "!room:reddit.com", "$root", page.End, 1) + if err != nil || len(page.Chunk) != 1 || page.Chunk[0].ID != "$first" || page.Start != "0" || page.End != "" || calls != 4 { + t.Fatalf("wrong terminal page: %+v, %v (%d calls)", page, err, calls) + } +} + +func TestThreadHistoryRejectsWrongStream(t *testing.T) { + for name, response := range map[string]string{ + "wrong root": `{"events":[{"event_id":"$other","seq_id":"24"}]}`, + "wrong root room": `{"events":[{"event_id":"$root","room_id":"!other:reddit.com","seq_id":"24"}]}`, + "duplicate root": `{"events":[{"event_id":"$root","seq_id":"24"},{"event_id":"$root","seq_id":"25"}]}`, + "nested root": `{"events":[{"event_id":"$root","seq_id":"24.1"}]}`, + "missing sequence": `{"events":[{"event_id":"$root"}]}`, + } { + t.Run(name, func(t *testing.T) { + client := threadTestClient(t, func(w http.ResponseWriter, req *http.Request) { _, _ = io.WriteString(w, response) }) + if _, err := client.ThreadMessages(context.Background(), "!room:reddit.com", "$root", "", 10); err == nil { + t.Fatal("invalid root accepted") + } + }) + } + for _, child := range []string{ + `{"event_id":"$reply","seq_id":"25.0"}`, + `{"event_id":"$reply","seq_id":"24.3"}`, + `{"event_id":"$reply","seq_id":"24.0","room_id":"!other:reddit.com"}`, + `{"event_id":"$reply","seq_id":"24.-1"}`, + `null`, + } { + t.Run(child, func(t *testing.T) { + client := threadTestClient(t, func(w http.ResponseWriter, req *http.Request) { + if req.URL.Query().Has("event_id") { + _, _ = io.WriteString(w, `{"events":[{"event_id":"$root","seq_id":"24"}]}`) + } else { + _, _ = fmt.Fprintf(w, `{"events":[%s]}`, child) + } + }) + if _, err := client.ThreadMessages(context.Background(), "!room:reddit.com", "$root", "2", 10); err == nil { + t.Fatal("invalid child stream accepted") + } + }) + } +} + +func TestThreadHistoryShortPageIsNotTerminal(t *testing.T) { + client := threadTestClient(t, func(w http.ResponseWriter, req *http.Request) { + if req.URL.Query().Has("event_id") { + _, _ = io.WriteString(w, `{"events":[{"event_id":"$root","seq_id":"0"}]}`) + } else { + _, _ = io.WriteString(w, `{"events":[{"event_id":"$reply","seq_id":"0.8"}]}`) + } + }) + page, err := client.ThreadMessages(context.Background(), "!room:reddit.com", "$root", "", 100) + if err != nil || len(page.Chunk) != 1 || page.End != "7" { + t.Fatalf("short page lost its continuation: %+v, %v", page, err) + } +}