├── .dockerignore ├── .editorconfig ├── .github ├── ISSUE_TEMPLATE │ ├── bug.md │ ├── config.yml │ └── enhancement.md └── workflows │ ├── go.yml │ └── stale.yml ├── .gitignore ├── .gitlab-ci.yml ├── .idea └── icon.svg ├── .pre-commit-config.yaml ├── CHANGELOG.md ├── Dockerfile ├── Dockerfile.ci ├── LICENSE ├── LICENSE.exceptions ├── README.md ├── ROADMAP.md ├── build.sh ├── cmd └── mautrix-twitter │ ├── legacymigrate.go │ ├── legacymigrate.sql │ ├── legacyprovision.go │ └── main.go ├── docker-run.sh ├── go.mod ├── go.sum └── pkg ├── connector ├── backfill.go ├── capabilities.go ├── chatinfo.go ├── chatsync.go ├── client.go ├── config.go ├── connector.go ├── dbmeta.go ├── directmedia.go ├── example-config.yaml ├── handlematrix.go ├── handletwit.go ├── ids.go ├── login.go ├── msgconv.go ├── push.go ├── startchat.go └── twitterfmt │ ├── convert.go │ └── convert_test.go └── twittermeow ├── README.md ├── account.go ├── client.go ├── cookies └── cookies.go ├── crypto ├── animation.go └── transaction.go ├── data ├── endpoints │ └── endpoints.go ├── payload │ ├── form.go │ ├── jot.go │ └── json.go ├── response │ ├── account.go │ ├── inbox.go │ ├── media.go │ ├── messaging.go │ ├── search.go │ └── stream.go └── types │ ├── entities.go │ ├── event.go │ ├── http.go │ ├── messaging.go │ └── user.go ├── errors.go ├── headers.go ├── http.go ├── jot_client.go ├── media.go ├── messaging.go ├── methods ├── html.go └── methods.go ├── polling.go ├── search.go ├── session_loader.go └── stream_client.go /.dockerignore: -------------------------------------------------------------------------------- 1 | .editorconfig 2 | logs 3 | .venv 4 | start 5 | config.yaml 6 | registration.yaml 7 | *.db 8 | *.pickle 9 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | indent_size = 4 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.{yaml,yml,sql}] 12 | indent_style = space 13 | 14 | [{.gitlab-ci.yml,.github/workflows/*.yml}] 15 | indent_size = 2 16 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: If something is definitely wrong in the bridge (rather than just a setup issue), 4 | file a bug report. Remember to include relevant logs. 5 | labels: bug 6 | 7 | --- 8 | 9 | 15 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | contact_links: 2 | - name: Troubleshooting docs & FAQ 3 | url: https://docs.mau.fi/bridges/general/troubleshooting.html 4 | about: Check this first if you're having problems setting up the bridge. 5 | - name: Support room 6 | url: https://matrix.to/#/#twitter:maunium.net 7 | about: For setup issues not answered by the troubleshooting docs, ask in the Matrix room. 8 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/enhancement.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Enhancement request 3 | about: Submit a feature request or other suggestion 4 | labels: enhancement 5 | 6 | --- 7 | -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | name: Go 2 | 3 | on: [push, pull_request] 4 | 5 | env: 6 | GOTOOLCHAIN: local 7 | 8 | jobs: 9 | lint: 10 | runs-on: ubuntu-latest 11 | strategy: 12 | fail-fast: false 13 | matrix: 14 | go-version: ["1.23", "1.24"] 15 | name: Lint ${{ matrix.go-version == '1.24' && '(latest)' || '(old)' }} 16 | 17 | steps: 18 | - uses: actions/checkout@v4 19 | 20 | - name: Set up Go 21 | uses: actions/setup-go@v5 22 | with: 23 | go-version: ${{ matrix.go-version }} 24 | cache: true 25 | 26 | - name: Install libolm 27 | run: sudo apt-get install libolm-dev libolm3 28 | 29 | - name: Install dependencies 30 | run: | 31 | go install golang.org/x/tools/cmd/goimports@latest 32 | go install honnef.co/go/tools/cmd/staticcheck@latest 33 | export PATH="$HOME/go/bin:$PATH" 34 | 35 | - name: Run pre-commit 36 | uses: pre-commit/action@v3.0.1 37 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | name: 'Lock old issues' 2 | 3 | on: 4 | schedule: 5 | - cron: '0 18 * * *' 6 | workflow_dispatch: 7 | 8 | permissions: 9 | issues: write 10 | # pull-requests: write 11 | # discussions: write 12 | 13 | concurrency: 14 | group: lock-threads 15 | 16 | jobs: 17 | lock-stale: 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: dessant/lock-threads@v5 21 | id: lock 22 | with: 23 | issue-inactive-days: 90 24 | process-only: issues 25 | - name: Log processed threads 26 | run: | 27 | if [ '${{ steps.lock.outputs.issues }}' ]; then 28 | echo "Issues:" && echo '${{ steps.lock.outputs.issues }}' | jq -r '.[] | "https://github.com/\(.owner)/\(.repo)/issues/\(.issue_number)"' 29 | fi 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ./mautrix-twitter 2 | logs/ 3 | *.db* 4 | *.yaml 5 | !example-config.yaml 6 | !.pre-commit-config.yaml 7 | /start 8 | /pkg/mautrix 9 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | include: 2 | - project: 'mautrix/ci' 3 | file: '/gov2-as-default.yml' 4 | -------------------------------------------------------------------------------- /.idea/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v5.0.0 4 | hooks: 5 | - id: trailing-whitespace 6 | exclude_types: [markdown] 7 | - id: end-of-file-fixer 8 | - id: check-yaml 9 | - id: check-added-large-files 10 | 11 | - repo: https://github.com/tekwizely/pre-commit-golang 12 | rev: v1.0.0-rc.1 13 | hooks: 14 | - id: go-imports-repo 15 | args: 16 | - "-local" 17 | - "go.mau.fi/mautrix-twitter" 18 | - "-w" 19 | - id: go-vet-repo-mod 20 | - id: go-staticcheck-repo-mod 21 | - id: go-mod-tidy 22 | 23 | - repo: https://github.com/beeper/pre-commit-go 24 | rev: v0.4.2 25 | hooks: 26 | - id: zerolog-ban-msgf 27 | - id: zerolog-use-stringer 28 | - id: prevent-literal-http-methods 29 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # v0.4.1 (2025-05-16) 2 | 3 | * Added support for voice messages in both directions. 4 | * Fixed certain reactions not being bridged to Twitter. 5 | * Fixed private chats having explicit names/avatars even if the bridge wasn't 6 | configured to set them. 7 | * Fixed mention bridging not including the mentioned user displayname. 8 | * Fixed handling member join events from Twitter. 9 | 10 | # v0.4.0 (2025-04-16) 11 | 12 | * Added support for own read status bridging. 13 | * Added support for sending mentions and intentional mentions in incoming messages. 14 | * Fixed newlines in incoming formatted messages. 15 | * Stopped creating portals for message requests automatically. 16 | 17 | # v0.3.0 (2025-03-16) 18 | 19 | * Added support for tweet attachments. 20 | * Added support for unshortening URLs in incoming messages. 21 | * Fixed sending media in unencrypted Matrix rooms. 22 | * Fixed chats not being bridged in some cases even after accepting the message 23 | request on Twitter. 24 | 25 | # v0.2.1 (2025-01-16) 26 | 27 | * Fixed various bugs. 28 | 29 | # v0.2.0 (2024-12-16) 30 | 31 | * Rewrote bridge in Go using bridgev2 architecture. 32 | * To migrate the bridge, simply upgrade in-place. The database and config 33 | will be migrated automatically, although some parts of the config aren't 34 | migrated (e.g. log config). 35 | * It is recommended to check the config file after upgrading. If you have 36 | prevented the bridge from writing to the config, you should update it 37 | manually. 38 | 39 | # v0.1.8 (2024-07-16) 40 | 41 | No changelog available. 42 | 43 | # v0.1.7 (2023-09-19) 44 | 45 | No changelog available. 46 | 47 | # v0.1.6 (2023-05-22) 48 | 49 | No changelog available. 50 | 51 | # v0.1.5 (2022-08-23) 52 | 53 | No changelog available. 54 | 55 | # v0.1.4 (2022-03-30) 56 | 57 | No changelog available. 58 | 59 | # v0.1.3 (2022-01-15) 60 | 61 | No changelog available. 62 | 63 | # v0.1.1 (2020-12-11) 64 | 65 | No changelog available. 66 | 67 | # v0.1.1 (2020-11-10) 68 | 69 | No changelog available. 70 | 71 | # v0.1.0 (2020-09-04) 72 | 73 | Initial release. 74 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1-alpine3.21 AS builder 2 | 3 | RUN apk add --no-cache git ca-certificates build-base su-exec olm-dev 4 | 5 | COPY . /build 6 | WORKDIR /build 7 | RUN ./build.sh 8 | 9 | FROM alpine:3.21 10 | 11 | ENV UID=1337 \ 12 | GID=1337 13 | 14 | RUN apk add --no-cache ffmpeg su-exec ca-certificates olm bash jq yq-go curl 15 | 16 | COPY --from=builder /build/mautrix-twitter /usr/bin/mautrix-twitter 17 | COPY --from=builder /build/docker-run.sh /docker-run.sh 18 | VOLUME /data 19 | 20 | CMD ["/docker-run.sh"] 21 | -------------------------------------------------------------------------------- /Dockerfile.ci: -------------------------------------------------------------------------------- 1 | FROM alpine:3.21 2 | 3 | ENV UID=1337 \ 4 | GID=1337 5 | 6 | RUN apk add --no-cache ffmpeg su-exec ca-certificates bash jq curl yq-go 7 | 8 | ARG EXECUTABLE=./mautrix-twitter 9 | COPY $EXECUTABLE /usr/bin/mautrix-twitter 10 | COPY ./docker-run.sh /docker-run.sh 11 | ENV BRIDGEV2=1 12 | VOLUME /data 13 | WORKDIR /data 14 | 15 | CMD ["/docker-run.sh"] 16 | -------------------------------------------------------------------------------- /LICENSE.exceptions: -------------------------------------------------------------------------------- 1 | The mautrix-twitter developers grant the following special exceptions: 2 | 3 | * to Beeper the right to embed the program in the Beeper clients and servers, 4 | and use and distribute the collective work without applying the license to 5 | the whole. 6 | * to Element the right to distribute compiled binaries of the program as a part 7 | of the Element Server Suite and other server bundles without applying the 8 | license. 9 | 10 | All exceptions are only valid under the condition that any modifications to 11 | the source code of mautrix-twitter remain publicly available under the terms 12 | of the GNU AGPL version 3 or later. 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # mautrix-twitter 2 | ![Languages](https://img.shields.io/github/languages/top/mautrix/twitter.svg) 3 | [![License](https://img.shields.io/github/license/mautrix/twitter.svg)](LICENSE) 4 | [![Release](https://img.shields.io/github/release/mautrix/twitter/all.svg)](https://github.com/mautrix/twitter/releases) 5 | [![GitLab CI](https://mau.dev/mautrix/twitter/badges/master/pipeline.svg)](https://mau.dev/mautrix/twitter/container_registry) 6 | 7 | A Matrix-Twitter DM puppeting bridge. 8 | 9 | ## Documentation 10 | All setup and usage instructions are located on 11 | [docs.mau.fi](https://docs.mau.fi/bridges/go/twitter/index.html). 12 | Some quick links: 13 | 14 | * [Bridge setup](https://docs.mau.fi/bridges/go/setup.html?bridge=twitter) 15 | (or [with Docker](https://docs.mau.fi/bridges/general/docker-setup.html?bridge=twitter)) 16 | * Basic usage: [Authentication](https://docs.mau.fi/bridges/go/twitter/authentication.html) 17 | 18 | ### Features & Roadmap 19 | [ROADMAP.md](https://github.com/mautrix/twitter/blob/main/ROADMAP.md) 20 | contains a general overview of what is supported by the bridge. 21 | 22 | ## Discussion 23 | Matrix room: [`#twitter:maunium.net`](https://matrix.to/#/#twitter:maunium.net) 24 | -------------------------------------------------------------------------------- /ROADMAP.md: -------------------------------------------------------------------------------- 1 | # Features & roadmap 2 | 3 | * Matrix → Twitter 4 | * [x] Message content 5 | * [x] Text 6 | * [ ] ~~Formatting~~ (not supported by Twitter) 7 | * [x] Media 8 | * [x] Images 9 | * [x] Videos 10 | * [x] Gifs 11 | * [x] Message reactions 12 | * [x] Typing notifications 13 | * [x] Read receipts 14 | * Twitter → Matrix 15 | * [x] Message content 16 | * [x] Text 17 | * [ ] ~~Formatting~~ (not supported by Twitter) 18 | * [x] Media 19 | * [x] Images 20 | * [x] Videos 21 | * [x] Gifs 22 | * [x] Message reactions 23 | * [ ] Message history 24 | * [ ] When creating portal 25 | * [ ] Missed messages 26 | * [x] Avatars 27 | * [ ] Typing notifications 28 | * [ ] Read receipts 29 | * Misc 30 | * [x] Automatic portal creation 31 | * [ ] At startup 32 | * [x] When receiving invite or message 33 | * [ ] Private chat creation by inviting Matrix puppet of Twitter user to new room 34 | * [ ] Option to use own Matrix account for messages sent from other Twitter clients 35 | * [ ] E2EE in Matrix rooms 36 | -------------------------------------------------------------------------------- /build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | MAUTRIX_VERSION=$(cat go.mod | grep 'maunium.net/go/mautrix ' | awk '{ print $2 }' | head -n1) 3 | GO_LDFLAGS="-s -w -X main.Tag=$(git describe --exact-match --tags 2>/dev/null) -X main.Commit=$(git rev-parse HEAD) -X 'main.BuildTime=`date -Iseconds`' -X 'maunium.net/go/mautrix.GoModVersion=$MAUTRIX_VERSION'" 4 | go build -ldflags="$GO_LDFLAGS" "$@" ./cmd/mautrix-twitter 5 | -------------------------------------------------------------------------------- /cmd/mautrix-twitter/legacymigrate.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | _ "embed" 5 | 6 | up "go.mau.fi/util/configupgrade" 7 | "maunium.net/go/mautrix/bridgev2/bridgeconfig" 8 | ) 9 | 10 | const legacyMigrateRenameTables = ` 11 | ALTER TABLE portal RENAME TO portal_old; 12 | ALTER TABLE puppet RENAME TO puppet_old; 13 | ALTER TABLE message RENAME TO message_old; 14 | ALTER TABLE reaction RENAME TO reaction_old; 15 | ALTER TABLE "user" RENAME TO user_old; 16 | ` 17 | 18 | //go:embed legacymigrate.sql 19 | var legacyMigrateCopyData string 20 | 21 | func migrateLegacyConfig(helper up.Helper) { 22 | helper.Set(up.Str, "mautrix.bridge.e2ee", "encryption", "pickle_key") 23 | bridgeconfig.CopyToOtherLocation(helper, up.Int, []string{"bridge", "initial_conversation_sync"}, []string{"network", "conversation_sync_limit"}) 24 | } 25 | -------------------------------------------------------------------------------- /cmd/mautrix-twitter/legacymigrate.sql: -------------------------------------------------------------------------------- 1 | INSERT INTO "user" (bridge_id, mxid, management_room, access_token) 2 | SELECT '', mxid, notice_room, '' 3 | FROM user_old; 4 | 5 | INSERT INTO user_login (bridge_id, user_mxid, id, remote_name, space_room, metadata, remote_profile) 6 | SELECT 7 | '', -- bridge_id 8 | mxid, -- user_mxid 9 | CAST(twid AS TEXT), -- id 10 | '', -- remote_name 11 | '', -- space_room 12 | -- only: postgres 13 | jsonb_build_object 14 | -- only: sqlite (line commented) 15 | -- json_object 16 | ( 17 | 'cookies', 'auth_token=' || auth_token || '; ct0=' || csrf_token || ';' 18 | ), -- metadata 19 | '{}' -- remote_profile 20 | FROM user_old WHERE twid IS NOT NULL; 21 | 22 | INSERT INTO ghost ( 23 | bridge_id, id, name, avatar_id, avatar_hash, avatar_mxc, 24 | name_set, avatar_set, contact_info_set, is_bot, identifiers, metadata 25 | ) 26 | SELECT 27 | '', -- bridge_id 28 | CAST(twid AS TEXT), -- id 29 | COALESCE(name, ''), -- name 30 | COALESCE(photo_url, ''), -- avatar_id 31 | '', -- avatar_hash 32 | COALESCE(photo_mxc, ''), -- avatar_mxc 33 | COALESCE(name <> '', false), -- name_set 34 | COALESCE(photo_mxc <> '', false), -- avatar_set 35 | COALESCE(contact_info_set, false), -- contact_info_set 36 | false, -- is_bot 37 | '[]', -- identifiers 38 | '{}' -- metadata 39 | FROM puppet_old; 40 | 41 | INSERT INTO portal ( 42 | bridge_id, id, receiver, mxid, other_user_id, 43 | name, topic, avatar_id, avatar_hash, avatar_mxc, name_set, avatar_set, topic_set, name_is_custom, 44 | in_space, room_type, metadata 45 | ) 46 | SELECT 47 | '', -- bridge_id 48 | CAST(twid AS TEXT), -- id 49 | CASE WHEN receiver<>0 THEN CAST(receiver AS TEXT) ELSE '' END, -- receiver 50 | mxid, -- mxid 51 | CASE WHEN conv_type='ONE_TO_ONE' THEN CAST(other_user AS TEXT) END, -- other_user_id 52 | '', -- name 53 | '', -- topic 54 | '', -- avatar_id 55 | '', -- avatar_hash 56 | '', -- avatar_mxc 57 | false, -- name_set 58 | false, -- avatar_set 59 | false, -- topic_set 60 | false, -- name_is_custom 61 | false, -- in_space 62 | CASE WHEN conv_type='GROUP_DM' THEN 'group_dm' ELSE 'dm' END, -- room_type 63 | '{}' -- metadata 64 | FROM portal_old; 65 | 66 | INSERT INTO ghost (bridge_id, id, name, avatar_id, avatar_hash, avatar_mxc, name_set, avatar_set, contact_info_set, is_bot, identifiers, metadata) 67 | VALUES ('', '', '', '', '', '', false, false, false, false, '[]', '{}') 68 | ON CONFLICT (bridge_id, id) DO NOTHING; 69 | 70 | INSERT INTO message ( 71 | bridge_id, id, part_id, mxid, room_id, room_receiver, 72 | sender_id, sender_mxid, timestamp, edit_count, metadata 73 | ) 74 | SELECT 75 | '', -- bridge_id 76 | CAST(twid as TEXT), -- id 77 | '', -- part_id 78 | mxid, -- mxid 79 | (SELECT twid FROM portal_old WHERE portal_old.mxid=message_old.mx_room), -- room_id 80 | CASE WHEN receiver<>0 THEN CAST(receiver AS TEXT) ELSE '' END, -- room_receiver 81 | '', -- sender_id 82 | '', -- sender_mxid 83 | ((twid>>22)+1288834974657)*1000000, -- timestamp 84 | 0, -- edit_count 85 | '{}' -- metadata 86 | FROM message_old; 87 | 88 | INSERT INTO reaction ( 89 | bridge_id, message_id, message_part_id, sender_id, sender_mxid, 90 | emoji_id, room_id, room_receiver, timestamp, mxid, emoji, metadata 91 | ) 92 | SELECT 93 | '', -- bridge_id 94 | tw_msgid, -- message_id 95 | '', -- message_part_id 96 | CAST(tw_sender AS TEXT), -- sender_id 97 | '', -- sender_mxid 98 | '', -- emoji_id 99 | (SELECT twid FROM portal_old WHERE portal_old.mxid=reaction_old.mx_room), -- room_id 100 | CASE WHEN tw_receiver<>0 THEN CAST(tw_receiver AS TEXT) ELSE '' END, -- room_receiver 101 | ((COALESCE(tw_reaction_id, tw_msgid)>>22)+1288834974657)*1000000, -- timestamp 102 | mxid, 103 | reaction, -- emoji 104 | '{}' -- metadata 105 | FROM reaction_old; 106 | 107 | -- Python -> Go mx_ table migration 108 | ALTER TABLE mx_room_state DROP COLUMN is_encrypted; 109 | ALTER TABLE mx_room_state RENAME COLUMN has_full_member_list TO members_fetched; 110 | UPDATE mx_room_state SET members_fetched=false WHERE members_fetched IS NULL; 111 | 112 | -- only: postgres until "end only" 113 | ALTER TABLE mx_room_state ALTER COLUMN power_levels TYPE jsonb USING power_levels::jsonb; 114 | ALTER TABLE mx_room_state ALTER COLUMN encryption TYPE jsonb USING encryption::jsonb; 115 | ALTER TABLE mx_room_state ALTER COLUMN members_fetched SET DEFAULT false; 116 | ALTER TABLE mx_room_state ALTER COLUMN members_fetched SET NOT NULL; 117 | -- end only postgres 118 | 119 | ALTER TABLE mx_user_profile ADD COLUMN name_skeleton bytea; 120 | CREATE INDEX mx_user_profile_membership_idx ON mx_user_profile (room_id, membership); 121 | CREATE INDEX mx_user_profile_name_skeleton_idx ON mx_user_profile (room_id, name_skeleton); 122 | 123 | UPDATE mx_user_profile SET displayname='' WHERE displayname IS NULL; 124 | UPDATE mx_user_profile SET avatar_url='' WHERE avatar_url IS NULL; 125 | 126 | CREATE TABLE mx_registrations ( 127 | user_id TEXT PRIMARY KEY 128 | ); 129 | 130 | UPDATE mx_version SET version=7; 131 | 132 | DROP TABLE backfill_status; 133 | DROP TABLE reaction_old; 134 | DROP TABLE message_old; 135 | DROP TABLE portal_old; 136 | DROP TABLE puppet_old; 137 | DROP TABLE user_old; 138 | -------------------------------------------------------------------------------- /cmd/mautrix-twitter/legacyprovision.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "net/http" 8 | 9 | "github.com/rs/zerolog" 10 | "maunium.net/go/mautrix" 11 | "maunium.net/go/mautrix/bridgev2" 12 | "maunium.net/go/mautrix/bridgev2/status" 13 | 14 | "go.mau.fi/mautrix-twitter/pkg/connector" 15 | ) 16 | 17 | func jsonResponse(w http.ResponseWriter, status int, response any) { 18 | w.Header().Add("Content-Type", "application/json") 19 | w.WriteHeader(status) 20 | _ = json.NewEncoder(w).Encode(response) 21 | } 22 | 23 | type Error struct { 24 | Success bool `json:"success"` 25 | Error string `json:"error"` 26 | ErrCode string `json:"errcode"` 27 | } 28 | 29 | type Response struct { 30 | Success bool `json:"success"` 31 | Status string `json:"status"` 32 | } 33 | 34 | func legacyProvLogin(w http.ResponseWriter, r *http.Request) { 35 | user := m.Matrix.Provisioning.GetUser(r) 36 | ctx := r.Context() 37 | var cookies map[string]string 38 | err := json.NewDecoder(r.Body).Decode(&cookies) 39 | if err != nil { 40 | jsonResponse(w, http.StatusBadRequest, Error{ErrCode: mautrix.MBadJSON.ErrCode, Error: err.Error()}) 41 | return 42 | } 43 | 44 | newCookies := map[string]string{ 45 | "auth_token": cookies["auth_token"], 46 | "ct0": cookies["csrf_token"], 47 | } 48 | 49 | lp, err := c.CreateLogin(ctx, user, "cookies") 50 | if err != nil { 51 | zerolog.Ctx(ctx).Err(err).Msg("Failed to create login") 52 | jsonResponse(w, http.StatusInternalServerError, Error{ErrCode: "M_UNKNOWN", Error: "Internal error creating login"}) 53 | } else if firstStep, err := lp.Start(ctx); err != nil { 54 | zerolog.Ctx(ctx).Err(err).Msg("Failed to start login") 55 | jsonResponse(w, http.StatusInternalServerError, Error{ErrCode: "M_UNKNOWN", Error: "Internal error starting login"}) 56 | } else if firstStep.StepID != connector.LoginStepIDCookies { 57 | jsonResponse(w, http.StatusInternalServerError, Error{ErrCode: "M_UNKNOWN", Error: "Unexpected login step"}) 58 | } else if finalStep, err := lp.(bridgev2.LoginProcessCookies).SubmitCookies(ctx, newCookies); err != nil { 59 | zerolog.Ctx(ctx).Err(err).Msg("Failed to log in") 60 | var respErr bridgev2.RespError 61 | if errors.As(err, &respErr) { 62 | jsonResponse(w, respErr.StatusCode, &respErr) 63 | } else { 64 | jsonResponse(w, http.StatusInternalServerError, Error{ErrCode: "M_UNKNOWN", Error: "Internal error logging in"}) 65 | } 66 | } else if finalStep.StepID != connector.LoginStepIDComplete { 67 | jsonResponse(w, http.StatusInternalServerError, Error{ErrCode: "M_UNKNOWN", Error: "Unexpected login step"}) 68 | } else { 69 | jsonResponse(w, http.StatusOK, map[string]any{}) 70 | go handleLoginComplete(context.WithoutCancel(ctx), user, finalStep.CompleteParams.UserLogin) 71 | } 72 | } 73 | 74 | func handleLoginComplete(ctx context.Context, user *bridgev2.User, newLogin *bridgev2.UserLogin) { 75 | allLogins := user.GetUserLogins() 76 | for _, login := range allLogins { 77 | if login.ID != newLogin.ID { 78 | login.Delete(ctx, status.BridgeState{StateEvent: status.StateLoggedOut, Reason: "LOGIN_OVERRIDDEN"}, bridgev2.DeleteOpts{}) 79 | } 80 | } 81 | } 82 | 83 | func legacyProvLogout(w http.ResponseWriter, r *http.Request) { 84 | user := m.Matrix.Provisioning.GetUser(r) 85 | logins := user.GetUserLogins() 86 | for _, login := range logins { 87 | // Intentionally don't delete the user login, only disconnect the client 88 | login.Client.(*connector.TwitterClient).LogoutRemote(r.Context()) 89 | } 90 | jsonResponse(w, http.StatusOK, Response{ 91 | Success: true, 92 | Status: "logged_out", 93 | }) 94 | } 95 | -------------------------------------------------------------------------------- /cmd/mautrix-twitter/main.go: -------------------------------------------------------------------------------- 1 | // mautrix-twitter - A Matrix-Twitter puppeting bridge. 2 | // Copyright (C) 2024 Tulir Asokan 3 | // 4 | // This program is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU Affero General Public License as published by 6 | // the Free Software Foundation, either version 3 of the License, or 7 | // (at your option) any later version. 8 | // 9 | // This program is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU Affero General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU Affero General Public License 15 | // along with this program. If not, see . 16 | 17 | package main 18 | 19 | import ( 20 | "net/http" 21 | 22 | "maunium.net/go/mautrix/bridgev2/bridgeconfig" 23 | "maunium.net/go/mautrix/bridgev2/matrix/mxmain" 24 | 25 | "go.mau.fi/mautrix-twitter/pkg/connector" 26 | ) 27 | 28 | // Information to find out exactly which commit the bridge was built from. 29 | // These are filled at build time with the -X linker flag. 30 | var ( 31 | Tag = "unknown" 32 | Commit = "unknown" 33 | BuildTime = "unknown" 34 | ) 35 | 36 | var c = &connector.TwitterConnector{} 37 | var m = mxmain.BridgeMain{ 38 | Name: "mautrix-twitter", 39 | URL: "https://github.com/mautrix/twitter", 40 | Description: "A Matrix-Twitter puppeting bridge.", 41 | Version: "0.4.1", 42 | Connector: c, 43 | } 44 | 45 | func main() { 46 | bridgeconfig.HackyMigrateLegacyNetworkConfig = migrateLegacyConfig 47 | m.PostInit = func() { 48 | m.CheckLegacyDB( 49 | 8, 50 | "v0.1.0", 51 | "v0.2.0", 52 | m.LegacyMigrateSimple(legacyMigrateRenameTables, legacyMigrateCopyData, 18), 53 | true, 54 | ) 55 | } 56 | m.PostStart = func() { 57 | if m.Matrix.Provisioning != nil { 58 | m.Matrix.Provisioning.Router.HandleFunc("/v1/api/login", legacyProvLogin).Methods(http.MethodPost) 59 | m.Matrix.Provisioning.Router.HandleFunc("/v1/api/logout", legacyProvLogout).Methods(http.MethodPost) 60 | } 61 | } 62 | 63 | m.InitVersion(Tag, Commit, BuildTime) 64 | m.Run() 65 | } 66 | -------------------------------------------------------------------------------- /docker-run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | if [[ -z "$GID" ]]; then 4 | GID="$UID" 5 | fi 6 | 7 | BINARY_NAME=/usr/bin/mautrix-twitter 8 | 9 | function fixperms { 10 | chown -R $UID:$GID /data 11 | } 12 | 13 | if [[ ! -f /data/config.yaml ]]; then 14 | $BINARY_NAME -c /data/config.yaml -e 15 | echo "Didn't find a config file." 16 | echo "Copied default config file to /data/config.yaml" 17 | echo "Modify that config file to your liking." 18 | echo "Start the container again after that to generate the registration file." 19 | exit 20 | fi 21 | 22 | if [[ ! -f /data/registration.yaml ]]; then 23 | $BINARY_NAME -g -c /data/config.yaml -r /data/registration.yaml 24 | echo "Didn't find a registration file." 25 | echo "Generated one for you." 26 | echo "See https://docs.mau.fi/bridges/general/registering-appservices.html on how to use it." 27 | exit 28 | fi 29 | 30 | cd /data 31 | fixperms 32 | exec su-exec $UID:$GID $BINARY_NAME 33 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module go.mau.fi/mautrix-twitter 2 | 3 | go 1.23.0 4 | 5 | toolchain go1.24.3 6 | 7 | require ( 8 | github.com/PuerkitoBio/goquery v1.10.3 9 | github.com/google/go-querystring v1.1.0 10 | github.com/google/uuid v1.6.0 11 | github.com/rs/zerolog v1.34.0 12 | github.com/stretchr/testify v1.10.0 13 | go.mau.fi/util v0.8.7 14 | golang.org/x/net v0.40.0 15 | gopkg.in/yaml.v3 v3.0.1 16 | maunium.net/go/mautrix v0.24.1-0.20250527083757-8a745c0d03ec 17 | ) 18 | 19 | require ( 20 | filippo.io/edwards25519 v1.1.0 // indirect 21 | github.com/andybalholm/cascadia v1.3.3 // indirect 22 | github.com/coreos/go-systemd/v22 v22.5.0 // indirect 23 | github.com/davecgh/go-spew v1.1.1 // indirect 24 | github.com/gorilla/mux v1.8.0 // indirect 25 | github.com/gorilla/websocket v1.5.0 // indirect 26 | github.com/lib/pq v1.10.9 // indirect 27 | github.com/mattn/go-colorable v0.1.14 // indirect 28 | github.com/mattn/go-isatty v0.0.20 // indirect 29 | github.com/mattn/go-sqlite3 v1.14.28 // indirect 30 | github.com/petermattis/goid v0.0.0-20250508124226-395b08cebbdb // indirect 31 | github.com/pmezard/go-difflib v1.0.0 // indirect 32 | github.com/rs/xid v1.6.0 // indirect 33 | github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect 34 | github.com/tidwall/gjson v1.18.0 // indirect 35 | github.com/tidwall/match v1.1.1 // indirect 36 | github.com/tidwall/pretty v1.2.1 // indirect 37 | github.com/tidwall/sjson v1.2.5 // indirect 38 | github.com/yuin/goldmark v1.7.11 // indirect 39 | go.mau.fi/zeroconfig v0.1.3 // indirect 40 | golang.org/x/crypto v0.38.0 // indirect 41 | golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 // indirect 42 | golang.org/x/sync v0.14.0 // indirect 43 | golang.org/x/sys v0.33.0 // indirect 44 | golang.org/x/text v0.25.0 // indirect 45 | gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect 46 | maunium.net/go/mauflag v1.0.0 // indirect 47 | ) 48 | -------------------------------------------------------------------------------- /pkg/connector/backfill.go: -------------------------------------------------------------------------------- 1 | // mautrix-twitter - A Matrix-Twitter puppeting bridge. 2 | // Copyright (C) 2025 Tulir Asokan 3 | // 4 | // This program is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU Affero General Public License as published by 6 | // the Free Software Foundation, either version 3 of the License, or 7 | // (at your option) any later version. 8 | // 9 | // This program is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU Affero General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU Affero General Public License 15 | // along with this program. If not, see . 16 | 17 | package connector 18 | 19 | import ( 20 | "context" 21 | "fmt" 22 | 23 | "github.com/rs/zerolog" 24 | "maunium.net/go/mautrix/bridgev2" 25 | "maunium.net/go/mautrix/bridgev2/networkid" 26 | 27 | "go.mau.fi/mautrix-twitter/pkg/twittermeow/data/payload" 28 | "go.mau.fi/mautrix-twitter/pkg/twittermeow/data/response" 29 | "go.mau.fi/mautrix-twitter/pkg/twittermeow/data/types" 30 | "go.mau.fi/mautrix-twitter/pkg/twittermeow/methods" 31 | ) 32 | 33 | var _ bridgev2.BackfillingNetworkAPI = (*TwitterClient)(nil) 34 | 35 | func (tc *TwitterClient) FetchMessages(ctx context.Context, params bridgev2.FetchMessagesParams) (*bridgev2.FetchMessagesResponse, error) { 36 | conversationID := string(params.Portal.PortalKey.ID) 37 | 38 | reqQuery := payload.DMRequestQuery{}.Default() 39 | reqQuery.Count = params.Count 40 | log := zerolog.Ctx(ctx) 41 | log.Debug(). 42 | Bool("forward", params.Forward). 43 | Str("cursor", string(params.Cursor)). 44 | Int("count", params.Count). 45 | Msg("Backfill params") 46 | if params.AnchorMessage != nil { 47 | log.Debug(). 48 | Time("anchor_ts", params.AnchorMessage.Timestamp). 49 | Str("anchor_id", string(params.AnchorMessage.ID)). 50 | Msg("Backfill anchor message") 51 | } 52 | if !params.Forward { 53 | if params.Cursor != "" { 54 | reqQuery.MaxID = string(params.Cursor) 55 | log.Debug().Msg("Using cursor as max ID") 56 | } else if params.AnchorMessage != nil { 57 | reqQuery.MaxID = string(params.AnchorMessage.ID) 58 | log.Debug().Msg("Using anchor as max ID") 59 | } else { 60 | return nil, fmt.Errorf("no cursor or anchor message provided for backward backfill") 61 | } 62 | } else if params.AnchorMessage != nil { 63 | reqQuery.MinID = string(params.AnchorMessage.ID) 64 | log.Debug().Msg("Using anchor as min ID") 65 | } else { 66 | log.Debug().Msg("No anchor for forward backfill, fetching latest messages") 67 | } 68 | 69 | var inbox *response.TwitterInboxData 70 | var conv *types.Conversation 71 | var messages []*types.Message 72 | bundle, ok := params.BundledData.(*backfillDataBundle) 73 | if ok && params.Forward && len(bundle.Messages) > 0 { 74 | inbox = bundle.Inbox 75 | conv = bundle.Conv 76 | messages = bundle.Messages 77 | // TODO support for fetching more messages 78 | } else { 79 | messageResp, err := tc.client.FetchConversationContext(ctx, conversationID, &reqQuery, payload.CONTEXT_FETCH_DM_CONVERSATION_HISTORY) 80 | if err != nil { 81 | return nil, err 82 | } 83 | inbox = messageResp.ConversationTimeline 84 | conv = inbox.GetConversationByID(conversationID) 85 | messages = messageResp.ConversationTimeline.SortedMessages(ctx)[conversationID] 86 | } 87 | 88 | if len(messages) == 0 { 89 | log.Debug(). 90 | Str("timeline_status", string(inbox.Status)). 91 | Msg("No messages in backfill response") 92 | return &bridgev2.FetchMessagesResponse{ 93 | Messages: make([]*bridgev2.BackfillMessage, 0), 94 | HasMore: false, 95 | Forward: params.Forward, 96 | }, nil 97 | } 98 | var lastReadID string 99 | if conv != nil { 100 | lastReadID = conv.LastReadEventID 101 | } 102 | 103 | converted := make([]*bridgev2.BackfillMessage, 0, len(messages)) 104 | log.Debug(). 105 | Bool("is_bundled_data", bundle != nil). 106 | Int("message_count", len(messages)). 107 | Str("oldest_raw_ts", messages[0].Time). 108 | Str("newest_raw_ts", messages[len(messages)-1].Time). 109 | Str("oldest_id", messages[0].ID). 110 | Str("newest_id", messages[len(messages)-1].ID). 111 | Str("last_read_id", lastReadID). 112 | Msg("Fetched messages") 113 | for _, msg := range messages { 114 | messageTS := methods.ParseSnowflake(msg.ID) 115 | log := log.With(). 116 | Str("message_id", msg.MessageData.ID). 117 | Str("message_raw_ts", msg.Time). 118 | Time("message_ts", messageTS). 119 | Logger() 120 | if params.AnchorMessage != nil { 121 | if string(params.AnchorMessage.ID) == msg.ID { 122 | log.Warn().Msg("Skipping anchor message") 123 | continue 124 | } else if params.Forward && messageTS.Before(params.AnchorMessage.Timestamp) { 125 | log.Warn().Msg("Skipping too old message in forwards backfill") 126 | continue 127 | } else if !params.Forward && messageTS.After(params.AnchorMessage.Timestamp) { 128 | log.Warn().Msg("Skipping too new message in backwards backfill") 129 | continue 130 | } 131 | } 132 | log.Trace().Msg("Converting message") 133 | // TODO get correct intent 134 | intent := tc.userLogin.Bridge.Matrix.BotIntent() 135 | convertedMsg := &bridgev2.BackfillMessage{ 136 | ConvertedMessage: tc.convertToMatrix(ctx, params.Portal, intent, &msg.MessageData), 137 | Sender: tc.MakeEventSender(msg.MessageData.SenderID), 138 | ID: networkid.MessageID(msg.MessageData.ID), 139 | Timestamp: messageTS, 140 | Reactions: tc.convertBackfillReactions(msg.MessageReactions), 141 | StreamOrder: methods.ParseSnowflakeInt(msg.MessageData.ID), 142 | } 143 | converted = append(converted, convertedMsg) 144 | } 145 | 146 | fetchMessagesResp := &bridgev2.FetchMessagesResponse{ 147 | Messages: converted, 148 | HasMore: bundle != nil || inbox.Status == types.PaginationStatusHasMore, 149 | Forward: params.Forward, 150 | MarkRead: lastReadID == messages[len(messages)-1].ID, 151 | } 152 | if !params.Forward { 153 | fetchMessagesResp.Cursor = networkid.PaginationCursor(inbox.MinEntryID) 154 | } 155 | 156 | return fetchMessagesResp, nil 157 | } 158 | 159 | func (tc *TwitterClient) convertBackfillReactions(reactions []types.MessageReaction) []*bridgev2.BackfillReaction { 160 | backfillReactions := make([]*bridgev2.BackfillReaction, 0) 161 | for _, reaction := range reactions { 162 | backfillReaction := &bridgev2.BackfillReaction{ 163 | Timestamp: methods.ParseSnowflake(reaction.ID), 164 | Sender: tc.MakeEventSender(reaction.SenderID), 165 | EmojiID: "", 166 | Emoji: reaction.EmojiReaction, 167 | // StreamOrder: methods.ParseSnowflakeInt(reaction.ID), 168 | } 169 | backfillReactions = append(backfillReactions, backfillReaction) 170 | } 171 | return backfillReactions 172 | } 173 | -------------------------------------------------------------------------------- /pkg/connector/capabilities.go: -------------------------------------------------------------------------------- 1 | // mautrix-twitter - A Matrix-Twitter puppeting bridge. 2 | // Copyright (C) 2025 Tulir Asokan 3 | // 4 | // This program is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU Affero General Public License as published by 6 | // the Free Software Foundation, either version 3 of the License, or 7 | // (at your option) any later version. 8 | // 9 | // This program is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU Affero General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU Affero General Public License 15 | // along with this program. If not, see . 16 | 17 | package connector 18 | 19 | import ( 20 | "context" 21 | "time" 22 | 23 | "go.mau.fi/util/ffmpeg" 24 | "go.mau.fi/util/jsontime" 25 | "go.mau.fi/util/ptr" 26 | "maunium.net/go/mautrix/bridgev2" 27 | "maunium.net/go/mautrix/event" 28 | ) 29 | 30 | func (tc *TwitterConnector) GetCapabilities() *bridgev2.NetworkGeneralCapabilities { 31 | return &bridgev2.NetworkGeneralCapabilities{} 32 | } 33 | 34 | func (tc *TwitterConnector) GetBridgeInfoVersion() (info, caps int) { 35 | return 1, 3 36 | } 37 | 38 | const MaxTextLength = 10000 39 | 40 | func supportedIfFFmpeg() event.CapabilitySupportLevel { 41 | if ffmpeg.Supported() { 42 | return event.CapLevelPartialSupport 43 | } 44 | return event.CapLevelRejected 45 | } 46 | 47 | func (tc *TwitterClient) GetCapabilities(_ context.Context, _ *bridgev2.Portal) *event.RoomFeatures { 48 | return &event.RoomFeatures{ 49 | ID: "fi.mau.twitter.capabilities.2025_05_05", 50 | //Formatting: map[event.FormattingFeature]event.CapabilitySupportLevel{ 51 | // event.FmtUserLink: event.CapLevelFullySupported, 52 | //}, 53 | File: event.FileFeatureMap{ 54 | event.MsgImage: { 55 | MimeTypes: map[string]event.CapabilitySupportLevel{ 56 | "image/jpeg": event.CapLevelFullySupported, 57 | "image/png": event.CapLevelFullySupported, 58 | "image/gif": event.CapLevelFullySupported, 59 | "image/webp": event.CapLevelFullySupported, 60 | }, 61 | Caption: event.CapLevelFullySupported, 62 | MaxCaptionLength: MaxTextLength, 63 | MaxSize: 5 * 1024 * 1024, 64 | }, 65 | event.MsgVideo: { 66 | MimeTypes: map[string]event.CapabilitySupportLevel{ 67 | "video/mp4": event.CapLevelFullySupported, 68 | "video/quicktime": event.CapLevelFullySupported, 69 | }, 70 | Caption: event.CapLevelFullySupported, 71 | MaxCaptionLength: MaxTextLength, 72 | MaxSize: 15 * 1024 * 1024, 73 | }, 74 | event.CapMsgVoice: { 75 | MimeTypes: map[string]event.CapabilitySupportLevel{ 76 | "audio/aac": supportedIfFFmpeg(), 77 | "audio/ogg": supportedIfFFmpeg(), 78 | "video/mp4": event.CapLevelFullySupported, 79 | }, 80 | Caption: event.CapLevelFullySupported, 81 | MaxCaptionLength: MaxTextLength, 82 | MaxSize: 5 * 1024 * 1024, 83 | }, 84 | event.CapMsgGIF: { 85 | MimeTypes: map[string]event.CapabilitySupportLevel{ 86 | "image/gif": event.CapLevelFullySupported, 87 | "video/mp4": event.CapLevelFullySupported, 88 | }, 89 | Caption: event.CapLevelFullySupported, 90 | MaxCaptionLength: MaxTextLength, 91 | MaxSize: 5 * 1024 * 1024, 92 | }, 93 | }, 94 | 95 | MaxTextLength: MaxTextLength, 96 | 97 | Reply: event.CapLevelFullySupported, 98 | 99 | Edit: event.CapLevelFullySupported, 100 | EditMaxCount: 10, 101 | EditMaxAge: ptr.Ptr(jsontime.S(15 * time.Minute)), 102 | Reaction: event.CapLevelFullySupported, 103 | ReactionCount: 1, 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /pkg/connector/chatinfo.go: -------------------------------------------------------------------------------- 1 | // mautrix-twitter - A Matrix-Twitter puppeting bridge. 2 | // Copyright (C) 2025 Tulir Asokan 3 | // 4 | // This program is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU Affero General Public License as published by 6 | // the Free Software Foundation, either version 3 of the License, or 7 | // (at your option) any later version. 8 | // 9 | // This program is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU Affero General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU Affero General Public License 15 | // along with this program. If not, see . 16 | 17 | package connector 18 | 19 | import ( 20 | "context" 21 | "fmt" 22 | "io" 23 | "maps" 24 | 25 | "go.mau.fi/util/ptr" 26 | "maunium.net/go/mautrix/bridgev2" 27 | "maunium.net/go/mautrix/bridgev2/database" 28 | "maunium.net/go/mautrix/bridgev2/networkid" 29 | bridgeEvt "maunium.net/go/mautrix/event" 30 | 31 | "go.mau.fi/mautrix-twitter/pkg/twittermeow" 32 | "go.mau.fi/mautrix-twitter/pkg/twittermeow/data/payload" 33 | "go.mau.fi/mautrix-twitter/pkg/twittermeow/data/response" 34 | "go.mau.fi/mautrix-twitter/pkg/twittermeow/data/types" 35 | ) 36 | 37 | func (tc *TwitterClient) GetChatInfo(ctx context.Context, portal *bridgev2.Portal) (*bridgev2.ChatInfo, error) { 38 | conversationID := string(portal.PortalKey.ID) 39 | queryConversationPayload := payload.DMRequestQuery{}.Default() 40 | queryConversationPayload.IncludeConversationInfo = true 41 | conversationData, err := tc.client.FetchConversationContext(ctx, conversationID, &queryConversationPayload, payload.CONTEXT_FETCH_DM_CONVERSATION) 42 | if err != nil { 43 | return nil, err 44 | } 45 | 46 | conversation := conversationData.ConversationTimeline.GetConversationByID(conversationID) 47 | if conversation == nil { 48 | return nil, fmt.Errorf("failed to find conversation by id %s", conversationID) 49 | } 50 | tc.userCacheLock.Lock() 51 | maps.Copy(tc.userCache, conversationData.ConversationTimeline.Users) 52 | tc.userCacheLock.Unlock() 53 | return tc.conversationToChatInfo(conversation, conversationData.ConversationTimeline), nil 54 | } 55 | 56 | func (tc *TwitterClient) GetUserInfo(_ context.Context, ghost *bridgev2.Ghost) (*bridgev2.UserInfo, error) { 57 | userInfo := tc.getCachedUserInfo(string(ghost.ID)) 58 | if userInfo == nil { 59 | return nil, fmt.Errorf("failed to find user info in cache by id: %s", ghost.ID) 60 | } 61 | return userInfo, nil 62 | } 63 | 64 | func (tc *TwitterClient) getCachedUserInfo(userID string) *bridgev2.UserInfo { 65 | tc.userCacheLock.Lock() 66 | defer tc.userCacheLock.Unlock() 67 | var userinfo *bridgev2.UserInfo 68 | if userCacheEntry, ok := tc.userCache[userID]; ok { 69 | userinfo = tc.connector.wrapUserInfo(tc.client, userCacheEntry) 70 | } 71 | return userinfo 72 | } 73 | 74 | func (tc *TwitterConnector) wrapUserInfo(cli *twittermeow.Client, user *types.User) *bridgev2.UserInfo { 75 | return &bridgev2.UserInfo{ 76 | Name: ptr.Ptr(tc.Config.FormatDisplayname(user.ScreenName, user.Name)), 77 | Avatar: makeAvatar(cli, user.ProfileImageURL), 78 | Identifiers: []string{fmt.Sprintf("twitter:%s", user.ScreenName)}, 79 | } 80 | } 81 | 82 | func (tc *TwitterClient) conversationToChatInfo(conv *types.Conversation, inbox *response.TwitterInboxData) *bridgev2.ChatInfo { 83 | memberList := tc.participantsToMemberList(conv.Participants, inbox) 84 | var userLocal bridgev2.UserLocalPortalInfo 85 | if conv.Muted { 86 | userLocal.MutedUntil = ptr.Ptr(bridgeEvt.MutedForever) 87 | } else { 88 | userLocal.MutedUntil = ptr.Ptr(bridgev2.Unmuted) 89 | } 90 | chatInfo := &bridgev2.ChatInfo{ 91 | Members: memberList, 92 | Type: tc.conversationTypeToRoomType(conv.Type), 93 | UserLocal: &userLocal, 94 | CanBackfill: true, 95 | } 96 | 97 | if *chatInfo.Type != database.RoomTypeDM { 98 | chatInfo.Name = &conv.Name 99 | chatInfo.Avatar = makeAvatar(tc.client, conv.AvatarImageHttps) 100 | } else { 101 | chatInfo.Name = bridgev2.DefaultChatName 102 | } 103 | 104 | return chatInfo 105 | } 106 | 107 | func (tc *TwitterClient) conversationTypeToRoomType(convType types.ConversationType) *database.RoomType { 108 | var roomType database.RoomType 109 | switch convType { 110 | case types.ConversationTypeOneToOne: 111 | roomType = database.RoomTypeDM 112 | case types.ConversationTypeGroupDM: 113 | roomType = database.RoomTypeGroupDM 114 | } 115 | 116 | return &roomType 117 | } 118 | 119 | func (tc *TwitterClient) participantsToMemberList(participants []types.Participant, inbox *response.TwitterInboxData) *bridgev2.ChatMemberList { 120 | memberMap := make(map[networkid.UserID]bridgev2.ChatMember, len(participants)) 121 | for _, participant := range participants { 122 | memberMap[MakeUserID(participant.UserID)] = tc.participantToChatMember(participant, inbox) 123 | } 124 | return &bridgev2.ChatMemberList{ 125 | IsFull: true, 126 | TotalMemberCount: len(participants), 127 | MemberMap: memberMap, 128 | } 129 | } 130 | 131 | func (tc *TwitterClient) participantToChatMember(participant types.Participant, inbox *response.TwitterInboxData) bridgev2.ChatMember { 132 | var userInfo *bridgev2.UserInfo 133 | if user := inbox.GetUserByID(participant.UserID); user != nil { 134 | userInfo = tc.connector.wrapUserInfo(tc.client, user) 135 | } else { 136 | userInfo = tc.getCachedUserInfo(participant.UserID) 137 | } 138 | return bridgev2.ChatMember{ 139 | EventSender: tc.MakeEventSender(participant.UserID), 140 | UserInfo: userInfo, 141 | } 142 | } 143 | 144 | func makeAvatar(cli *twittermeow.Client, avatarURL string) *bridgev2.Avatar { 145 | return &bridgev2.Avatar{ 146 | ID: networkid.AvatarID(avatarURL), 147 | Get: func(ctx context.Context) ([]byte, error) { 148 | resp, err := downloadFile(ctx, cli, avatarURL) 149 | if err != nil { 150 | return nil, err 151 | } 152 | data, err := io.ReadAll(resp.Body) 153 | _ = resp.Body.Close() 154 | return data, err 155 | }, 156 | Remove: avatarURL == "", 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /pkg/connector/chatsync.go: -------------------------------------------------------------------------------- 1 | // mautrix-twitter - A Matrix-Twitter puppeting bridge. 2 | // Copyright (C) 2025 Tulir Asokan 3 | // 4 | // This program is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU Affero General Public License as published by 6 | // the Free Software Foundation, either version 3 of the License, or 7 | // (at your option) any later version. 8 | // 9 | // This program is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU Affero General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU Affero General Public License 15 | // along with this program. If not, see . 16 | 17 | package connector 18 | 19 | import ( 20 | "context" 21 | "fmt" 22 | "maps" 23 | 24 | "github.com/rs/zerolog" 25 | "go.mau.fi/util/ptr" 26 | "maunium.net/go/mautrix/bridgev2" 27 | "maunium.net/go/mautrix/bridgev2/simplevent" 28 | 29 | "go.mau.fi/mautrix-twitter/pkg/twittermeow/data/payload" 30 | "go.mau.fi/mautrix-twitter/pkg/twittermeow/data/response" 31 | "go.mau.fi/mautrix-twitter/pkg/twittermeow/data/types" 32 | "go.mau.fi/mautrix-twitter/pkg/twittermeow/methods" 33 | ) 34 | 35 | func (tc *TwitterClient) syncChannels(ctx context.Context, inbox *response.TwitterInboxData) { 36 | log := zerolog.Ctx(ctx) 37 | 38 | reqQuery := ptr.Ptr(payload.DMRequestQuery{}.Default()) 39 | if inbox == nil { 40 | initialInboxState, err := tc.client.GetInitialInboxState(ctx, reqQuery) 41 | if err != nil { 42 | log.Error().Err(err).Msg("failed to fetch initial inbox state:") 43 | return 44 | } 45 | inbox = initialInboxState.InboxInitialState 46 | } 47 | 48 | trustedInbox := inbox.InboxTimelines.Trusted 49 | cursor := trustedInbox.MinEntryID 50 | paginationStatus := trustedInbox.Status 51 | 52 | for paginationStatus == types.PaginationStatusHasMore && (tc.connector.Config.ConversationSyncLimit == -1 || len(inbox.Entries) < tc.connector.Config.ConversationSyncLimit) { 53 | reqQuery.MaxID = cursor 54 | nextInboxTimelineResponse, err := tc.client.FetchTrustedThreads(ctx, reqQuery) 55 | if err != nil { 56 | log.Error().Err(err).Msg(fmt.Sprintf("failed to fetch threads in trusted inbox using cursor %s:", cursor)) 57 | return 58 | } else if len(nextInboxTimelineResponse.InboxTimeline.Entries) == 0 { 59 | break 60 | } 61 | 62 | if inbox.Conversations == nil { 63 | inbox.Conversations = map[string]*types.Conversation{} 64 | } 65 | if inbox.Users == nil { 66 | inbox.Users = map[string]*types.User{} 67 | } 68 | maps.Copy(inbox.Conversations, nextInboxTimelineResponse.InboxTimeline.Conversations) 69 | maps.Copy(inbox.Users, nextInboxTimelineResponse.InboxTimeline.Users) 70 | inbox.Entries = append(inbox.Entries, nextInboxTimelineResponse.InboxTimeline.Entries...) 71 | 72 | cursor = nextInboxTimelineResponse.InboxTimeline.MinEntryID 73 | paginationStatus = nextInboxTimelineResponse.InboxTimeline.Status 74 | } 75 | 76 | tc.userCacheLock.Lock() 77 | maps.Copy(tc.userCache, inbox.Users) 78 | tc.userCacheLock.Unlock() 79 | 80 | messages := inbox.SortedMessages(ctx) 81 | for _, conv := range inbox.SortedConversations() { 82 | convMessages := messages[conv.ConversationID] 83 | if len(convMessages) == 0 { 84 | continue 85 | } 86 | latestMessage := convMessages[len(convMessages)-1] 87 | latestMessageTS := methods.ParseSnowflake(latestMessage.MessageData.ID) 88 | evt := &simplevent.ChatResync{ 89 | EventMeta: simplevent.EventMeta{ 90 | Type: bridgev2.RemoteEventChatResync, 91 | LogContext: func(c zerolog.Context) zerolog.Context { 92 | return c. 93 | Str("conversation_id", conv.ConversationID). 94 | Bool("conv_low_quality", conv.LowQuality). 95 | Bool("conv_trusted", conv.Trusted) 96 | }, 97 | PortalKey: tc.MakePortalKey(conv), 98 | CreatePortal: conv.Trusted, 99 | }, 100 | ChatInfo: tc.conversationToChatInfo(conv, inbox), 101 | BundledBackfillData: &backfillDataBundle{ 102 | Conv: conv, 103 | Messages: convMessages, 104 | Inbox: inbox, 105 | }, 106 | LatestMessageTS: latestMessageTS, 107 | } 108 | tc.connector.br.QueueRemoteEvent(tc.userLogin, evt) 109 | } 110 | } 111 | 112 | type backfillDataBundle struct { 113 | Conv *types.Conversation 114 | Messages []*types.Message 115 | Inbox *response.TwitterInboxData 116 | } 117 | -------------------------------------------------------------------------------- /pkg/connector/client.go: -------------------------------------------------------------------------------- 1 | // mautrix-twitter - A Matrix-Twitter puppeting bridge. 2 | // Copyright (C) 2025 Tulir Asokan 3 | // 4 | // This program is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU Affero General Public License as published by 6 | // the Free Software Foundation, either version 3 of the License, or 7 | // (at your option) any later version. 8 | // 9 | // This program is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU Affero General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU Affero General Public License 15 | // along with this program. If not, see . 16 | 17 | package connector 18 | 19 | import ( 20 | "context" 21 | "strings" 22 | "sync" 23 | 24 | "github.com/rs/zerolog" 25 | "maunium.net/go/mautrix/bridgev2" 26 | "maunium.net/go/mautrix/bridgev2/networkid" 27 | "maunium.net/go/mautrix/bridgev2/status" 28 | "maunium.net/go/mautrix/format" 29 | "maunium.net/go/mautrix/id" 30 | 31 | "go.mau.fi/mautrix-twitter/pkg/twittermeow" 32 | "go.mau.fi/mautrix-twitter/pkg/twittermeow/cookies" 33 | "go.mau.fi/mautrix-twitter/pkg/twittermeow/data/response" 34 | "go.mau.fi/mautrix-twitter/pkg/twittermeow/data/types" 35 | ) 36 | 37 | type TwitterClient struct { 38 | connector *TwitterConnector 39 | client *twittermeow.Client 40 | 41 | userLogin *bridgev2.UserLogin 42 | 43 | userCache map[string]*types.User 44 | userCacheLock sync.RWMutex 45 | 46 | participantCache map[string][]types.Participant 47 | 48 | matrixParser *format.HTMLParser 49 | } 50 | 51 | var _ bridgev2.NetworkAPI = (*TwitterClient)(nil) 52 | 53 | func NewTwitterClient(login *bridgev2.UserLogin, connector *TwitterConnector, client *twittermeow.Client) *TwitterClient { 54 | tc := &TwitterClient{ 55 | connector: connector, 56 | client: client, 57 | userLogin: login, 58 | userCache: make(map[string]*types.User), 59 | participantCache: make(map[string][]types.Participant), 60 | } 61 | client.SetEventHandler(tc.HandleTwitterEvent, tc.HandleStreamEvent) 62 | tc.matrixParser = &format.HTMLParser{ 63 | TabsToSpaces: 4, 64 | Newline: "\n", 65 | HorizontalLine: "\n---\n", 66 | PillConverter: func(displayname, mxid, eventID string, ctx format.Context) string { 67 | userID, ok := tc.connector.br.Matrix.ParseGhostMXID(id.UserID(mxid)) 68 | if !ok { 69 | return displayname 70 | } 71 | ghost, err := tc.connector.br.GetGhostByID(context.TODO(), userID) 72 | if err != nil || len(ghost.Identifiers) < 1 { 73 | return displayname 74 | } 75 | id := ghost.Identifiers[0] 76 | return "@" + strings.TrimPrefix(id, "twitter:") 77 | }, 78 | } 79 | return tc 80 | } 81 | 82 | func (tc *TwitterConnector) LoadUserLogin(ctx context.Context, login *bridgev2.UserLogin) error { 83 | login.Client = NewTwitterClient(login, tc, twittermeow.NewClient(&twittermeow.ClientOpts{ 84 | Cookies: cookies.NewCookiesFromString(login.Metadata.(*UserLoginMetadata).Cookies), 85 | WithJOTClient: true, 86 | }, login.Log.With().Str("component", "twitter_client").Logger())) 87 | return nil 88 | } 89 | 90 | func (tc *TwitterClient) Connect(ctx context.Context) { 91 | if tc.client == nil { 92 | tc.userLogin.BridgeState.Send(status.BridgeState{ 93 | StateEvent: status.StateBadCredentials, 94 | Error: "twitter-not-logged-in", 95 | }) 96 | return 97 | } 98 | 99 | inboxState, _, err := tc.client.LoadMessagesPage(ctx) 100 | if err != nil { 101 | zerolog.Ctx(ctx).Err(err).Msg("Failed to load messages page") 102 | if twittermeow.IsAuthError(err) { 103 | tc.userLogin.BridgeState.Send(status.BridgeState{ 104 | StateEvent: status.StateBadCredentials, 105 | Error: "twitter-invalid-credentials", 106 | Message: err.Error(), 107 | }) 108 | } else { 109 | tc.userLogin.BridgeState.Send(status.BridgeState{ 110 | StateEvent: status.StateUnknownError, 111 | Error: "twitter-load-error", 112 | }) 113 | } 114 | return 115 | } 116 | 117 | currentUserID := tc.client.GetCurrentUserID() 118 | if MakeUserLoginID(currentUserID) != tc.userLogin.ID { 119 | zerolog.Ctx(ctx).Warn(). 120 | Str("user_login_id", string(tc.userLogin.ID)). 121 | Str("current_user_id", currentUserID). 122 | Msg("User login ID mismatch") 123 | } 124 | 125 | remoteProfile := tc.connector.makeRemoteProfile(ctx, tc.client, currentUserID, inboxState.InboxInitialState) 126 | if remoteProfile != nil && (tc.userLogin.RemoteName != remoteProfile.Username || 127 | tc.userLogin.RemoteProfile != *remoteProfile) { 128 | tc.userLogin.RemoteName = remoteProfile.Username 129 | tc.userLogin.RemoteProfile = *remoteProfile 130 | err = tc.userLogin.Save(ctx) 131 | if err != nil { 132 | zerolog.Ctx(ctx).Err(err).Msg("Failed to save user login after updating remote profile") 133 | } 134 | } 135 | 136 | go tc.syncChannels(ctx, inboxState.InboxInitialState) 137 | tc.startPolling(ctx) 138 | } 139 | 140 | func (tc *TwitterConnector) makeRemoteProfile(ctx context.Context, cli *twittermeow.Client, currentUserID string, inbox *response.TwitterInboxData) *status.RemoteProfile { 141 | selfUser := inbox.GetUserByID(currentUserID) 142 | if selfUser == nil { 143 | zerolog.Ctx(ctx).Warn().Msg("Own user info not found in inbox state") 144 | return nil 145 | } 146 | var avatarMXC id.ContentURIString 147 | ownGhost, err := tc.br.GetGhostByID(ctx, MakeUserID(currentUserID)) 148 | if err != nil { 149 | zerolog.Ctx(ctx).Err(err).Msg("Failed to get own ghost by ID") 150 | } else { 151 | ownGhost.UpdateInfo(ctx, tc.wrapUserInfo(cli, selfUser)) 152 | avatarMXC = ownGhost.AvatarMXC 153 | } 154 | return &status.RemoteProfile{ 155 | // TODO fetch from /1.1/users/email_phone_info.json? 156 | Phone: "", 157 | Email: "", 158 | Username: selfUser.ScreenName, 159 | Name: selfUser.Name, 160 | Avatar: avatarMXC, 161 | } 162 | } 163 | 164 | func (tc *TwitterClient) startPolling(ctx context.Context) { 165 | err := tc.client.Connect() 166 | if err != nil { 167 | zerolog.Ctx(ctx).Err(err).Msg("Failed to start polling") 168 | tc.userLogin.BridgeState.Send(status.BridgeState{ 169 | StateEvent: status.StateUnknownError, 170 | Error: "twitter-connect-error", 171 | }) 172 | } else { 173 | tc.userLogin.BridgeState.Send(status.BridgeState{StateEvent: status.StateConnected}) 174 | } 175 | } 176 | 177 | func (tc *TwitterClient) Disconnect() { 178 | tc.client.Disconnect() 179 | } 180 | 181 | func (tc *TwitterClient) IsLoggedIn() bool { 182 | return tc.client.IsLoggedIn() 183 | } 184 | 185 | func (tc *TwitterClient) LogoutRemote(ctx context.Context) { 186 | log := zerolog.Ctx(ctx) 187 | _, err := tc.client.Logout(ctx) 188 | if err != nil { 189 | log.Error().Err(err).Msg("error logging out") 190 | } 191 | } 192 | 193 | func (tc *TwitterClient) IsThisUser(_ context.Context, userID networkid.UserID) bool { 194 | return UserLoginIDToUserID(tc.userLogin.ID) == userID 195 | } 196 | -------------------------------------------------------------------------------- /pkg/connector/config.go: -------------------------------------------------------------------------------- 1 | package connector 2 | 3 | import ( 4 | _ "embed" 5 | "strings" 6 | "text/template" 7 | 8 | up "go.mau.fi/util/configupgrade" 9 | "gopkg.in/yaml.v3" 10 | ) 11 | 12 | //go:embed example-config.yaml 13 | var ExampleConfig string 14 | 15 | type Config struct { 16 | Proxy string `yaml:"proxy"` 17 | GetProxyURL string `yaml:"get_proxy_url"` 18 | 19 | DisplaynameTemplate string `yaml:"displayname_template"` 20 | 21 | ConversationSyncLimit int `yaml:"conversation_sync_limit"` 22 | 23 | displaynameTemplate *template.Template `yaml:"-"` 24 | } 25 | 26 | type umConfig Config 27 | 28 | func (c *Config) UnmarshalYAML(node *yaml.Node) error { 29 | err := node.Decode((*umConfig)(c)) 30 | if err != nil { 31 | return err 32 | } 33 | return c.PostProcess() 34 | } 35 | 36 | func (c *Config) PostProcess() error { 37 | var err error 38 | c.displaynameTemplate, err = template.New("displayname").Parse(c.DisplaynameTemplate) 39 | return err 40 | } 41 | 42 | func upgradeConfig(helper up.Helper) { 43 | helper.Copy(up.Str|up.Null, "proxy") 44 | helper.Copy(up.Str|up.Null, "get_proxy_url") 45 | helper.Copy(up.Str, "displayname_template") 46 | helper.Copy(up.Int, "conversation_sync_limit") 47 | } 48 | 49 | type DisplaynameParams struct { 50 | Username string 51 | DisplayName string 52 | } 53 | 54 | func (c *Config) FormatDisplayname(username string, displayname string) string { 55 | var nameBuf strings.Builder 56 | err := c.displaynameTemplate.Execute(&nameBuf, &DisplaynameParams{ 57 | Username: username, 58 | DisplayName: displayname, 59 | }) 60 | if err != nil { 61 | panic(err) 62 | } 63 | return nameBuf.String() 64 | } 65 | 66 | func (tc *TwitterConnector) GetConfig() (string, any, up.Upgrader) { 67 | return ExampleConfig, &tc.Config, &up.StructUpgrader{ 68 | SimpleUpgrader: up.SimpleUpgrader(upgradeConfig), 69 | Base: ExampleConfig, 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /pkg/connector/connector.go: -------------------------------------------------------------------------------- 1 | // mautrix-twitter - A Matrix-Twitter puppeting bridge. 2 | // Copyright (C) 2024 Tulir Asokan 3 | // 4 | // This program is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU Affero General Public License as published by 6 | // the Free Software Foundation, either version 3 of the License, or 7 | // (at your option) any later version. 8 | // 9 | // This program is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU Affero General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU Affero General Public License 15 | // along with this program. If not, see . 16 | 17 | package connector 18 | 19 | import ( 20 | "context" 21 | 22 | "maunium.net/go/mautrix/bridgev2" 23 | ) 24 | 25 | type TwitterConnector struct { 26 | br *bridgev2.Bridge 27 | 28 | Config Config 29 | directMedia bool 30 | } 31 | 32 | var _ bridgev2.NetworkConnector = (*TwitterConnector)(nil) 33 | 34 | func (tc *TwitterConnector) Init(bridge *bridgev2.Bridge) { 35 | tc.br = bridge 36 | } 37 | 38 | func (tc *TwitterConnector) Start(_ context.Context) error { 39 | return nil 40 | } 41 | 42 | func (tc *TwitterConnector) GetName() bridgev2.BridgeName { 43 | return bridgev2.BridgeName{ 44 | DisplayName: "Twitter", 45 | NetworkURL: "https://twitter.com", 46 | NetworkIcon: "mxc://maunium.net/HVHcnusJkQcpVcsVGZRELLCn", 47 | NetworkID: "twitter", 48 | BeeperBridgeType: "twitter", 49 | DefaultPort: 29327, 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /pkg/connector/dbmeta.go: -------------------------------------------------------------------------------- 1 | // mautrix-twitter - A Matrix-Twitter puppeting bridge. 2 | // Copyright (C) 2025 Tulir Asokan 3 | // 4 | // This program is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU Affero General Public License as published by 6 | // the Free Software Foundation, either version 3 of the License, or 7 | // (at your option) any later version. 8 | // 9 | // This program is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU Affero General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU Affero General Public License 15 | // along with this program. If not, see . 16 | 17 | package connector 18 | 19 | import ( 20 | "crypto/ecdh" 21 | "crypto/rand" 22 | 23 | "go.mau.fi/util/exerrors" 24 | "go.mau.fi/util/random" 25 | "maunium.net/go/mautrix/bridgev2/database" 26 | ) 27 | 28 | func (tc *TwitterConnector) GetDBMetaTypes() database.MetaTypes { 29 | return database.MetaTypes{ 30 | Reaction: nil, 31 | Portal: nil, 32 | Message: func() any { 33 | return &MessageMetadata{} 34 | }, 35 | Ghost: nil, 36 | UserLogin: func() any { 37 | return &UserLoginMetadata{} 38 | }, 39 | } 40 | } 41 | 42 | type UserLoginMetadata struct { 43 | Cookies string `json:"cookies"` 44 | PushKeys *PushKeys `json:"push_keys,omitempty"` 45 | } 46 | 47 | type MessageMetadata struct { 48 | EditCount int `json:"edit_count,omitempty"` 49 | } 50 | 51 | type PushKeys struct { 52 | P256DH []byte `json:"p256dh"` 53 | Auth []byte `json:"auth"` 54 | Private []byte `json:"private"` 55 | } 56 | 57 | func (m *UserLoginMetadata) GeneratePushKeys() { 58 | privateKey := exerrors.Must(ecdh.P256().GenerateKey(rand.Reader)) 59 | m.PushKeys = &PushKeys{ 60 | P256DH: privateKey.Public().(*ecdh.PublicKey).Bytes(), 61 | Auth: random.Bytes(16), 62 | Private: privateKey.Bytes(), 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /pkg/connector/directmedia.go: -------------------------------------------------------------------------------- 1 | package connector 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/rs/zerolog" 8 | "maunium.net/go/mautrix/bridgev2" 9 | "maunium.net/go/mautrix/bridgev2/networkid" 10 | "maunium.net/go/mautrix/mediaproxy" 11 | ) 12 | 13 | var _ bridgev2.DirectMediableNetwork = (*TwitterConnector)(nil) 14 | 15 | func (tc *TwitterConnector) SetUseDirectMedia() { 16 | tc.directMedia = true 17 | } 18 | 19 | func (tc *TwitterConnector) Download(ctx context.Context, mediaID networkid.MediaID, params map[string]string) (mediaproxy.GetMediaResponse, error) { 20 | mediaInfo, err := ParseMediaID(mediaID) 21 | if err != nil { 22 | return nil, err 23 | } 24 | zerolog.Ctx(ctx).Trace().Any("mediaInfo", mediaInfo).Any("err", err).Msg("download direct media") 25 | ul := tc.br.GetCachedUserLoginByID(mediaInfo.UserID) 26 | if ul == nil || !ul.Client.IsLoggedIn() { 27 | return nil, fmt.Errorf("no logged in user found") 28 | } 29 | client := ul.Client.(*TwitterClient) 30 | resp, err := client.downloadFile(ctx, mediaInfo.URL) 31 | if err != nil { 32 | return nil, err 33 | } 34 | return &mediaproxy.GetMediaResponseData{ 35 | Reader: resp.Body, 36 | ContentType: resp.Header.Get("content-type"), 37 | ContentLength: resp.ContentLength, 38 | }, nil 39 | } 40 | -------------------------------------------------------------------------------- /pkg/connector/example-config.yaml: -------------------------------------------------------------------------------- 1 | # Proxy to use for all Twitter connections. 2 | proxy: null 3 | # Alternative to proxy: an HTTP endpoint that returns the proxy URL to use for Twitter connections. 4 | get_proxy_url: null 5 | 6 | # Displayname template for Twitter users. 7 | # {{ .DisplayName }} is replaced with the display name of the Twitter user. 8 | # {{ .Username }} is replaced with the username of the Twitter user. 9 | displayname_template: "{{ .DisplayName }} (Twitter)" 10 | 11 | # Maximum number of conversations to sync on startup 12 | conversation_sync_limit: 20 13 | -------------------------------------------------------------------------------- /pkg/connector/handlematrix.go: -------------------------------------------------------------------------------- 1 | // mautrix-twitter - A Matrix-Twitter puppeting bridge. 2 | // Copyright (C) 2025 Tulir Asokan 3 | // 4 | // This program is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU Affero General Public License as published by 6 | // the Free Software Foundation, either version 3 of the License, or 7 | // (at your option) any later version. 8 | // 9 | // This program is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU Affero General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU Affero General Public License 15 | // along with this program. If not, see . 16 | 17 | package connector 18 | 19 | import ( 20 | "context" 21 | "fmt" 22 | 23 | "github.com/google/uuid" 24 | "github.com/rs/zerolog" 25 | "go.mau.fi/util/variationselector" 26 | "maunium.net/go/mautrix/bridgev2" 27 | "maunium.net/go/mautrix/bridgev2/database" 28 | "maunium.net/go/mautrix/bridgev2/networkid" 29 | "maunium.net/go/mautrix/event" 30 | "maunium.net/go/mautrix/format" 31 | "maunium.net/go/mautrix/id" 32 | 33 | "go.mau.fi/mautrix-twitter/pkg/twittermeow/data/payload" 34 | "go.mau.fi/mautrix-twitter/pkg/twittermeow/data/types" 35 | "go.mau.fi/mautrix-twitter/pkg/twittermeow/methods" 36 | ) 37 | 38 | var mediaCategoryMap = map[event.MessageType]payload.MediaCategory{ 39 | event.MsgVideo: payload.MEDIA_CATEGORY_DM_VIDEO, 40 | event.MsgImage: payload.MEDIA_CATEGORY_DM_IMAGE, 41 | } 42 | 43 | var ( 44 | _ bridgev2.ReactionHandlingNetworkAPI = (*TwitterClient)(nil) 45 | _ bridgev2.ReadReceiptHandlingNetworkAPI = (*TwitterClient)(nil) 46 | _ bridgev2.EditHandlingNetworkAPI = (*TwitterClient)(nil) 47 | _ bridgev2.TypingHandlingNetworkAPI = (*TwitterClient)(nil) 48 | _ bridgev2.ChatViewingNetworkAPI = (*TwitterClient)(nil) 49 | ) 50 | 51 | var _ bridgev2.TransactionIDGeneratingNetwork = (*TwitterConnector)(nil) 52 | 53 | func (tc *TwitterClient) HandleMatrixTyping(ctx context.Context, msg *bridgev2.MatrixTyping) error { 54 | if msg.IsTyping && msg.Type == bridgev2.TypingTypeText { 55 | return tc.client.SendTypingNotification(ctx, string(msg.Portal.ID)) 56 | } 57 | return nil 58 | } 59 | 60 | func (tc *TwitterConnector) GenerateTransactionID(userID id.UserID, roomID id.RoomID, eventType event.Type) networkid.RawTransactionID { 61 | return networkid.RawTransactionID(uuid.NewString()) 62 | } 63 | 64 | func (tc *TwitterClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.MatrixMessage) (message *bridgev2.MatrixMessageResponse, err error) { 65 | conversationID := string(msg.Portal.ID) 66 | sendDMPayload := &payload.SendDirectMessagePayload{ 67 | ConversationID: conversationID, 68 | IncludeCards: 1, 69 | IncludeQuoteCount: true, 70 | RecipientIDs: false, 71 | DMUsers: false, 72 | CardsPlatform: "Web-12", 73 | RequestID: string(msg.InputTransactionID), 74 | } 75 | if sendDMPayload.RequestID == "" { 76 | sendDMPayload.RequestID = uuid.NewString() 77 | } 78 | 79 | if msg.ReplyTo != nil { 80 | sendDMPayload.ReplyToDMID = string(msg.ReplyTo.ID) 81 | } 82 | 83 | content := msg.Content 84 | if content.Format == event.FormatHTML { 85 | sendDMPayload.Text = tc.matrixParser.Parse(content.FormattedBody, format.NewContext(ctx)) 86 | } else { 87 | sendDMPayload.Text = content.Body 88 | } 89 | 90 | switch content.MsgType { 91 | case event.MsgText: 92 | break 93 | case event.MsgVideo, event.MsgImage, event.MsgAudio: 94 | if content.FileName == "" || content.Body == content.FileName { 95 | sendDMPayload.Text = "" 96 | } 97 | 98 | data, err := tc.connector.br.Bot.DownloadMedia(ctx, content.URL, content.File) 99 | if err != nil { 100 | return nil, err 101 | } 102 | 103 | uploadMediaParams := &payload.UploadMediaQuery{ 104 | MediaCategory: mediaCategoryMap[content.MsgType], 105 | MediaType: content.Info.MimeType, 106 | } 107 | if content.Info.MimeType == "image/gif" || content.Info.MauGIF { 108 | uploadMediaParams.MediaCategory = "dm_gif" 109 | } 110 | 111 | if content.MsgType == event.MsgAudio { 112 | sendDMPayload.AudioOnlyMediaAttachment = true 113 | uploadMediaParams.MediaCategory = "dm_video" 114 | if content.Info.MimeType != "video/mp4" { 115 | converted, err := tc.client.ConvertAudioPayload(ctx, data, content.Info.MimeType) 116 | if err != nil { 117 | return nil, err 118 | } else { 119 | data = converted 120 | } 121 | } 122 | } 123 | uploadedMediaResponse, err := tc.client.UploadMedia(ctx, uploadMediaParams, data) 124 | if err != nil { 125 | return nil, err 126 | } 127 | 128 | zerolog.Ctx(ctx).Debug().Any("media_info", uploadedMediaResponse).Msg("Successfully uploaded media to twitter's servers") 129 | sendDMPayload.MediaID = uploadedMediaResponse.MediaIDString 130 | default: 131 | return nil, fmt.Errorf("%w %s", bridgev2.ErrUnsupportedMessageType, content.MsgType) 132 | } 133 | 134 | txnID := networkid.TransactionID(sendDMPayload.RequestID) 135 | msg.AddPendingToIgnore(txnID) 136 | resp, err := tc.client.SendDirectMessage(ctx, sendDMPayload) 137 | if err != nil { 138 | return nil, err 139 | } else if len(resp.Entries) == 0 { 140 | return nil, fmt.Errorf("no entries in send response") 141 | } else if len(resp.Entries) > 1 { 142 | zerolog.Ctx(ctx).Warn(). 143 | Int("entry_count", len(resp.Entries)). 144 | Msg("Unexpected number of entries in send response") 145 | } 146 | entry, ok := resp.Entries[0].ParseWithErrorLog(zerolog.Ctx(ctx)).(*types.Message) 147 | if !ok { 148 | return nil, fmt.Errorf("unexpected response data: not a message") 149 | } 150 | return &bridgev2.MatrixMessageResponse{ 151 | DB: &database.Message{ 152 | ID: networkid.MessageID(entry.MessageData.ID), 153 | MXID: msg.Event.ID, 154 | Room: msg.Portal.PortalKey, 155 | SenderID: UserLoginIDToUserID(tc.userLogin.ID), 156 | Timestamp: methods.ParseSnowflake(entry.MessageData.ID), 157 | Metadata: &MessageMetadata{}, 158 | }, 159 | StreamOrder: methods.ParseSnowflakeInt(entry.MessageData.ID), 160 | RemovePending: txnID, 161 | }, nil 162 | } 163 | 164 | func (tc *TwitterClient) HandleMatrixReactionRemove(ctx context.Context, msg *bridgev2.MatrixReactionRemove) error { 165 | return tc.doHandleMatrixReaction(ctx, true, string(msg.Portal.ID), string(msg.TargetReaction.MessageID), msg.TargetReaction.Emoji) 166 | } 167 | 168 | func (tc *TwitterClient) PreHandleMatrixReaction(_ context.Context, msg *bridgev2.MatrixReaction) (bridgev2.MatrixReactionPreResponse, error) { 169 | return bridgev2.MatrixReactionPreResponse{ 170 | SenderID: UserLoginIDToUserID(tc.userLogin.ID), 171 | Emoji: variationselector.FullyQualify(msg.Content.RelatesTo.Key), 172 | MaxReactions: 1, 173 | }, nil 174 | } 175 | 176 | func (tc *TwitterClient) HandleMatrixReaction(ctx context.Context, msg *bridgev2.MatrixReaction) (reaction *database.Reaction, err error) { 177 | return nil, tc.doHandleMatrixReaction(ctx, false, string(msg.Portal.ID), string(msg.TargetMessage.ID), msg.PreHandleResp.Emoji) 178 | } 179 | 180 | func (tc *TwitterClient) doHandleMatrixReaction(ctx context.Context, remove bool, conversationID, messageID, emoji string) error { 181 | reactionPayload := &payload.ReactionActionPayload{ 182 | ConversationID: conversationID, 183 | MessageID: messageID, 184 | ReactionTypes: []string{"Emoji"}, 185 | EmojiReactions: []string{emoji}, 186 | } 187 | reactionResponse, err := tc.client.React(ctx, reactionPayload, remove) 188 | if err != nil { 189 | return err 190 | } 191 | tc.client.Logger.Debug().Any("reactionResponse", reactionResponse).Any("payload", reactionPayload).Msg("Reaction response") 192 | if reactionResponse.Data.CreateDmReaction.Typename == "CreateDMReactionFailure" { 193 | return fmt.Errorf("server rejected reaction") 194 | } 195 | return nil 196 | } 197 | 198 | func (tc *TwitterClient) HandleMatrixReadReceipt(ctx context.Context, msg *bridgev2.MatrixReadReceipt) error { 199 | params := &payload.MarkConversationReadQuery{ 200 | ConversationID: string(msg.Portal.ID), 201 | } 202 | 203 | if msg.ExactMessage != nil { 204 | params.LastReadEventID = string(msg.ExactMessage.ID) 205 | } else { 206 | lastMessage, err := tc.userLogin.Bridge.DB.Message.GetLastPartAtOrBeforeTime(ctx, msg.Portal.PortalKey, msg.ReadUpTo) 207 | if err != nil { 208 | return err 209 | } 210 | params.LastReadEventID = string(lastMessage.ID) 211 | } 212 | 213 | return tc.client.MarkConversationRead(ctx, params) 214 | } 215 | 216 | func (tc *TwitterClient) HandleMatrixEdit(ctx context.Context, edit *bridgev2.MatrixEdit) error { 217 | req := &payload.EditDirectMessagePayload{ 218 | ConversationID: string(edit.Portal.ID), 219 | RequestID: string(edit.InputTransactionID), 220 | DMID: string(edit.EditTarget.ID), 221 | Text: edit.Content.Body, 222 | } 223 | if req.RequestID == "" { 224 | req.RequestID = uuid.NewString() 225 | } 226 | resp, err := tc.client.EditDirectMessage(ctx, req) 227 | if err != nil { 228 | return err 229 | } 230 | edit.EditTarget.Metadata.(*MessageMetadata).EditCount = resp.MessageData.EditCount 231 | return nil 232 | } 233 | 234 | func (tc *TwitterClient) HandleMatrixViewingChat(ctx context.Context, chat *bridgev2.MatrixViewingChat) error { 235 | conversationID := "" 236 | if chat.Portal != nil { 237 | conversationID = string(chat.Portal.ID) 238 | } 239 | tc.client.SetActiveConversation(conversationID) 240 | return nil 241 | } 242 | -------------------------------------------------------------------------------- /pkg/connector/ids.go: -------------------------------------------------------------------------------- 1 | // mautrix-twitter - A Matrix-Twitter puppeting bridge. 2 | // Copyright (C) 2025 Tulir Asokan 3 | // 4 | // This program is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU Affero General Public License as published by 6 | // the Free Software Foundation, either version 3 of the License, or 7 | // (at your option) any later version. 8 | // 9 | // This program is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU Affero General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU Affero General Public License 15 | // along with this program. If not, see . 16 | 17 | package connector 18 | 19 | import ( 20 | "bytes" 21 | "encoding/binary" 22 | "fmt" 23 | "io" 24 | "strconv" 25 | "strings" 26 | 27 | "maunium.net/go/mautrix/bridgev2" 28 | "maunium.net/go/mautrix/bridgev2/networkid" 29 | 30 | "go.mau.fi/mautrix-twitter/pkg/twittermeow/data/response" 31 | "go.mau.fi/mautrix-twitter/pkg/twittermeow/data/types" 32 | ) 33 | 34 | func (tc *TwitterClient) makePortalKeyFromInbox(conversationID string, inbox *response.TwitterInboxData) networkid.PortalKey { 35 | conv := inbox.GetConversationByID(conversationID) 36 | if conv != nil { 37 | return tc.MakePortalKey(conv) 38 | } else { 39 | return tc.MakePortalKeyFromID(conversationID) 40 | } 41 | } 42 | 43 | func (tc *TwitterClient) MakePortalKey(conv *types.Conversation) networkid.PortalKey { 44 | var receiver networkid.UserLoginID 45 | if conv.Type == types.ConversationTypeOneToOne || tc.connector.br.Config.SplitPortals { 46 | receiver = tc.userLogin.ID 47 | } 48 | return networkid.PortalKey{ 49 | ID: networkid.PortalID(conv.ConversationID), 50 | Receiver: receiver, 51 | } 52 | } 53 | 54 | func (tc *TwitterClient) MakePortalKeyFromID(conversationID string) networkid.PortalKey { 55 | var receiver networkid.UserLoginID 56 | if strings.Contains(conversationID, "-") || tc.connector.br.Config.SplitPortals { 57 | receiver = tc.userLogin.ID 58 | } 59 | return networkid.PortalKey{ 60 | ID: networkid.PortalID(conversationID), 61 | Receiver: receiver, 62 | } 63 | } 64 | 65 | func MakeUserID(userID string) networkid.UserID { 66 | return networkid.UserID(userID) 67 | } 68 | 69 | func ParseUserID(userID networkid.UserID) string { 70 | return string(userID) 71 | } 72 | 73 | func UserIDToUserLoginID(userID networkid.UserID) networkid.UserLoginID { 74 | return networkid.UserLoginID(userID) 75 | } 76 | 77 | func UserLoginIDToUserID(userID networkid.UserLoginID) networkid.UserID { 78 | return networkid.UserID(userID) 79 | } 80 | 81 | func MakeUserLoginID(userID string) networkid.UserLoginID { 82 | return networkid.UserLoginID(userID) 83 | } 84 | 85 | func ParseUserLoginID(userID networkid.UserLoginID) string { 86 | return string(userID) 87 | } 88 | 89 | func (tc *TwitterClient) MakeEventSender(userID string) bridgev2.EventSender { 90 | return bridgev2.EventSender{ 91 | IsFromMe: userID == string(tc.userLogin.ID), 92 | SenderLogin: MakeUserLoginID(userID), 93 | Sender: MakeUserID(userID), 94 | } 95 | } 96 | 97 | type MediaInfo struct { 98 | UserID networkid.UserLoginID 99 | URL string 100 | } 101 | 102 | func MakeMediaID(userID networkid.UserLoginID, URL string) networkid.MediaID { 103 | mediaID := []byte{1} 104 | uID, err := strconv.ParseUint(ParseUserLoginID(userID), 10, 64) 105 | if err != nil { 106 | panic(err) 107 | } 108 | mediaID = binary.AppendUvarint(mediaID, uID) 109 | 110 | bs := []byte(URL) 111 | mediaID = binary.AppendUvarint(mediaID, uint64(len(bs))) 112 | mediaID, err = binary.Append(mediaID, binary.BigEndian, bs) 113 | if err != nil { 114 | panic(err) 115 | } 116 | 117 | return mediaID 118 | } 119 | 120 | func ParseMediaID(mediaID networkid.MediaID) (*MediaInfo, error) { 121 | buf := bytes.NewReader(mediaID) 122 | version := make([]byte, 1) 123 | _, err := io.ReadFull(buf, version) 124 | if err != nil { 125 | return nil, err 126 | } 127 | if version[0] != byte(1) { 128 | return nil, fmt.Errorf("unknown mediaID version: %v", version) 129 | } 130 | 131 | mediaInfo := &MediaInfo{} 132 | uID, err := binary.ReadUvarint(buf) 133 | if err != nil { 134 | return nil, err 135 | } 136 | mediaInfo.UserID = MakeUserLoginID(strconv.FormatUint(uID, 10)) 137 | 138 | size, err := binary.ReadUvarint(buf) 139 | if err != nil { 140 | return nil, err 141 | } 142 | bs := make([]byte, size) 143 | _, err = io.ReadFull(buf, bs) 144 | if err != nil { 145 | return nil, err 146 | } 147 | mediaInfo.URL = string(bs) 148 | 149 | return mediaInfo, nil 150 | } 151 | -------------------------------------------------------------------------------- /pkg/connector/login.go: -------------------------------------------------------------------------------- 1 | // mautrix-twitter - A Matrix-Twitter puppeting bridge. 2 | // Copyright (C) 2024 Tulir Asokan 3 | // 4 | // This program is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU Affero General Public License as published by 6 | // the Free Software Foundation, either version 3 of the License, or 7 | // (at your option) any later version. 8 | // 9 | // This program is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU Affero General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU Affero General Public License 15 | // along with this program. If not, see . 16 | 17 | package connector 18 | 19 | import ( 20 | "context" 21 | "fmt" 22 | 23 | "maunium.net/go/mautrix/bridgev2" 24 | "maunium.net/go/mautrix/bridgev2/database" 25 | "maunium.net/go/mautrix/bridgev2/status" 26 | 27 | "go.mau.fi/mautrix-twitter/pkg/twittermeow" 28 | twitCookies "go.mau.fi/mautrix-twitter/pkg/twittermeow/cookies" 29 | "go.mau.fi/mautrix-twitter/pkg/twittermeow/data/response" 30 | ) 31 | 32 | type TwitterLogin struct { 33 | User *bridgev2.User 34 | Cookies string 35 | tc *TwitterConnector 36 | } 37 | 38 | var ( 39 | LoginStepIDCookies = "fi.mau.twitter.login.enter_cookies" 40 | LoginStepIDComplete = "fi.mau.twitter.login.complete" 41 | ) 42 | 43 | var _ bridgev2.LoginProcessCookies = (*TwitterLogin)(nil) 44 | 45 | func (tc *TwitterConnector) GetLoginFlows() []bridgev2.LoginFlow { 46 | return []bridgev2.LoginFlow{ 47 | { 48 | Name: "Cookies", 49 | Description: "Log in with your Twitter account using your cookies", 50 | ID: "cookies", 51 | }, 52 | } 53 | } 54 | 55 | func (tc *TwitterConnector) CreateLogin(_ context.Context, user *bridgev2.User, flowID string) (bridgev2.LoginProcess, error) { 56 | if flowID != "cookies" { 57 | return nil, fmt.Errorf("unknown login flow ID: %s", flowID) 58 | } 59 | return &TwitterLogin{User: user, tc: tc}, nil 60 | } 61 | 62 | func (t *TwitterLogin) Start(_ context.Context) (*bridgev2.LoginStep, error) { 63 | return &bridgev2.LoginStep{ 64 | Type: bridgev2.LoginStepTypeCookies, 65 | StepID: LoginStepIDCookies, 66 | Instructions: "Open the Login URL in an Incognito/Private browsing mode. Then, extract the cookies as a JSON object/cURL command copied from the Network tab of your browser's DevTools. After that, close the browser **before** pasting the cookies.\n\nFor example: `{\"ct0\":\"123466-...\",\"auth_token\":\"abcde-...\"}`", 67 | CookiesParams: &bridgev2.LoginCookiesParams{ 68 | URL: "https://x.com", 69 | UserAgent: "", 70 | Fields: []bridgev2.LoginCookieField{ 71 | { 72 | ID: "ct0", 73 | Required: true, 74 | Sources: []bridgev2.LoginCookieFieldSource{ 75 | {Type: bridgev2.LoginCookieTypeCookie, Name: "ct0"}, 76 | }, 77 | }, 78 | { 79 | ID: "auth_token", 80 | Required: true, 81 | Sources: []bridgev2.LoginCookieFieldSource{ 82 | {Type: bridgev2.LoginCookieTypeCookie, Name: "auth_token"}, 83 | }, 84 | }, 85 | }, 86 | }, 87 | }, nil 88 | } 89 | 90 | func (t *TwitterLogin) Cancel() {} 91 | 92 | func (t *TwitterLogin) SubmitCookies(ctx context.Context, cookies map[string]string) (*bridgev2.LoginStep, error) { 93 | cookieStruct := twitCookies.NewCookies(cookies) 94 | meta := &UserLoginMetadata{ 95 | Cookies: cookieStruct.String(), 96 | } 97 | 98 | clientOpts := &twittermeow.ClientOpts{ 99 | Cookies: cookieStruct, 100 | WithJOTClient: true, 101 | } 102 | client := twittermeow.NewClient(clientOpts, t.User.Log.With().Str("component", "login_twitter_client").Logger()) 103 | 104 | inboxState, settings, err := client.LoadMessagesPage(ctx) 105 | if err != nil { 106 | return nil, fmt.Errorf("failed to load messages page after submitting cookies: %w", err) 107 | } 108 | remoteProfile := t.tc.makeRemoteProfile(ctx, client, client.GetCurrentUserID(), inboxState.InboxInitialState) 109 | if remoteProfile == nil { 110 | remoteProfile = &status.RemoteProfile{ 111 | Username: settings.ScreenName, 112 | } 113 | } 114 | id := MakeUserLoginID(client.GetCurrentUserID()) 115 | ul, err := t.User.NewLogin( 116 | ctx, 117 | &database.UserLogin{ 118 | ID: id, 119 | Metadata: meta, 120 | RemoteName: remoteProfile.Username, 121 | RemoteProfile: *remoteProfile, 122 | }, 123 | &bridgev2.NewLoginParams{ 124 | DeleteOnConflict: true, 125 | DontReuseExisting: false, 126 | LoadUserLogin: func(ctx context.Context, login *bridgev2.UserLogin) error { 127 | client.Logger = login.Log.With().Str("component", "twitter_client").Logger() 128 | login.Client = NewTwitterClient(login, t.tc, client) 129 | return nil 130 | }, 131 | }, 132 | ) 133 | if err != nil { 134 | return nil, err 135 | } 136 | 137 | go func(ctx context.Context, client *TwitterClient, inboxState *response.InboxInitialStateResponse) { 138 | client.syncChannels(ctx, inboxState.InboxInitialState) 139 | client.startPolling(ctx) 140 | }(context.WithoutCancel(ctx), ul.Client.(*TwitterClient), inboxState) 141 | 142 | return &bridgev2.LoginStep{ 143 | Type: bridgev2.LoginStepTypeComplete, 144 | StepID: LoginStepIDComplete, 145 | Instructions: fmt.Sprintf("Successfully logged into @%s", ul.UserLogin.RemoteName), 146 | CompleteParams: &bridgev2.LoginCompleteParams{ 147 | UserLoginID: ul.ID, 148 | UserLogin: ul, 149 | }, 150 | }, nil 151 | } 152 | -------------------------------------------------------------------------------- /pkg/connector/msgconv.go: -------------------------------------------------------------------------------- 1 | // mautrix-twitter - A Matrix-Twitter puppeting bridge. 2 | // Copyright (C) 2025 Tulir Asokan 3 | // 4 | // This program is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU Affero General Public License as published by 6 | // the Free Software Foundation, either version 3 of the License, or 7 | // (at your option) any later version. 8 | // 9 | // This program is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU Affero General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU Affero General Public License 15 | // along with this program. If not, see . 16 | 17 | package connector 18 | 19 | import ( 20 | "context" 21 | "fmt" 22 | "io" 23 | "net/http" 24 | "os" 25 | "strings" 26 | 27 | "github.com/rs/zerolog" 28 | "go.mau.fi/util/exmime" 29 | "maunium.net/go/mautrix/bridgev2" 30 | "maunium.net/go/mautrix/bridgev2/database" 31 | "maunium.net/go/mautrix/bridgev2/networkid" 32 | "maunium.net/go/mautrix/event" 33 | 34 | "go.mau.fi/util/ffmpeg" 35 | 36 | "go.mau.fi/mautrix-twitter/pkg/connector/twitterfmt" 37 | "go.mau.fi/mautrix-twitter/pkg/twittermeow" 38 | "go.mau.fi/mautrix-twitter/pkg/twittermeow/data/types" 39 | ) 40 | 41 | func (tc *TwitterClient) convertEditToMatrix(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI, existing []*database.Message, data *types.MessageData) (*bridgev2.ConvertedEdit, error) { 42 | if ec := existing[0].Metadata.(*MessageMetadata).EditCount; ec >= data.EditCount { 43 | return nil, fmt.Errorf("%w: db edit count %d >= remote edit count %d", bridgev2.ErrIgnoringRemoteEvent, ec, data.EditCount) 44 | } 45 | data.Text = strings.TrimPrefix(data.Text, "Edited: ") 46 | editPart := tc.convertToMatrix(ctx, portal, intent, data).Parts[0].ToEditPart(existing[0]) 47 | editPart.Part.Metadata = &MessageMetadata{EditCount: data.EditCount} 48 | return &bridgev2.ConvertedEdit{ 49 | ModifiedParts: []*bridgev2.ConvertedEditPart{editPart}, 50 | }, nil 51 | } 52 | 53 | func (tc *TwitterClient) convertToMatrix(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI, msg *types.MessageData) *bridgev2.ConvertedMessage { 54 | var replyTo *networkid.MessageOptionalPartID 55 | if msg.ReplyData.ID != "" { 56 | replyTo = &networkid.MessageOptionalPartID{ 57 | MessageID: networkid.MessageID(msg.ReplyData.ID), 58 | } 59 | } 60 | 61 | textPart := &bridgev2.ConvertedMessagePart{ 62 | ID: "", 63 | Type: event.EventMessage, 64 | Content: twitterfmt.Parse(ctx, portal, msg), 65 | } 66 | 67 | parts := make([]*bridgev2.ConvertedMessagePart, 0) 68 | 69 | if msg.Attachment != nil { 70 | convertedAttachmentPart, indices, err := tc.twitterAttachmentToMatrix(ctx, portal, intent, msg) 71 | if err != nil { 72 | zerolog.Ctx(ctx).Err(err).Msg("Failed to convert attachment") 73 | parts = append(parts, &bridgev2.ConvertedMessagePart{ 74 | ID: "", 75 | Type: event.EventMessage, 76 | Content: &event.MessageEventContent{ 77 | MsgType: event.MsgNotice, 78 | Body: "Failed to convert attachment from Twitter", 79 | }, 80 | }) 81 | } else { 82 | if msg.Attachment.Card != nil || msg.Attachment.Tweet != nil { 83 | textPart.Content.BeeperLinkPreviews = convertedAttachmentPart.Content.BeeperLinkPreviews 84 | } else { 85 | parts = append(parts, convertedAttachmentPart) 86 | removeEntityLinkFromText(textPart, indices) 87 | } 88 | } 89 | } 90 | 91 | if len(textPart.Content.Body) > 0 { 92 | parts = append(parts, textPart) 93 | } 94 | for _, part := range parts { 95 | part.DBMetadata = &MessageMetadata{EditCount: msg.EditCount} 96 | } 97 | 98 | cm := &bridgev2.ConvertedMessage{ 99 | ReplyTo: replyTo, 100 | Parts: parts, 101 | } 102 | cm.MergeCaption() 103 | 104 | return cm 105 | } 106 | 107 | func removeEntityLinkFromText(msgPart *bridgev2.ConvertedMessagePart, indices []int) { 108 | start, end := indices[0], indices[1] 109 | msgPart.Content.Body = msgPart.Content.Body[:start-1] + msgPart.Content.Body[end:] 110 | } 111 | 112 | func (tc *TwitterClient) twitterAttachmentToMatrix(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI, msg *types.MessageData) (*bridgev2.ConvertedMessagePart, []int, error) { 113 | attachment := msg.Attachment 114 | var attachmentInfo *types.AttachmentInfo 115 | var attachmentURL string 116 | var mimeType string 117 | var msgType event.MessageType 118 | extraInfo := map[string]any{} 119 | if attachment.Photo != nil { 120 | attachmentInfo = attachment.Photo 121 | mimeType = "image/jpeg" // attachment doesn't include this specifically 122 | msgType = event.MsgImage 123 | attachmentURL = attachmentInfo.MediaURLHTTPS 124 | } else if attachment.Video != nil || attachment.AnimatedGif != nil { 125 | if attachment.AnimatedGif != nil { 126 | attachmentInfo = attachment.AnimatedGif 127 | extraInfo["fi.mau.gif"] = true 128 | extraInfo["fi.mau.loop"] = true 129 | extraInfo["fi.mau.autoplay"] = true 130 | extraInfo["fi.mau.hide_controls"] = true 131 | extraInfo["fi.mau.no_audio"] = true 132 | } else { 133 | attachmentInfo = attachment.Video 134 | } 135 | mimeType = "video/mp4" 136 | msgType = event.MsgVideo 137 | 138 | highestBitRateVariant, err := attachmentInfo.VideoInfo.GetHighestBitrateVariant() 139 | if err != nil { 140 | return nil, nil, err 141 | } 142 | attachmentURL = highestBitRateVariant.URL 143 | } else if attachment.Card != nil { 144 | content := event.MessageEventContent{ 145 | MsgType: event.MsgText, 146 | BeeperLinkPreviews: []*event.BeeperLinkPreview{tc.attachmentCardToMatrix(ctx, attachment.Card, msg.Entities.URLs)}, 147 | } 148 | return &bridgev2.ConvertedMessagePart{ 149 | ID: networkid.PartID(""), 150 | Type: event.EventMessage, 151 | Content: &content, 152 | }, []int{0, 0}, nil 153 | } else if attachment.Tweet != nil { 154 | content := event.MessageEventContent{ 155 | MsgType: event.MsgText, 156 | BeeperLinkPreviews: []*event.BeeperLinkPreview{tc.attachmentTweetToMatrix(ctx, portal, intent, attachment.Tweet)}, 157 | } 158 | return &bridgev2.ConvertedMessagePart{ 159 | ID: networkid.PartID(""), 160 | Type: event.EventMessage, 161 | Content: &content, 162 | }, []int{0, 0}, nil 163 | } else { 164 | return nil, nil, fmt.Errorf("unsupported attachment type") 165 | } 166 | 167 | fileResp, err := tc.downloadFile(ctx, attachmentURL) 168 | if err != nil { 169 | return nil, nil, err 170 | } 171 | content := event.MessageEventContent{ 172 | Info: &event.FileInfo{ 173 | MimeType: mimeType, 174 | Width: attachmentInfo.OriginalInfo.Width, 175 | Height: attachmentInfo.OriginalInfo.Height, 176 | Duration: attachmentInfo.VideoInfo.DurationMillis, 177 | }, 178 | MsgType: msgType, 179 | Body: attachmentInfo.IDStr, 180 | } 181 | if content.Body == "" { 182 | content.Body = strings.TrimPrefix(string(msgType), "m.") 183 | } 184 | 185 | audioOnly := attachment.Video != nil && attachment.Video.AudioOnly 186 | if tc.connector.directMedia { 187 | content.URL, err = tc.connector.br.Matrix.GenerateContentURI(ctx, MakeMediaID(portal.Receiver, attachmentURL)) 188 | } else { 189 | content.URL, content.File, err = intent.UploadMediaStream(ctx, portal.MXID, fileResp.ContentLength, audioOnly, func(file io.Writer) (*bridgev2.FileStreamResult, error) { 190 | n, err := io.Copy(file, fileResp.Body) 191 | if err != nil { 192 | return nil, err 193 | } 194 | if audioOnly && ffmpeg.Supported() { 195 | outFile, err := ffmpeg.ConvertPath(ctx, file.(*os.File).Name(), ".ogg", []string{}, []string{"-c:a", "libopus"}, false) 196 | if err == nil { 197 | mimeType = "audio/ogg" 198 | content.Info.MimeType = mimeType 199 | content.Info.Width = 0 200 | content.Info.Height = 0 201 | content.MsgType = event.MsgAudio 202 | content.Body += ".ogg" 203 | return &bridgev2.FileStreamResult{ 204 | ReplacementFile: outFile, 205 | MimeType: mimeType, 206 | FileName: content.Body, 207 | }, nil 208 | } else { 209 | zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to convert voice message to ogg") 210 | } 211 | } else { 212 | content.Info.Size = int(n) 213 | } 214 | ext := exmime.ExtensionFromMimetype(mimeType) 215 | if !strings.HasSuffix(content.Body, ext) { 216 | content.Body += ext 217 | } 218 | return &bridgev2.FileStreamResult{ 219 | MimeType: content.Info.MimeType, 220 | FileName: content.Body, 221 | }, nil 222 | }) 223 | } 224 | if err != nil { 225 | return nil, nil, err 226 | } 227 | 228 | if audioOnly { 229 | content.MSC3245Voice = &event.MSC3245Voice{} 230 | } 231 | return &bridgev2.ConvertedMessagePart{ 232 | ID: networkid.PartID(""), 233 | Type: event.EventMessage, 234 | Content: &content, 235 | Extra: map[string]any{ 236 | "info": extraInfo, 237 | }, 238 | }, attachmentInfo.Indices, nil 239 | } 240 | 241 | func downloadFile(ctx context.Context, cli *twittermeow.Client, url string) (*http.Response, error) { 242 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) 243 | if err != nil { 244 | return nil, fmt.Errorf("failed to prepare request: %w", err) 245 | } 246 | 247 | headers := twittermeow.BaseHeaders.Clone() 248 | headers.Set("Cookie", cli.GetCookieString()) 249 | req.Header = headers 250 | 251 | getResp, err := cli.HTTP.Do(req) 252 | if err != nil { 253 | return nil, fmt.Errorf("failed to send request: %w", err) 254 | } 255 | return getResp, nil 256 | } 257 | 258 | func (tc *TwitterClient) downloadFile(ctx context.Context, url string) (*http.Response, error) { 259 | return downloadFile(ctx, tc.client, url) 260 | } 261 | 262 | func (tc *TwitterClient) attachmentCardToMatrix(ctx context.Context, card *types.AttachmentCard, urls []types.URLs) *event.BeeperLinkPreview { 263 | canonicalURL := card.BindingValues.CardURL.StringValue 264 | for _, url := range urls { 265 | if url.URL == canonicalURL { 266 | canonicalURL = url.ExpandedURL 267 | break 268 | } 269 | } 270 | preview := &event.BeeperLinkPreview{ 271 | LinkPreview: event.LinkPreview{ 272 | CanonicalURL: canonicalURL, 273 | Title: card.BindingValues.Title.StringValue, 274 | Description: card.BindingValues.Description.StringValue, 275 | }, 276 | } 277 | return preview 278 | } 279 | 280 | func (tc *TwitterClient) attachmentTweetToMatrix(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI, tweet *types.AttachmentTweet) *event.BeeperLinkPreview { 281 | linkPreview := event.LinkPreview{ 282 | CanonicalURL: tweet.ExpandedURL, 283 | Title: tweet.Status.User.Name + " on X", 284 | Description: tweet.Status.FullText, 285 | } 286 | medias := tweet.Status.Entities.Media 287 | if len(medias) > 0 { 288 | media := medias[0] 289 | if media.Type == "photo" { 290 | resp, err := tc.downloadFile(ctx, media.MediaURLHTTPS) 291 | if err != nil { 292 | zerolog.Ctx(ctx).Err(err).Msg("failed to download tweet image") 293 | } else { 294 | linkPreview.ImageType = "image/jpeg" 295 | linkPreview.ImageWidth = event.IntOrString(media.OriginalInfo.Width) 296 | linkPreview.ImageHeight = event.IntOrString(media.OriginalInfo.Height) 297 | linkPreview.ImageSize = event.IntOrString(resp.ContentLength) 298 | linkPreview.ImageURL, _, err = intent.UploadMediaStream(ctx, portal.MXID, resp.ContentLength, false, func(file io.Writer) (*bridgev2.FileStreamResult, error) { 299 | _, err := io.Copy(file, resp.Body) 300 | if err != nil { 301 | return nil, err 302 | } 303 | return &bridgev2.FileStreamResult{ 304 | MimeType: linkPreview.ImageType, 305 | FileName: "image.jpeg", 306 | }, nil 307 | }) 308 | if err != nil { 309 | zerolog.Ctx(ctx).Err(err).Msg("failed to upload tweet image to Matrix") 310 | } 311 | } 312 | } 313 | } 314 | return &event.BeeperLinkPreview{ 315 | LinkPreview: linkPreview, 316 | } 317 | } 318 | -------------------------------------------------------------------------------- /pkg/connector/push.go: -------------------------------------------------------------------------------- 1 | // mautrix-twitter - A Matrix-Twitter puppeting bridge. 2 | // Copyright (C) 2025 Tulir Asokan 3 | // 4 | // This program is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU Affero General Public License as published by 6 | // the Free Software Foundation, either version 3 of the License, or 7 | // (at your option) any later version. 8 | // 9 | // This program is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU Affero General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU Affero General Public License 15 | // along with this program. If not, see . 16 | 17 | package connector 18 | 19 | import ( 20 | "context" 21 | "fmt" 22 | 23 | "maunium.net/go/mautrix/bridgev2" 24 | 25 | "go.mau.fi/mautrix-twitter/pkg/twittermeow" 26 | "go.mau.fi/mautrix-twitter/pkg/twittermeow/data/payload" 27 | ) 28 | 29 | var _ bridgev2.PushableNetworkAPI = (*TwitterClient)(nil) 30 | 31 | var pushCfg = &bridgev2.PushConfig{ 32 | Web: &bridgev2.WebPushConfig{VapidKey: "BF5oEo0xDUpgylKDTlsd8pZmxQA1leYINiY-rSscWYK_3tWAkz4VMbtf1MLE_Yyd6iII6o-e3Q9TCN5vZMzVMEs"}, 33 | } 34 | 35 | var pushSettings = &payload.PushNotificationSettings{ 36 | Addressbook: "off", 37 | Ads: "off", 38 | DirectMessages: "on", 39 | DMReaction: "reaction_your_own", 40 | FollowersNonVit: "off", 41 | FollowersVit: "off", 42 | LifelineAlerts: "off", 43 | LikesNonVit: "off", 44 | LikesVit: "off", 45 | LiveVideo: "off", 46 | Mentions: "off", 47 | Moments: "off", 48 | News: "off", 49 | PhotoTags: "off", 50 | Recommendations: "off", 51 | Retweets: "off", 52 | Spaces: "off", 53 | Topics: "off", 54 | Tweets: "off", 55 | } 56 | 57 | func (tc *TwitterClient) GetPushConfigs() *bridgev2.PushConfig { 58 | return pushCfg 59 | } 60 | 61 | func (tc *TwitterClient) RegisterPushNotifications(ctx context.Context, pushType bridgev2.PushType, token string) error { 62 | if tc.client == nil { 63 | return bridgev2.ErrNotLoggedIn 64 | } 65 | switch pushType { 66 | case bridgev2.PushTypeWeb: 67 | meta := tc.userLogin.Metadata.(*UserLoginMetadata) 68 | if meta.PushKeys == nil { 69 | meta.GeneratePushKeys() 70 | err := tc.userLogin.Save(ctx) 71 | if err != nil { 72 | return fmt.Errorf("failed to save push key: %w", err) 73 | } 74 | } 75 | pc := twittermeow.WebPushConfig{ 76 | Endpoint: token, 77 | Auth: meta.PushKeys.Auth, 78 | P256DH: meta.PushKeys.P256DH, 79 | } 80 | err := tc.client.SetPushNotificationConfig(ctx, twittermeow.PushRegister, pc) 81 | if err != nil { 82 | return fmt.Errorf("failed to set push notification config: %w", err) 83 | } 84 | pc.Settings = pushSettings 85 | err = tc.client.SetPushNotificationConfig(ctx, twittermeow.PushSave, pc) 86 | if err != nil { 87 | return fmt.Errorf("failed to set push notification preferences: %w", err) 88 | } 89 | return nil 90 | default: 91 | return fmt.Errorf("unsupported push type: %v", pushType) 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /pkg/connector/startchat.go: -------------------------------------------------------------------------------- 1 | package connector 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "maunium.net/go/mautrix/bridgev2" 8 | "maunium.net/go/mautrix/bridgev2/networkid" 9 | 10 | "go.mau.fi/mautrix-twitter/pkg/twittermeow/data/payload" 11 | "go.mau.fi/mautrix-twitter/pkg/twittermeow/data/types" 12 | "go.mau.fi/mautrix-twitter/pkg/twittermeow/methods" 13 | ) 14 | 15 | var ( 16 | _ bridgev2.IdentifierResolvingNetworkAPI = (*TwitterClient)(nil) 17 | ) 18 | 19 | func (tc *TwitterClient) ResolveIdentifier(ctx context.Context, identifier string, startChat bool) (*bridgev2.ResolveIdentifierResponse, error) { 20 | response, err := tc.client.Search(ctx, payload.SearchQuery{ 21 | Query: identifier, 22 | ResultType: payload.SEARCH_RESULT_TYPE_USERS, 23 | }) 24 | if err != nil { 25 | return nil, err 26 | } 27 | var resolvedUser types.User 28 | for _, user := range response.Users { 29 | if user.ScreenName == identifier { 30 | resolvedUser = user 31 | } 32 | } 33 | ghost, err := tc.connector.br.GetGhostByID(ctx, MakeUserID(resolvedUser.IDStr)) 34 | if err != nil { 35 | return nil, fmt.Errorf("failed to get ghost from Twitter User ID: %w", err) 36 | } 37 | 38 | var portalKey networkid.PortalKey 39 | if startChat { 40 | permissions, err := tc.client.GetDMPermissions(ctx, payload.GetDMPermissionsQuery{ 41 | RecipientIDs: resolvedUser.IDStr, 42 | DMUsers: true, 43 | }) 44 | if err != nil { 45 | return nil, fmt.Errorf("failed to get DM permissions for Twitter User: %w", err) 46 | } 47 | 48 | perms := permissions.Permissions.GetPermissionsForUser(resolvedUser.IDStr) 49 | 50 | if !perms.CanDM || perms.ErrorCode > 0 { 51 | return nil, fmt.Errorf("not allowed to DM this Twitter user: %v", resolvedUser.IDStr) 52 | } 53 | 54 | conversationID := methods.CreateConversationID([]string{resolvedUser.IDStr, ParseUserLoginID(tc.userLogin.ID)}) 55 | portalKey = tc.MakePortalKeyFromID(conversationID) 56 | } 57 | 58 | return &bridgev2.ResolveIdentifierResponse{ 59 | Ghost: ghost, 60 | UserID: MakeUserID(resolvedUser.IDStr), 61 | Chat: &bridgev2.CreateChatResponse{PortalKey: portalKey}, 62 | }, nil 63 | } 64 | -------------------------------------------------------------------------------- /pkg/connector/twitterfmt/convert.go: -------------------------------------------------------------------------------- 1 | package twitterfmt 2 | 3 | import ( 4 | "cmp" 5 | "context" 6 | "fmt" 7 | "html" 8 | "math" 9 | "slices" 10 | "strings" 11 | 12 | "github.com/rs/zerolog" 13 | "maunium.net/go/mautrix/bridgev2" 14 | "maunium.net/go/mautrix/bridgev2/networkid" 15 | "maunium.net/go/mautrix/event" 16 | 17 | "go.mau.fi/mautrix-twitter/pkg/twittermeow/data/types" 18 | ) 19 | 20 | func Parse(ctx context.Context, portal *bridgev2.Portal, msg *types.MessageData) *event.MessageEventContent { 21 | body := strings.Builder{} 22 | bodyHTML := strings.Builder{} 23 | charArr := []rune(msg.Text) 24 | cursor := 0 25 | sortedEntites := sortEntities(msg.Entities) 26 | var mentions event.Mentions 27 | 28 | for _, union := range sortedEntites { 29 | switch entity := union.(type) { 30 | case types.URLs: 31 | url := entity 32 | start, end := url.Indices[0], url.Indices[1] 33 | if cursor < start { 34 | body.WriteString(string(charArr[cursor:start])) 35 | bodyHTML.WriteString(string(charArr[cursor:start])) 36 | } 37 | body.WriteString(url.ExpandedURL) 38 | _, _ = fmt.Fprintf(&bodyHTML, 39 | `%s`, 40 | url.ExpandedURL, 41 | url.DisplayURL, 42 | ) 43 | cursor = end 44 | case types.UserMention: 45 | mention := entity 46 | start, end := mention.Indices[0], mention.Indices[1] 47 | body.WriteString(string(charArr[cursor:end])) 48 | if cursor < start { 49 | bodyHTML.WriteString(string(charArr[cursor:start])) 50 | } 51 | 52 | uid := mention.IDStr 53 | ghost, err := portal.Bridge.GetGhostByID(ctx, networkid.UserID(uid)) // TODO use MakeUserID 54 | if err != nil { 55 | zerolog.Ctx(ctx).Err(err).Msg("Failed to get ghost") 56 | bodyHTML.WriteString(string(charArr[start:end])) 57 | continue 58 | } 59 | targetMXID := ghost.Intent.GetMXID() 60 | login := portal.Bridge.GetCachedUserLoginByID(networkid.UserLoginID(uid)) // TODO use MakeUserLoginID 61 | if login != nil { 62 | targetMXID = login.UserMXID 63 | } 64 | _, _ = fmt.Fprintf(&bodyHTML, 65 | `%s`, 66 | targetMXID.URI().MatrixToURL(), 67 | string(charArr[start:end]), 68 | ) 69 | mentions.Add(targetMXID) 70 | cursor = end 71 | } 72 | } 73 | 74 | body.WriteString(string(charArr[cursor:])) 75 | content := &event.MessageEventContent{ 76 | MsgType: event.MsgText, 77 | Body: html.UnescapeString(body.String()), 78 | Mentions: &mentions, 79 | } 80 | 81 | if msg.Entities != nil { 82 | bodyHTML.WriteString(string(charArr[cursor:])) 83 | content.Format = event.FormatHTML 84 | content.FormattedBody = strings.ReplaceAll(bodyHTML.String(), "\n", "
") 85 | } 86 | 87 | return content 88 | } 89 | 90 | type EntityUnion = any 91 | 92 | func getStart(union any) int { 93 | switch entity := union.(type) { 94 | case types.URLs: 95 | return entity.Indices[0] 96 | case types.UserMention: 97 | return entity.Indices[0] 98 | default: 99 | return math.MaxInt32 100 | } 101 | } 102 | 103 | func sortEntities(entities *types.Entities) []EntityUnion { 104 | if entities == nil { 105 | return []EntityUnion{} 106 | } 107 | 108 | merged := make([]EntityUnion, 0) 109 | 110 | for _, url := range entities.URLs { 111 | merged = append(merged, url) 112 | } 113 | 114 | for _, mention := range entities.UserMentions { 115 | merged = append(merged, mention) 116 | } 117 | 118 | slices.SortFunc(merged, func(a EntityUnion, b EntityUnion) int { 119 | return cmp.Compare(getStart(a), getStart(b)) 120 | }) 121 | 122 | return merged 123 | } 124 | -------------------------------------------------------------------------------- /pkg/connector/twitterfmt/convert_test.go: -------------------------------------------------------------------------------- 1 | package twitterfmt_test 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | 9 | "go.mau.fi/mautrix-twitter/pkg/connector/twitterfmt" 10 | "go.mau.fi/mautrix-twitter/pkg/twittermeow/data/types" 11 | ) 12 | 13 | func TestParse(t *testing.T) { 14 | tests := []struct { 15 | name string 16 | ins string 17 | ine *types.Entities 18 | body string 19 | html string 20 | }{ 21 | { 22 | name: "plain", 23 | ins: "Hello world!", 24 | body: "Hello world!", 25 | }, 26 | { 27 | name: "emoji before url", 28 | ins: "🚀 https://t.co/WCPQgzfcO4 abc", 29 | ine: &types.Entities{ 30 | URLs: []types.URLs{ 31 | {ExpandedURL: "https://x.com", 32 | Indices: []int{2, 25}, 33 | }, 34 | }, 35 | }, 36 | body: "🚀 https://x.com abc", 37 | html: "🚀 https://x.com abc", 38 | }, 39 | } 40 | 41 | for _, test := range tests { 42 | t.Run(test.name, func(t *testing.T) { 43 | msg := &types.MessageData{ 44 | Text: test.ins, 45 | Entities: test.ine, 46 | } 47 | parsed := twitterfmt.Parse(context.TODO(), nil, msg) 48 | assert.Equal(t, test.body, parsed.Body) 49 | assert.Equal(t, test.html, parsed.FormattedBody) 50 | }) 51 | } 52 | 53 | } 54 | -------------------------------------------------------------------------------- /pkg/twittermeow/README.md: -------------------------------------------------------------------------------- 1 | # x-go 2 | A Go library for interacting with X's API 3 | 4 | ### Test steps 5 | 6 | 1. Create cookies.txt in this directory, grab your cookie string from x.com and paste it there 7 | 2. Run `go test client_test.go -v` 8 | 9 | ### Testing functionality 10 | 11 | ```go 12 | _, _, err = cli.LoadMessagesPage() 13 | if err != nil { 14 | log.Fatal(err) 15 | } 16 | ``` 17 | The `LoadMessagesPage` method makes a request to `https://x.com/messages` then makes 2 calls: 18 | ```go 19 | data, err := c.GetAccountSettings(...) 20 | initialInboxState, err := c.GetInitialInboxState(...) 21 | ``` 22 | it sets up the current "page" session for the client, fetches the current authenticated user info as well as the initial inbox state (the very starting inbox information you see when u load `/messages`) then returns the parsed data. 23 | 24 | To easily test with the available functions I have made, lets say you wanna test uploading an image and sending it to the top conversation in your inbox you could simply do something like: 25 | ```go 26 | initialInboxData, _, err := cli.LoadMessagesPage() 27 | if err != nil { 28 | log.Fatal(err) 29 | } 30 | uploadAndSendImageTest(initialInboxData) 31 | ``` 32 | Or feel free to try it out yourself! All the methods are available on the client instance. 33 | -------------------------------------------------------------------------------- /pkg/twittermeow/account.go: -------------------------------------------------------------------------------- 1 | package twittermeow 2 | 3 | import ( 4 | "context" 5 | "encoding/base64" 6 | "encoding/json" 7 | "fmt" 8 | "net/http" 9 | 10 | "go.mau.fi/mautrix-twitter/pkg/twittermeow/data/endpoints" 11 | "go.mau.fi/mautrix-twitter/pkg/twittermeow/data/payload" 12 | "go.mau.fi/mautrix-twitter/pkg/twittermeow/data/response" 13 | "go.mau.fi/mautrix-twitter/pkg/twittermeow/data/types" 14 | ) 15 | 16 | func (c *Client) Login(ctx context.Context) error { 17 | err := c.session.LoadPage(ctx, endpoints.BASE_LOGIN_URL) 18 | if err != nil { 19 | return err 20 | } 21 | return nil 22 | } 23 | 24 | func (c *Client) GetAccountSettings(ctx context.Context, params payload.AccountSettingsQuery) (*response.AccountSettingsResponse, error) { 25 | encodedQuery, err := params.Encode() 26 | if err != nil { 27 | return nil, err 28 | } 29 | url := fmt.Sprintf("%s?%s", endpoints.ACCOUNT_SETTINGS_URL, string(encodedQuery)) 30 | apiRequestOpts := apiRequestOpts{ 31 | URL: url, 32 | Method: http.MethodGet, 33 | } 34 | _, respBody, err := c.makeAPIRequest(ctx, apiRequestOpts) 35 | if err != nil { 36 | return nil, err 37 | } 38 | 39 | data := response.AccountSettingsResponse{} 40 | return &data, json.Unmarshal(respBody, &data) 41 | } 42 | 43 | func (c *Client) GetDMPermissions(ctx context.Context, params payload.GetDMPermissionsQuery) (*response.GetDMPermissionsResponse, error) { 44 | encodedQuery, err := params.Encode() 45 | if err != nil { 46 | return nil, err 47 | } 48 | url := fmt.Sprintf("%s?%s", endpoints.DM_PERMISSIONS_URL, string(encodedQuery)) 49 | apiRequestOpts := apiRequestOpts{ 50 | URL: url, 51 | Method: http.MethodGet, 52 | WithClientUUID: true, 53 | } 54 | _, respBody, err := c.makeAPIRequest(ctx, apiRequestOpts) 55 | if err != nil { 56 | return nil, err 57 | } 58 | 59 | data := response.GetDMPermissionsResponse{} 60 | return &data, json.Unmarshal(respBody, &data) 61 | } 62 | 63 | type WebPushConfig struct { 64 | Endpoint string 65 | P256DH []byte 66 | Auth []byte 67 | 68 | Settings *payload.PushNotificationSettings 69 | } 70 | 71 | type PushNotificationConfigAction int 72 | 73 | const ( 74 | PushRegister PushNotificationConfigAction = iota 75 | PushUnregister 76 | PushCheckin 77 | PushSave 78 | ) 79 | 80 | func (c *Client) SetPushNotificationConfig(ctx context.Context, action PushNotificationConfigAction, config WebPushConfig) error { 81 | var url string 82 | switch action { 83 | case PushRegister: 84 | url = endpoints.NOTIFICATION_LOGIN_URL 85 | case PushUnregister: 86 | url = endpoints.NOTIFICATION_LOGOUT_URL 87 | case PushCheckin: 88 | url = endpoints.NOTIFICATION_CHECKIN_URL 89 | case PushSave: 90 | url = endpoints.NOTIFICATION_SAVE_URL 91 | default: 92 | return fmt.Errorf("unknown push notification setting: %d", action) 93 | } 94 | 95 | webPushPayload := payload.WebPushConfigPayload{ 96 | Env: 3, 97 | ProtocolVersion: 1, 98 | 99 | Locale: "en", 100 | OSVersion: UDID, 101 | UDID: UDID, 102 | 103 | Token: config.Endpoint, 104 | P256DH: base64.RawURLEncoding.EncodeToString(config.P256DH), 105 | Auth: base64.RawURLEncoding.EncodeToString(config.Auth), 106 | 107 | Settings: config.Settings, 108 | } 109 | 110 | var wrappedPayload any 111 | if action != PushUnregister { 112 | wrappedPayload = &payload.PushConfigPayloadWrapper{ 113 | PushDeviceInfo: &webPushPayload, 114 | } 115 | } else { 116 | wrappedPayload = &webPushPayload 117 | } 118 | 119 | encodedBody, err := json.Marshal(wrappedPayload) 120 | if err != nil { 121 | return err 122 | } 123 | 124 | apiRequestOpts := apiRequestOpts{ 125 | URL: url, 126 | Method: http.MethodPost, 127 | WithClientUUID: true, 128 | Referer: endpoints.BASE_NOTIFICATION_SETTINGS_URL, 129 | Origin: endpoints.BASE_URL, 130 | Body: encodedBody, 131 | ContentType: types.ContentTypeJSON, 132 | } 133 | _, _, err = c.makeAPIRequest(ctx, apiRequestOpts) 134 | return err 135 | } 136 | -------------------------------------------------------------------------------- /pkg/twittermeow/cookies/cookies.go: -------------------------------------------------------------------------------- 1 | package cookies 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "strings" 7 | "sync" 8 | "time" 9 | ) 10 | 11 | type XCookieName string 12 | 13 | const ( 14 | XAuthToken XCookieName = "auth_token" 15 | XGuestID XCookieName = "guest_id" 16 | XNightMode XCookieName = "night_mode" 17 | XGuestToken XCookieName = "gt" 18 | XCt0 XCookieName = "ct0" 19 | XKdt XCookieName = "kdt" 20 | XTwid XCookieName = "twid" 21 | XLang XCookieName = "lang" 22 | XAtt XCookieName = "att" 23 | XPersonalizationID XCookieName = "personalization_id" 24 | XGuestIDMarketing XCookieName = "guest_id_marketing" 25 | ) 26 | 27 | type Cookies struct { 28 | store map[string]string 29 | lock sync.RWMutex 30 | } 31 | 32 | func NewCookies(store map[string]string) *Cookies { 33 | if store == nil { 34 | store = make(map[string]string) 35 | } 36 | return &Cookies{ 37 | store: store, 38 | lock: sync.RWMutex{}, 39 | } 40 | } 41 | 42 | func NewCookiesFromString(cookieStr string) *Cookies { 43 | c := NewCookies(nil) 44 | cookieStrings := strings.Split(cookieStr, ";") 45 | fakeHeader := http.Header{} 46 | for _, cookieStr := range cookieStrings { 47 | trimmedCookieStr := strings.TrimSpace(cookieStr) 48 | if trimmedCookieStr != "" { 49 | fakeHeader.Add("Set-Cookie", trimmedCookieStr) 50 | } 51 | } 52 | fakeResponse := &http.Response{Header: fakeHeader} 53 | 54 | for _, cookie := range fakeResponse.Cookies() { 55 | c.store[cookie.Name] = cookie.Value 56 | } 57 | 58 | return c 59 | } 60 | 61 | func (c *Cookies) String() string { 62 | c.lock.RLock() 63 | defer c.lock.RUnlock() 64 | var out []string 65 | for k, v := range c.store { 66 | out = append(out, fmt.Sprintf("%s=%s", k, v)) 67 | } 68 | return strings.Join(out, "; ") 69 | } 70 | 71 | func (c *Cookies) IsCookieEmpty(key XCookieName) bool { 72 | return c.Get(key) == "" 73 | } 74 | 75 | func (c *Cookies) Get(key XCookieName) string { 76 | c.lock.RLock() 77 | defer c.lock.RUnlock() 78 | return c.store[string(key)] 79 | } 80 | 81 | func (c *Cookies) Set(key XCookieName, value string) { 82 | c.lock.Lock() 83 | defer c.lock.Unlock() 84 | c.store[string(key)] = value 85 | } 86 | 87 | func (c *Cookies) UpdateFromResponse(r *http.Response) { 88 | c.lock.Lock() 89 | defer c.lock.Unlock() 90 | for _, cookie := range r.Cookies() { 91 | if cookie.MaxAge == 0 || cookie.Expires.Before(time.Now()) { 92 | delete(c.store, cookie.Name) 93 | } else { 94 | //log.Println(fmt.Sprintf("updated cookie %s to value %s", cookie.Name, cookie.Value)) 95 | c.store[cookie.Name] = cookie.Value 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /pkg/twittermeow/crypto/animation.go: -------------------------------------------------------------------------------- 1 | package crypto 2 | 3 | import ( 4 | "encoding/base64" 5 | "math" 6 | "strconv" 7 | "strings" 8 | ) 9 | 10 | func GenerateAnimationState(variableIndexes *[4]int, loadingAnims *[4][16][11]int, verificationToken string) string { 11 | verificationTokenBytes, err := base64.StdEncoding.DecodeString(verificationToken) 12 | if err != nil { 13 | return "" 14 | } 15 | svgData := loadingAnims[verificationTokenBytes[5]%4][verificationTokenBytes[variableIndexes[0]]%16] 16 | animationTime := int(verificationTokenBytes[variableIndexes[1]]%16) * int(verificationTokenBytes[variableIndexes[2]]%16) * int(verificationTokenBytes[variableIndexes[3]]%16) 17 | randomPart := generateAnimationStateWithParams(svgData[:], animationTime) 18 | return randomPart 19 | } 20 | 21 | const totalTime = 4096.0 22 | 23 | func generateAnimationStateWithParams(row []int, animTime int) string { 24 | if animTime >= totalTime-1 { 25 | return "000" 26 | } 27 | fromColor := []float64{float64(row[0]), float64(row[1]), float64(row[2]), 1.0} 28 | toColor := []float64{float64(row[3]), float64(row[4]), float64(row[5]), 1.0} 29 | fromRotation := []float64{0.0} 30 | toRotation := []float64{math.Floor(mapValueToRange(float64(row[6]), 60.0, 360.0))} 31 | row = row[7:] 32 | curves := [4]float64{} 33 | for i := 0; i < len(row); i++ { 34 | curves[i] = toFixed(mapValueToRange(float64(row[i]), isEven(i), 1.0), 2) 35 | } 36 | c := &cubic{Curves: curves} 37 | val := c.getValue(math.Round(float64(animTime)/10.0) * 10.0 / totalTime) 38 | color := interpolate(fromColor, toColor, val) 39 | rotation := interpolate(fromRotation, toRotation, val) 40 | matrix := convertRotationToMatrix(rotation[0]) 41 | strArr := []string{} 42 | for i := 0; i < len(color)-1; i++ { 43 | if color[i] < 0 { 44 | color[i] = 0 45 | } 46 | roundedColor := math.Round(color[i]) 47 | if roundedColor < 0 { 48 | roundedColor = 0 49 | } 50 | hexColor := strconv.FormatInt(int64(roundedColor), 16) 51 | strArr = append(strArr, hexColor) 52 | } 53 | for i := 0; i < len(matrix)-2; i++ { 54 | rounded := toFixed(matrix[i], 2) 55 | if rounded < 0 { 56 | rounded = -rounded 57 | } 58 | strArr = append(strArr, floatToHex(rounded)) 59 | } 60 | strArr = append(strArr, "0", "0") 61 | return strings.Join(strArr, "") 62 | } 63 | 64 | func interpolate(from, to []float64, f float64) []float64 { 65 | out := []float64{} 66 | for i := 0; i < len(from); i++ { 67 | out = append(out, interpolateNum(from[i], to[i], f)) 68 | } 69 | return out 70 | } 71 | 72 | func interpolateNum(from, to, f float64) float64 { 73 | return from*(1.0-f) + to*f 74 | } 75 | 76 | func convertRotationToMatrix(degrees float64) []float64 { 77 | radians := degrees * math.Pi / 180 78 | c := math.Cos(radians) 79 | s := math.Sin(radians) 80 | return []float64{c, s, -s, c, 0, 0} 81 | } 82 | 83 | type cubic struct { 84 | Curves [4]float64 85 | } 86 | 87 | func (c *cubic) getValue(time float64) float64 { 88 | startGradient := 0.0 89 | endGradient := 0.0 90 | if time <= 0.0 { 91 | if c.Curves[0] > 0.0 { 92 | startGradient = c.Curves[1] / c.Curves[0] 93 | } else if c.Curves[1] == 0.0 && c.Curves[2] > 0.0 { 94 | startGradient = c.Curves[3] / c.Curves[2] 95 | } 96 | return startGradient * time 97 | } 98 | 99 | if time >= 1.0 { 100 | if c.Curves[2] < 1.0 { 101 | endGradient = (c.Curves[3] - 1.0) / (c.Curves[2] - 1.0) 102 | } else if c.Curves[2] == 1.0 && c.Curves[0] < 1.0 { 103 | endGradient = (c.Curves[1] - 1.0) / (c.Curves[0] - 1.0) 104 | } 105 | return 1.0 + endGradient*(time-1.0) 106 | } 107 | 108 | start := 0.0 109 | end := 1.0 110 | mid := 0.0 111 | for start < end { 112 | mid = (start + end) / 2 113 | xEst := bezierCurve(c.Curves[0], c.Curves[2], mid) 114 | if math.Abs(time-xEst) < 0.00001 { 115 | return bezierCurve(c.Curves[1], c.Curves[3], mid) 116 | } 117 | if xEst < time { 118 | start = mid 119 | } else { 120 | end = mid 121 | } 122 | } 123 | return bezierCurve(c.Curves[1], c.Curves[3], mid) 124 | } 125 | 126 | func bezierCurve(a, b, m float64) float64 { 127 | return 3.0*a*(1-m)*(1-m)*m + 3.0*b*(1-m)*m*m + m*m*m 128 | } 129 | 130 | func roundPositive(num float64) int { 131 | return int(num + math.Copysign(0.5, num)) 132 | } 133 | 134 | func toFixed(num float64, precision int) float64 { 135 | output := math.Pow(10, float64(precision)) 136 | return float64(roundPositive(num*output)) / output 137 | } 138 | 139 | func floatToHex(x float64) string { 140 | quotient := int(x) 141 | fraction := x - float64(quotient) 142 | result := strconv.FormatInt(int64(quotient), 16) 143 | if fraction == 0 { 144 | return result 145 | } 146 | 147 | for fraction > 0 { 148 | fraction *= 16 149 | integer := int64(fraction) 150 | result += strconv.FormatInt(integer, 16) 151 | fraction -= float64(integer) 152 | } 153 | 154 | return result 155 | } 156 | 157 | func mapValueToRange(val, min, max float64) float64 { 158 | return val*(max-min)/255.0 + min 159 | } 160 | 161 | func isEven(val int) float64 { 162 | if val%2 == 1 { 163 | return -1.0 164 | } 165 | return 0.0 166 | } 167 | -------------------------------------------------------------------------------- /pkg/twittermeow/crypto/transaction.go: -------------------------------------------------------------------------------- 1 | package crypto 2 | 3 | import ( 4 | "bytes" 5 | "crypto/sha256" 6 | "encoding/base64" 7 | "encoding/binary" 8 | "fmt" 9 | "math/rand/v2" 10 | neturl "net/url" 11 | "strconv" 12 | "time" 13 | 14 | "go.mau.fi/util/random" 15 | ) 16 | 17 | func SignTransaction(animationToken, verificationToken, url, method string) (string, error) { 18 | verificationTokenBytes, err := base64.StdEncoding.DecodeString(verificationToken) 19 | if err != nil { 20 | return "", fmt.Errorf("failed to decode verification token: %w", err) 21 | } 22 | if len(verificationTokenBytes) < 9 { 23 | return "", fmt.Errorf("invalid verification token: %s", verificationToken) 24 | } 25 | parsedURL, err := neturl.Parse(url) 26 | if err != nil { 27 | return "", fmt.Errorf("failed to parse URL %q: %w", url, err) 28 | } 29 | 30 | ts, tsBytes := makeTSBytes() 31 | salt := base64.RawStdEncoding.EncodeToString([]byte{161, 183, 226, 163, 7, 171, 122, 24, 171, 138, 120}) 32 | hashInput := fmt.Sprintf("%s!%s!%s%s%s", method, parsedURL.Path, ts, salt, animationToken) 33 | rawHash := sha256.Sum256([]byte(hashInput)) 34 | sdp := generateSDP() 35 | sdpHash := []byte{sdp[verificationTokenBytes[5]%8], sdp[verificationTokenBytes[8]%8]} 36 | hash := append(rawHash[:], sdpHash...) 37 | 38 | resultBytes := bytes.NewBuffer(make([]byte, 0, 1+len(verificationTokenBytes)+len(tsBytes)+16+1)) 39 | resultBytes.WriteByte(byte(rand.IntN(256))) 40 | resultBytes.Write(verificationTokenBytes) 41 | resultBytes.Write(tsBytes) 42 | resultBytes.Write(hash[:16]) 43 | resultBytes.WriteByte(3) 44 | 45 | return base64.RawStdEncoding.EncodeToString(encodeXOR(resultBytes.Bytes())), nil 46 | } 47 | 48 | const sdpTemplate = "v=0\r\no=- %d 2 IN IP4 127.0.0.1\r\ns=-\r\nt=0 0\r\na=group:BUNDLE 0\r\na=extmap-allow-mixed\r\n" + 49 | "a=msid-semantic: WMS\r\nm=application 9 UDP/DTLS/SCTP webrtc-datachannel\r\nc=IN IP4 0.0.0.0\r\na=ice-ufrag:%s\r\n" + 50 | "a=ice-pwd:%s\r\na=ice-options:trickle\r\na=fingerprint:sha-256 00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:" + 51 | "00:00:00:00:00:00:00:00:00:00:00:00:00\r\na=setup:actpass\r\na=mid:0\r\na=sctp-port:5000\r\na=max-message-size:262144\r\n" 52 | 53 | func generateSDP() string { 54 | return fmt.Sprintf(sdpTemplate, rand.Int64(), random.String(4), random.String(24)) 55 | } 56 | 57 | func makeTSBytes() (string, []byte) { 58 | ts := time.Now().Unix() - 1682924400 59 | resultInt32 := make([]byte, 4) 60 | binary.LittleEndian.PutUint32(resultInt32, uint32(ts)) 61 | return strconv.FormatInt(ts, 10), resultInt32 62 | } 63 | 64 | func encodeXOR(plainArr []byte) []byte { 65 | encodedArr := make([]byte, len(plainArr)) 66 | for i := 0; i < len(plainArr); i++ { 67 | if i == 0 { 68 | encodedArr[i] = plainArr[i] 69 | } else { 70 | encodedArr[i] = plainArr[i] ^ plainArr[0] 71 | } 72 | } 73 | return encodedArr 74 | } 75 | -------------------------------------------------------------------------------- /pkg/twittermeow/data/endpoints/endpoints.go: -------------------------------------------------------------------------------- 1 | package endpoints 2 | 3 | // TODO fix variable name casing 4 | 5 | const ( 6 | TWITTER_BASE_HOST = "twitter.com" 7 | TWITTER_BASE_URL = "https://" + TWITTER_BASE_HOST 8 | 9 | BASE_HOST = "x.com" 10 | BASE_URL = "https://" + BASE_HOST 11 | BASE_LOGIN_URL = BASE_URL + "/login" 12 | BASE_MESSAGES_URL = BASE_URL + "/messages" 13 | BASE_LOGOUT_URL = BASE_URL + "/logout" 14 | BASE_NOTIFICATION_SETTINGS_URL = BASE_URL + "/settings/push_notifications" 15 | 16 | API_BASE_HOST = "api.x.com" 17 | API_BASE_URL = "https://" + API_BASE_HOST 18 | 19 | ACCOUNT_SETTINGS_URL = API_BASE_URL + "/1.1/account/settings.json" 20 | INBOX_INITIAL_STATE_URL = BASE_URL + "/i/api/1.1/dm/inbox_initial_state.json" 21 | DM_USER_UPDATES_URL = BASE_URL + "/i/api/1.1/dm/user_updates.json" 22 | CONVERSATION_MARK_READ_URL = BASE_URL + "/i/api/1.1/dm/conversation/%s/mark_read.json" 23 | CONVERSATION_FETCH_MESSAGES = BASE_URL + "/i/api/1.1/dm/conversation/%s.json" 24 | UPDATE_LAST_SEEN_EVENT_ID_URL = BASE_URL + "/i/api/1.1/dm/update_last_seen_event_id.json" 25 | TRUSTED_INBOX_TIMELINE_URL = BASE_URL + "/i/api/1.1/dm/inbox_timeline/trusted.json" 26 | SEND_DM_URL = BASE_URL + "/i/api/1.1/dm/new2.json" 27 | EDIT_DM_URL = BASE_URL + "/i/api/1.1/dm/edit.json" 28 | GRAPHQL_MESSAGE_DELETION_MUTATION = BASE_URL + "/i/api/graphql/BJ6DtxA2llfjnRoRjaiIiw/DMMessageDeleteMutation" 29 | SEARCH_TYPEAHEAD_URL = BASE_URL + "/i/api/1.1/search/typeahead.json" 30 | DM_PERMISSIONS_URL = BASE_URL + "/i/api/1.1/dm/permissions.json" 31 | DELETE_CONVERSATION_URL = BASE_URL + "/i/api/1.1/dm/conversation/%s/delete.json" 32 | PIN_CONVERSATION_URL = BASE_URL + "/i/api/graphql/o0aymgGiJY-53Y52YSUGVA/DMPinnedInboxAppend_Mutation" 33 | UNPIN_CONVERSATION_URL = BASE_URL + "/i/api/graphql/_TQxP2Rb0expwVP9ktGrTQ/DMPinnedInboxDelete_Mutation" 34 | GET_PINNED_CONVERSATIONS_URL = BASE_URL + "/i/api/graphql/_gBQBgClVuMQb8efxWkbbQ/DMPinnedInboxQuery" 35 | ADD_REACTION_URL = BASE_URL + "/i/api/graphql/VyDyV9pC2oZEj6g52hgnhA/useDMReactionMutationAddMutation" 36 | REMOVE_REACTION_URL = BASE_URL + "/i/api/graphql/bV_Nim3RYHsaJwMkTXJ6ew/useDMReactionMutationRemoveMutation" 37 | SEND_TYPING_NOTIFICATION = BASE_URL + "/i/api/graphql/HL96-xZ3Y81IEzAdczDokg/useTypingNotifierMutation" 38 | 39 | JOT_CLIENT_EVENT_URL = API_BASE_URL + "/1.1/jot/client_event.json" 40 | JOT_CES_P2_URL = API_BASE_URL + "/1.1/jot/ces/p2" 41 | 42 | PIPELINE_EVENTS_URL = API_BASE_URL + "/live_pipeline/events" 43 | PIPELINE_UPDATE_URL = API_BASE_URL + "/1.1/live_pipeline/update_subscriptions" 44 | 45 | UPLOAD_BASE_HOST = "upload.x.com" 46 | UPLOAD_BASE_URL = "https://" + UPLOAD_BASE_HOST 47 | UPLOAD_MEDIA_URL = UPLOAD_BASE_URL + "/i/media/upload.json" 48 | 49 | NOTIFICATION_SETTINGS_URL = BASE_URL + "/i/api/1.1/notifications/settings" 50 | NOTIFICATION_LOGIN_URL = NOTIFICATION_SETTINGS_URL + "/login.json" 51 | NOTIFICATION_LOGOUT_URL = NOTIFICATION_SETTINGS_URL + "/logout.json" 52 | NOTIFICATION_CHECKIN_URL = NOTIFICATION_SETTINGS_URL + "/checkin.json" 53 | NOTIFICATION_SAVE_URL = NOTIFICATION_SETTINGS_URL + "/save.json" 54 | ) 55 | -------------------------------------------------------------------------------- /pkg/twittermeow/data/payload/jot.go: -------------------------------------------------------------------------------- 1 | package payload 2 | 3 | import "encoding/json" 4 | 5 | type JotLoggingCategory string 6 | 7 | const ( 8 | JotLoggingCategoryPerftown JotLoggingCategory = "perftown" 9 | ) 10 | 11 | type JotLogPayload struct { 12 | Description string `json:"description,omitempty"` 13 | Product string `json:"product,omitempty"` 14 | DurationMS int64 `json:"duration_ms,omitempty"` 15 | EventValue int64 `json:"event_value,omitempty"` 16 | } 17 | 18 | func (p *JotLogPayload) ToJSON() ([]byte, error) { 19 | val := []interface{}{p} 20 | return json.Marshal(&val) 21 | } 22 | 23 | type JotDebugLoggingCategory string 24 | 25 | const ( 26 | JotDebugLoggingCategoryClientEvent JotDebugLoggingCategory = "client_event" 27 | ) 28 | 29 | type JotDebugLogPayload struct { 30 | Category JotDebugLoggingCategory `json:"_category_,omitempty"` 31 | FormatVersion int `json:"format_version,omitempty"` 32 | TriggeredOn int64 `json:"triggered_on,omitempty"` 33 | Items []any `json:"items,omitempty"` 34 | EventNamespace EventNamespace `json:"event_namespace,omitempty"` 35 | ClientEventSequenceStartTimestamp int64 `json:"client_event_sequence_start_timestamp,omitempty"` 36 | ClientEventSequenceNumber int `json:"client_event_sequence_number,omitempty"` 37 | ClientAppID string `json:"client_app_id,omitempty"` 38 | } 39 | 40 | type EventNamespace struct { 41 | Page string `json:"page,omitempty"` 42 | Action string `json:"action,omitempty"` 43 | Client string `json:"client,omitempty"` 44 | } 45 | -------------------------------------------------------------------------------- /pkg/twittermeow/data/payload/json.go: -------------------------------------------------------------------------------- 1 | package payload 2 | 3 | import "encoding/json" 4 | 5 | type SendDirectMessagePayload struct { 6 | ConversationID string `json:"conversation_id,omitempty"` 7 | MediaID string `json:"media_id,omitempty"` 8 | ReplyToDMID string `json:"reply_to_dm_id,omitempty"` 9 | RecipientIDs bool `json:"recipient_ids"` 10 | RequestID string `json:"request_id,omitempty"` 11 | Text string `json:"text"` 12 | CardsPlatform string `json:"cards_platform,omitempty"` 13 | IncludeCards int `json:"include_cards,omitempty"` 14 | IncludeQuoteCount bool `json:"include_quote_count"` 15 | DMUsers bool `json:"dm_users"` 16 | AudioOnlyMediaAttachment bool `json:"audio_only_media_attachment"` 17 | } 18 | 19 | func (p *SendDirectMessagePayload) Encode() ([]byte, error) { 20 | return json.Marshal(p) 21 | } 22 | 23 | type GraphQLPayload struct { 24 | Variables interface{} `json:"variables,omitempty"` 25 | QueryID string `json:"queryId,omitempty"` 26 | } 27 | 28 | func (p *GraphQLPayload) Encode() ([]byte, error) { 29 | return json.Marshal(p) 30 | } 31 | 32 | type SendTypingNotificationVariables struct { 33 | ConversationID string `json:"conversationId,omitempty"` 34 | } 35 | 36 | type DMMessageDeleteMutationVariables struct { 37 | MessageID string `json:"messageId,omitempty"` 38 | RequestID string `json:"requestId,omitempty"` 39 | } 40 | 41 | type LabelType string 42 | 43 | const ( 44 | LABEL_TYPE_PINNED LabelType = "Pinned" 45 | ) 46 | 47 | type PinAndUnpinConversationVariables struct { 48 | ConversationID string `json:"conversation_id,omitempty"` 49 | LabelType LabelType `json:"label_type,omitempty"` 50 | Label LabelType `json:"label,omitempty"` 51 | } 52 | 53 | type ReactionActionPayload struct { 54 | ConversationID string `json:"conversationId"` 55 | MessageID string `json:"messageId"` 56 | ReactionTypes []string `json:"reactionTypes"` 57 | EmojiReactions []string `json:"emojiReactions"` 58 | } 59 | 60 | func (p *ReactionActionPayload) Encode() ([]byte, error) { 61 | return json.Marshal(p) 62 | } 63 | 64 | type PushNotificationSettings struct { 65 | Addressbook string `json:"AddressbookSetting,omitempty"` 66 | Ads string `json:"AdsSetting,omitempty"` 67 | DirectMessages string `json:"DirectMessagesSetting,omitempty"` 68 | DMReaction string `json:"DmReactionSetting,omitempty"` 69 | FollowersNonVit string `json:"FollowersNonVitSetting,omitempty"` 70 | FollowersVit string `json:"FollowersVitSetting,omitempty"` 71 | LifelineAlerts string `json:"LifelineAlertsSetting,omitempty"` 72 | LikesNonVit string `json:"LikesNonVitSetting,omitempty"` 73 | LikesVit string `json:"LikesVitSetting,omitempty"` 74 | LiveVideo string `json:"LiveVideoSetting,omitempty"` 75 | Mentions string `json:"MentionsSetting,omitempty"` 76 | Moments string `json:"MomentsSetting,omitempty"` 77 | News string `json:"NewsSetting,omitempty"` 78 | PhotoTags string `json:"PhotoTagsSetting,omitempty"` 79 | Recommendations string `json:"RecommendationsSetting,omitempty"` 80 | Retweets string `json:"RetweetsSetting,omitempty"` 81 | Spaces string `json:"SpacesSetting,omitempty"` 82 | Topics string `json:"TopicsSetting,omitempty"` 83 | Tweets string `json:"TweetsSetting,omitempty"` 84 | } 85 | 86 | type WebPushConfigPayload struct { 87 | Token string `json:"token"` 88 | P256DH string `json:"encryption_key1"` 89 | Auth string `json:"encryption_key2"` 90 | 91 | OSVersion string `json:"os_version"` 92 | UDID string `json:"udid"` 93 | Locale string `json:"locale"` 94 | 95 | Env int `json:"env"` 96 | ProtocolVersion int `json:"protocol_version"` 97 | 98 | Checksum string `json:"checksum,omitempty"` 99 | Settings *PushNotificationSettings `json:"settings,omitempty"` 100 | } 101 | 102 | type PushConfigPayloadWrapper struct { 103 | PushDeviceInfo *WebPushConfigPayload `json:"push_device_info"` 104 | } 105 | -------------------------------------------------------------------------------- /pkg/twittermeow/data/response/account.go: -------------------------------------------------------------------------------- 1 | package response 2 | 3 | import "go.mau.fi/mautrix-twitter/pkg/twittermeow/data/types" 4 | 5 | type AccountSettingsResponse struct { 6 | Protected bool `json:"protected,omitempty"` 7 | ScreenName string `json:"screen_name,omitempty"` 8 | AlwaysUseHTTPS bool `json:"always_use_https,omitempty"` 9 | UseCookiePersonalization bool `json:"use_cookie_personalization,omitempty"` 10 | SleepTime SleepTime `json:"sleep_time,omitempty"` 11 | GeoEnabled bool `json:"geo_enabled,omitempty"` 12 | Language string `json:"language,omitempty"` 13 | DiscoverableByEmail bool `json:"discoverable_by_email,omitempty"` 14 | DiscoverableByMobilePhone bool `json:"discoverable_by_mobile_phone,omitempty"` 15 | DisplaySensitiveMedia bool `json:"display_sensitive_media,omitempty"` 16 | PersonalizedTrends bool `json:"personalized_trends,omitempty"` 17 | AllowMediaTagging string `json:"allow_media_tagging,omitempty"` 18 | AllowContributorRequest string `json:"allow_contributor_request,omitempty"` 19 | AllowAdsPersonalization bool `json:"allow_ads_personalization,omitempty"` 20 | AllowLoggedOutDevicePersonalization bool `json:"allow_logged_out_device_personalization,omitempty"` 21 | AllowLocationHistoryPersonalization bool `json:"allow_location_history_personalization,omitempty"` 22 | AllowSharingDataForThirdPartyPersonalization bool `json:"allow_sharing_data_for_third_party_personalization,omitempty"` 23 | AllowDmsFrom string `json:"allow_dms_from,omitempty"` 24 | AlwaysAllowDmsFromSubscribers any `json:"always_allow_dms_from_subscribers,omitempty"` 25 | AllowDmGroupsFrom string `json:"allow_dm_groups_from,omitempty"` 26 | TranslatorType string `json:"translator_type,omitempty"` 27 | CountryCode string `json:"country_code,omitempty"` 28 | NSFWUser bool `json:"nsfw_user,omitempty"` 29 | NSFWAdmin bool `json:"nsfw_admin,omitempty"` 30 | RankedTimelineSetting any `json:"ranked_timeline_setting,omitempty"` 31 | RankedTimelineEligible any `json:"ranked_timeline_eligible,omitempty"` 32 | AddressBookLiveSyncEnabled bool `json:"address_book_live_sync_enabled,omitempty"` 33 | UniversalQualityFilteringEnabled string `json:"universal_quality_filtering_enabled,omitempty"` 34 | DMReceiptSetting string `json:"dm_receipt_setting,omitempty"` 35 | AltTextComposeEnabled any `json:"alt_text_compose_enabled,omitempty"` 36 | MentionFilter string `json:"mention_filter,omitempty"` 37 | AllowAuthenticatedPeriscopeRequests bool `json:"allow_authenticated_periscope_requests,omitempty"` 38 | ProtectPasswordReset bool `json:"protect_password_reset,omitempty"` 39 | RequirePasswordLogin bool `json:"require_password_login,omitempty"` 40 | RequiresLoginVerification bool `json:"requires_login_verification,omitempty"` 41 | ExtSharingAudiospacesListeningDataWithFollowers bool `json:"ext_sharing_audiospaces_listening_data_with_followers,omitempty"` 42 | Ext Ext `json:"ext,omitempty"` 43 | DmQualityFilter string `json:"dm_quality_filter,omitempty"` 44 | AutoplayDisabled bool `json:"autoplay_disabled,omitempty"` 45 | SettingsMetadata SettingsMetadata `json:"settings_metadata,omitempty"` 46 | } 47 | type SleepTime struct { 48 | Enabled bool `json:"enabled,omitempty"` 49 | EndTime any `json:"end_time,omitempty"` 50 | StartTime any `json:"start_time,omitempty"` 51 | } 52 | type Ok struct { 53 | SSOIDHash string `json:"ssoIdHash,omitempty"` 54 | SSOProvider string `json:"ssoProvider,omitempty"` 55 | } 56 | type R struct { 57 | Ok []Ok `json:"ok,omitempty"` 58 | } 59 | type SsoConnections struct { 60 | R R `json:"r,omitempty"` 61 | TTL int `json:"ttl,omitempty"` 62 | } 63 | type Ext struct { 64 | SsoConnections SsoConnections `json:"ssoConnections,omitempty"` 65 | } 66 | type SettingsMetadata struct { 67 | IsEU string `json:"is_eu,omitempty"` 68 | } 69 | 70 | type GetDMPermissionsResponse struct { 71 | Permissions Permissions `json:"permissions,omitempty"` 72 | Users map[string]types.User `json:"users,omitempty"` 73 | } 74 | 75 | type PermissionDetails struct { 76 | CanDM bool `json:"can_dm,omitempty"` 77 | ErrorCode int `json:"error_code,omitempty"` 78 | } 79 | 80 | type Permissions struct { 81 | IDKeys map[string]PermissionDetails `json:"id_keys,omitempty"` 82 | } 83 | 84 | func (perms Permissions) GetPermissionsForUser(userID string) *PermissionDetails { 85 | if user, ok := perms.IDKeys[userID]; ok { 86 | return &user 87 | } 88 | 89 | return nil 90 | } 91 | -------------------------------------------------------------------------------- /pkg/twittermeow/data/response/inbox.go: -------------------------------------------------------------------------------- 1 | package response 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/rs/zerolog" 7 | 8 | "go.mau.fi/mautrix-twitter/pkg/twittermeow/data/types" 9 | "go.mau.fi/mautrix-twitter/pkg/twittermeow/methods" 10 | ) 11 | 12 | type GetDMUserUpdatesResponse struct { 13 | InboxInitialState *TwitterInboxData `json:"inbox_initial_state,omitempty"` 14 | UserEvents *TwitterInboxData `json:"user_events,omitempty"` 15 | } 16 | 17 | type TwitterInboxData struct { 18 | Status types.PaginationStatus `json:"status,omitempty"` 19 | MinEntryID string `json:"min_entry_id,omitempty"` 20 | MaxEntryID string `json:"max_entry_id,omitempty"` 21 | LastSeenEventID string `json:"last_seen_event_id,omitempty"` 22 | TrustedLastSeenEventID string `json:"trusted_last_seen_event_id,omitempty"` 23 | UntrustedLastSeenEventID string `json:"untrusted_last_seen_event_id,omitempty"` 24 | Cursor string `json:"cursor,omitempty"` 25 | InboxTimelines InboxTimelines `json:"inbox_timelines,omitempty"` 26 | Entries []types.RawTwitterEvent `json:"entries,omitempty"` 27 | Users map[string]*types.User `json:"users,omitempty"` 28 | Conversations map[string]*types.Conversation `json:"conversations,omitempty"` 29 | KeyRegistryState KeyRegistryState `json:"key_registry_state,omitempty"` 30 | } 31 | 32 | func (data *TwitterInboxData) GetUserByID(userID string) *types.User { 33 | if data == nil { 34 | return nil 35 | } 36 | if user, ok := data.Users[userID]; ok { 37 | return user 38 | } 39 | return nil 40 | } 41 | 42 | func (data *TwitterInboxData) GetConversationByID(conversationID string) *types.Conversation { 43 | if data == nil { 44 | return nil 45 | } 46 | if conv, ok := data.Conversations[conversationID]; ok { 47 | return conv 48 | } 49 | return nil 50 | } 51 | 52 | func (data *TwitterInboxData) SortedConversations() []*types.Conversation { 53 | return methods.SortConversationsByTimestamp(data.Conversations) 54 | } 55 | 56 | func (data *TwitterInboxData) SortedMessages(ctx context.Context) map[string][]*types.Message { 57 | conversations := make(map[string][]*types.Message) 58 | log := zerolog.Ctx(ctx) 59 | for _, entry := range data.Entries { 60 | switch evt := entry.ParseWithErrorLog(log).(type) { 61 | case *types.Message: 62 | conversations[evt.ConversationID] = append(conversations[evt.ConversationID], evt) 63 | } 64 | } 65 | for _, conv := range conversations { 66 | methods.SortMessagesByTime(conv) 67 | } 68 | return conversations 69 | } 70 | -------------------------------------------------------------------------------- /pkg/twittermeow/data/response/media.go: -------------------------------------------------------------------------------- 1 | package response 2 | 3 | type InitUploadMediaResponse struct { 4 | MediaID int64 `json:"media_id,omitempty"` 5 | MediaIDString string `json:"media_id_string,omitempty"` 6 | ExpiresAfterSecs int `json:"expires_after_secs,omitempty"` 7 | MediaKey string `json:"media_key,omitempty"` 8 | } 9 | 10 | type FinalizedUploadMediaResponse struct { 11 | MediaID int64 `json:"media_id,omitempty"` 12 | MediaIDString string `json:"media_id_string,omitempty"` 13 | MediaKey string `json:"media_key,omitempty"` 14 | Size int `json:"size,omitempty"` 15 | ExpiresAfterSecs int `json:"expires_after_secs,omitempty"` 16 | Image Image `json:"image,omitempty"` 17 | Video Video `json:"video,omitempty"` 18 | ProcessingInfo ProcessingInfo `json:"processing_info,omitempty"` 19 | } 20 | 21 | type Image struct { 22 | ImageType string `json:"image_type,omitempty"` 23 | W int `json:"w,omitempty"` 24 | H int `json:"h,omitempty"` 25 | } 26 | 27 | type Video struct { 28 | VideoType string `json:"video_type,omitempty"` 29 | } 30 | 31 | type ProcessingState string 32 | 33 | const ( 34 | ProcessingStatePending ProcessingState = "pending" 35 | ProcessingStateInProgress ProcessingState = "in_progress" 36 | ProcessingStateSucceeded ProcessingState = "succeeded" 37 | ) 38 | 39 | type ProcessingInfo struct { 40 | State ProcessingState `json:"state,omitempty"` 41 | CheckAfterSecs int `json:"check_after_secs,omitempty"` 42 | ProgressPercent int `json:"progress_percent,omitempty"` 43 | } 44 | -------------------------------------------------------------------------------- /pkg/twittermeow/data/response/messaging.go: -------------------------------------------------------------------------------- 1 | package response 2 | 3 | import ( 4 | "go.mau.fi/mautrix-twitter/pkg/twittermeow/data/types" 5 | ) 6 | 7 | type InboxTimelineResponse struct { 8 | InboxTimeline *TwitterInboxData `json:"inbox_timeline"` 9 | } 10 | 11 | type ConversationDMResponse struct { 12 | ConversationTimeline *TwitterInboxData `json:"conversation_timeline"` 13 | } 14 | 15 | type InboxInitialStateResponse struct { 16 | InboxInitialState *TwitterInboxData `json:"inbox_initial_state,omitempty"` 17 | } 18 | 19 | type Trusted struct { 20 | Status types.PaginationStatus `json:"status,omitempty"` 21 | MinEntryID string `json:"min_entry_id,omitempty"` 22 | } 23 | 24 | type Untrusted struct { 25 | Status types.PaginationStatus `json:"status,omitempty"` 26 | } 27 | 28 | type InboxTimelines struct { 29 | Trusted Trusted `json:"trusted,omitempty"` 30 | Untrusted Untrusted `json:"untrusted,omitempty"` 31 | } 32 | 33 | type KeyRegistryState struct { 34 | Status types.PaginationStatus `json:"status,omitempty"` 35 | } 36 | 37 | type DMMessageDeleteMutationResponse struct { 38 | Data struct { 39 | DmMessageHideDelete string `json:"dm_message_hide_delete,omitempty"` 40 | } `json:"data,omitempty"` 41 | } 42 | 43 | type PinConversationResponse struct { 44 | Data struct { 45 | AddDmConversationLabelV3 struct { 46 | Typename string `json:"__typename,omitempty"` 47 | LabelType string `json:"label_type,omitempty"` 48 | Timestamp int64 `json:"timestamp,omitempty"` 49 | } `json:"add_dm_conversation_label_v3,omitempty"` 50 | } 51 | } 52 | 53 | type UnpinConversationResponse struct { 54 | Data struct { 55 | DmConversationLabelDelete string `json:"dm_conversation_label_delete,omitempty"` 56 | } `json:"data,omitempty"` 57 | } 58 | 59 | type ReactionResponse struct { 60 | Data struct { 61 | DeleteDmReaction struct { 62 | Typename string `json:"__typename,omitempty"` 63 | } `json:"delete_dm_reaction,omitempty"` 64 | CreateDmReaction struct { 65 | Typename string `json:"__typename,omitempty"` 66 | } `json:"create_dm_reaction,omitempty"` 67 | } `json:"data,omitempty"` 68 | } 69 | -------------------------------------------------------------------------------- /pkg/twittermeow/data/response/search.go: -------------------------------------------------------------------------------- 1 | package response 2 | 3 | import "go.mau.fi/mautrix-twitter/pkg/twittermeow/data/types" 4 | 5 | type SearchResponse struct { 6 | NumResults int `json:"num_results,omitempty"` 7 | Users []types.User `json:"users,omitempty"` 8 | Topics []any `json:"topics,omitempty"` 9 | Events []any `json:"events,omitempty"` 10 | Lists []any `json:"lists,omitempty"` 11 | OrderedSections []any `json:"ordered_sections,omitempty"` 12 | Oneclick []any `json:"oneclick,omitempty"` 13 | Hashtags []any `json:"hashtags,omitempty"` 14 | CompletedIn float32 `json:"completed_in,omitempty"` 15 | Query string `json:"query,omitempty"` 16 | } 17 | -------------------------------------------------------------------------------- /pkg/twittermeow/data/response/stream.go: -------------------------------------------------------------------------------- 1 | package response 2 | 3 | type StreamEvent struct { 4 | Topic string `json:"topic,omitempty"` 5 | Payload StreamEventPayload `json:"payload,omitempty"` 6 | } 7 | 8 | type StreamEventPayload struct { 9 | Config *ConfigPayload `json:"config,omitempty"` 10 | DmTyping *DmTypingPayload `json:"dm_typing,omitempty"` 11 | DmUpdate *DmUpdatePayload `json:"dm_update,omitempty"` 12 | } 13 | 14 | type ConfigPayload struct { 15 | SessionID string `json:"session_id"` 16 | SubscriptionTTLMillis int `json:"subscription_ttl_millis,omitempty"` 17 | HeartbeatMillis int `json:"heartbeat_millis,omitempty"` 18 | } 19 | 20 | type DmUpdatePayload struct { 21 | ConversationID string `json:"conversation_id"` 22 | UserID string `json:"user_id"` 23 | } 24 | 25 | type DmTypingPayload = DmUpdatePayload 26 | -------------------------------------------------------------------------------- /pkg/twittermeow/data/types/entities.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import "fmt" 4 | 5 | type Attachment struct { 6 | Video *AttachmentInfo `json:"video,omitempty"` 7 | AnimatedGif *AttachmentInfo `json:"animated_gif,omitempty"` 8 | Photo *AttachmentInfo `json:"photo,omitempty"` 9 | Card *AttachmentCard `json:"card,omitempty"` 10 | Tweet *AttachmentTweet `json:"tweet,omitempty"` 11 | } 12 | type URLs struct { 13 | URL string `json:"url,omitempty"` 14 | ExpandedURL string `json:"expanded_url,omitempty"` 15 | DisplayURL string `json:"display_url,omitempty"` 16 | Indices []int `json:"indices,omitempty"` 17 | } 18 | 19 | type UserMention struct { 20 | ID int64 `json:"id,omitempty"` 21 | IDStr string `json:"id_str,omitempty"` 22 | Name string `json:"name,omitempty"` 23 | ScreenName string `json:"screen_name,omitempty"` 24 | Indices []int `json:"indices,omitempty"` 25 | } 26 | 27 | type Entities struct { 28 | Hashtags []any `json:"hashtags,omitempty"` 29 | Symbols []any `json:"symbols,omitempty"` 30 | UserMentions []UserMention `json:"user_mentions,omitempty"` 31 | URLs []URLs `json:"urls,omitempty"` 32 | Media []AttachmentInfo `json:"media,omitempty"` 33 | } 34 | type OriginalInfo struct { 35 | URL string `json:"url,omitempty"` 36 | Width int `json:"width,omitempty"` 37 | Height int `json:"height,omitempty"` 38 | } 39 | type Thumb struct { 40 | W int `json:"w,omitempty"` 41 | H int `json:"h,omitempty"` 42 | Resize string `json:"resize,omitempty"` 43 | } 44 | type Small struct { 45 | W int `json:"w,omitempty"` 46 | H int `json:"h,omitempty"` 47 | Resize string `json:"resize,omitempty"` 48 | } 49 | type Large struct { 50 | W int `json:"w,omitempty"` 51 | H int `json:"h,omitempty"` 52 | Resize string `json:"resize,omitempty"` 53 | } 54 | type Medium struct { 55 | W int `json:"w,omitempty"` 56 | H int `json:"h,omitempty"` 57 | Resize string `json:"resize,omitempty"` 58 | } 59 | type Sizes struct { 60 | Thumb Thumb `json:"thumb,omitempty"` 61 | Small Small `json:"small,omitempty"` 62 | Large Large `json:"large,omitempty"` 63 | Medium Medium `json:"medium,omitempty"` 64 | } 65 | type Variant struct { 66 | Bitrate int `json:"bitrate,omitempty"` 67 | ContentType string `json:"content_type,omitempty"` 68 | URL string `json:"url,omitempty"` 69 | } 70 | type VideoInfo struct { 71 | AspectRatio []int `json:"aspect_ratio,omitempty"` 72 | DurationMillis int `json:"duration_millis,omitempty"` 73 | Variants []Variant `json:"variants,omitempty"` 74 | } 75 | 76 | func (v *VideoInfo) GetHighestBitrateVariant() (Variant, error) { 77 | if len(v.Variants) == 0 { 78 | return Variant{}, fmt.Errorf("no variants available") 79 | } 80 | 81 | maxVariant := v.Variants[0] 82 | for _, variant := range v.Variants[1:] { 83 | if variant.Bitrate > maxVariant.Bitrate { 84 | maxVariant = variant 85 | } 86 | } 87 | 88 | return maxVariant, nil 89 | } 90 | 91 | type Features struct { 92 | } 93 | type Rgb struct { 94 | Red int `json:"red,omitempty"` 95 | Green int `json:"green,omitempty"` 96 | Blue int `json:"blue,omitempty"` 97 | } 98 | type Palette struct { 99 | Rgb Rgb `json:"rgb,omitempty"` 100 | Percentage float64 `json:"percentage,omitempty"` 101 | } 102 | type ExtMediaColor struct { 103 | Palette []Palette `json:"palette,omitempty"` 104 | } 105 | type MediaStats struct { 106 | R string `json:"r,omitempty"` 107 | TTL int `json:"ttl,omitempty"` 108 | } 109 | type Ok struct { 110 | Palette []Palette `json:"palette,omitempty"` 111 | ViewCount string `json:"view_count,omitempty"` 112 | } 113 | type R struct { 114 | Ok any `json:"ok,omitempty"` 115 | } 116 | type MediaColor struct { 117 | R any `json:"r,omitempty"` 118 | TTL int `json:"ttl,omitempty"` 119 | } 120 | type AltTextR struct { 121 | Ok string `json:"ok,omitempty"` 122 | } 123 | type AltText struct { 124 | // this is weird, it can be both string or AltTextR struct object 125 | R any `json:"r,omitempty"` 126 | TTL int `json:"ttl,omitempty"` 127 | } 128 | 129 | // different for video/image/gif 130 | type Ext struct { 131 | MediaStats any `json:"mediaStats,omitempty"` 132 | MediaColor MediaColor `json:"mediaColor,omitempty"` 133 | AltText AltText `json:"altText,omitempty"` 134 | } 135 | type AttachmentInfo struct { 136 | ID int64 `json:"id,omitempty"` 137 | IDStr string `json:"id_str,omitempty"` 138 | Indices []int `json:"indices,omitempty"` 139 | MediaURL string `json:"media_url,omitempty"` 140 | MediaURLHTTPS string `json:"media_url_https,omitempty"` 141 | URL string `json:"url,omitempty"` 142 | DisplayURL string `json:"display_url,omitempty"` 143 | ExpandedURL string `json:"expanded_url,omitempty"` 144 | Type string `json:"type,omitempty"` 145 | OriginalInfo OriginalInfo `json:"original_info,omitempty"` 146 | Sizes Sizes `json:"sizes,omitempty"` 147 | VideoInfo VideoInfo `json:"video_info,omitempty"` 148 | Features Features `json:"features,omitempty"` 149 | ExtMediaColor ExtMediaColor `json:"ext_media_color,omitempty"` 150 | ExtAltText string `json:"ext_alt_text,omitempty"` 151 | Ext Ext `json:"ext,omitempty"` 152 | AudioOnly bool `json:"audio_only,omitempty"` 153 | } 154 | 155 | type AttachmentCard struct { 156 | BindingValues AttachmentCardBinding `json:"binding_values,omitempty"` 157 | } 158 | 159 | type AttachmentCardBinding struct { 160 | CardURL AttachmentCardBindingValue `json:"card_url,omitempty"` 161 | Description AttachmentCardBindingValue `json:"description,omitempty"` 162 | Domain AttachmentCardBindingValue `json:"domain,omitempty"` 163 | Title AttachmentCardBindingValue `json:"title,omitempty"` 164 | VanityUrl AttachmentCardBindingValue `json:"vanity_url,omitempty"` 165 | } 166 | 167 | type AttachmentCardBindingValue struct { 168 | StringValue string `json:"string_value,omitempty"` 169 | } 170 | 171 | type AttachmentTweet struct { 172 | DisplayURL string `json:"display_url,omitempty"` 173 | ExpandedURL string `json:"expanded_url,omitempty"` 174 | Status AttachmentTweetStatus `json:"status,omitempty"` 175 | } 176 | 177 | type AttachmentTweetStatus struct { 178 | FullText string `json:"full_text,omitempty"` 179 | Entities Entities `json:"entities,omitempty"` 180 | User User `json:"user,omitempty"` 181 | } 182 | -------------------------------------------------------------------------------- /pkg/twittermeow/data/types/event.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "encoding/json" 5 | "maps" 6 | "slices" 7 | 8 | "github.com/rs/zerolog" 9 | ) 10 | 11 | type TwitterEvent interface { 12 | isTwitterEvent() 13 | } 14 | 15 | func (*Message) isTwitterEvent() {} 16 | func (*MessageEdit) isTwitterEvent() {} 17 | func (*MessageDelete) isTwitterEvent() {} 18 | func (*MessageReactionCreate) isTwitterEvent() {} 19 | func (*MessageReactionDelete) isTwitterEvent() {} 20 | func (*ConversationCreate) isTwitterEvent() {} 21 | func (*ConversationDelete) isTwitterEvent() {} 22 | func (*ParticipantsJoin) isTwitterEvent() {} 23 | func (*ConversationMetadataUpdate) isTwitterEvent() {} 24 | func (*ConversationNameUpdate) isTwitterEvent() {} 25 | func (*ConversationRead) isTwitterEvent() {} 26 | func (*TrustConversation) isTwitterEvent() {} 27 | func (*PollingError) isTwitterEvent() {} 28 | 29 | type PollingError struct { 30 | Error error 31 | } 32 | 33 | type RawTwitterEvent []byte 34 | 35 | func (rte *RawTwitterEvent) UnmarshalJSON(data []byte) error { 36 | *rte = data 37 | return nil 38 | } 39 | 40 | type twitterEventContainer struct { 41 | Message *Message `json:"message,omitempty"` 42 | MessageDelete *MessageDelete `json:"message_delete,omitempty"` 43 | MessageEdit *MessageEdit `json:"message_edit,omitempty"` 44 | ReactionCreate *MessageReactionCreate `json:"reaction_create,omitempty"` 45 | ReactionDelete *MessageReactionDelete `json:"reaction_delete,omitempty"` 46 | ConversationCreate *ConversationCreate `json:"conversation_create,omitempty"` 47 | ConversationDelete *ConversationDelete `json:"remove_conversation,omitempty"` 48 | ParticipantsJoin *ParticipantsJoin `json:"participants_join,omitempty"` 49 | ConversationMetadataUpdate *ConversationMetadataUpdate `json:"conversation_metadata_update,omitempty"` 50 | ConversationNameUpdate *ConversationNameUpdate `json:"conversation_name_update,omitempty"` 51 | ConversationRead *ConversationRead `json:"conversation_read,omitempty"` 52 | TrustConversation *TrustConversation `json:"trust_conversation,omitempty"` 53 | // DisableNotifications *types.DisableNotifications `json:"disable_notifications,omitempty"` 54 | } 55 | 56 | func (rte *RawTwitterEvent) Parse() (TwitterEvent, map[string]any, error) { 57 | var tec twitterEventContainer 58 | if err := json.Unmarshal(*rte, &tec); err != nil { 59 | return nil, nil, err 60 | } 61 | switch { 62 | case tec.Message != nil: 63 | return tec.Message, nil, nil 64 | case tec.MessageDelete != nil: 65 | return tec.MessageDelete, nil, nil 66 | case tec.MessageEdit != nil: 67 | return tec.MessageEdit, nil, nil 68 | case tec.ReactionCreate != nil: 69 | return tec.ReactionCreate, nil, nil 70 | case tec.ReactionDelete != nil: 71 | return tec.ReactionDelete, nil, nil 72 | case tec.ConversationCreate != nil: 73 | return tec.ConversationCreate, nil, nil 74 | case tec.ConversationDelete != nil: 75 | return tec.ConversationDelete, nil, nil 76 | case tec.ParticipantsJoin != nil: 77 | return tec.ParticipantsJoin, nil, nil 78 | case tec.ConversationMetadataUpdate != nil: 79 | return tec.ConversationMetadataUpdate, nil, nil 80 | case tec.ConversationNameUpdate != nil: 81 | return tec.ConversationNameUpdate, nil, nil 82 | case tec.ConversationRead != nil: 83 | return tec.ConversationRead, nil, nil 84 | case tec.TrustConversation != nil: 85 | return tec.TrustConversation, nil, nil 86 | default: 87 | var unrecognized map[string]any 88 | if err := json.Unmarshal(*rte, &unrecognized); err != nil { 89 | return nil, nil, err 90 | } 91 | return nil, unrecognized, nil 92 | } 93 | } 94 | 95 | func (rte *RawTwitterEvent) ParseWithErrorLog(log *zerolog.Logger) TwitterEvent { 96 | evt, unrecognized, err := rte.Parse() 97 | if err != nil { 98 | logEvt := log.Err(err) 99 | if log.GetLevel() == zerolog.TraceLevel { 100 | logEvt.RawJSON("entry_data", *rte) 101 | } 102 | logEvt.Msg("Failed to parse entry") 103 | } else if unrecognized != nil { 104 | logEvt := log.Warn().Strs("type_keys", slices.Collect(maps.Keys(unrecognized))) 105 | if log.GetLevel() == zerolog.TraceLevel { 106 | logEvt.Any("entry_data", unrecognized) 107 | } 108 | logEvt.Msg("Unrecognized entry type") 109 | } else { 110 | log.Trace().RawJSON("entry_data", *rte).Msg("Parsed entry") 111 | return evt 112 | } 113 | return nil 114 | } 115 | -------------------------------------------------------------------------------- /pkg/twittermeow/data/types/http.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | type ContentType string 4 | 5 | const ( 6 | ContentTypeNone ContentType = "" 7 | ContentTypeJSON ContentType = "application/json" 8 | ContentTypeForm ContentType = "application/x-www-form-urlencoded" 9 | ) 10 | -------------------------------------------------------------------------------- /pkg/twittermeow/data/types/messaging.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | type MessageData struct { 4 | ID string `json:"id,omitempty"` 5 | Time string `json:"time,omitempty"` 6 | RecipientID string `json:"recipient_id,omitempty"` 7 | SenderID string `json:"sender_id,omitempty"` 8 | Text string `json:"text,omitempty"` 9 | Entities *Entities `json:"entities,omitempty"` 10 | Attachment *Attachment `json:"attachment,omitempty"` 11 | ReplyData ReplyData `json:"reply_data,omitempty"` 12 | EditCount int `json:"edit_count,omitempty"` 13 | } 14 | 15 | type Message struct { 16 | ID string `json:"id,omitempty"` 17 | Time string `json:"time,omitempty"` 18 | AffectsSort bool `json:"affects_sort,omitempty"` 19 | RequestID string `json:"request_id,omitempty"` 20 | ConversationID string `json:"conversation_id,omitempty"` 21 | MessageData MessageData `json:"message_data,omitempty"` 22 | MessageReactions []MessageReaction `json:"message_reactions,omitempty"` 23 | } 24 | 25 | type MessageEdit Message 26 | 27 | type ReplyData struct { 28 | ID string `json:"id,omitempty"` 29 | Time string `json:"time,omitempty"` 30 | RecipientID string `json:"recipient_id,omitempty"` 31 | SenderID string `json:"sender_id,omitempty"` 32 | Text string `json:"text,omitempty"` 33 | } 34 | 35 | type MessageReaction struct { 36 | ID string `json:"id,omitempty"` 37 | Time string `json:"time,omitempty"` 38 | ConversationID string `json:"conversation_id,omitempty"` 39 | MessageID string `json:"message_id,omitempty"` 40 | ReactionKey string `json:"reaction_key,omitempty"` 41 | EmojiReaction string `json:"emoji_reaction,omitempty"` 42 | SenderID string `json:"sender_id,omitempty"` 43 | AffectsSort bool `json:"affects_sort,omitempty"` 44 | } 45 | 46 | type MessageReactionCreate MessageReaction 47 | type MessageReactionDelete MessageReaction 48 | 49 | type ConversationRead struct { 50 | ID string `json:"id,omitempty"` 51 | Time string `json:"time,omitempty"` 52 | AffectsSort bool `json:"affects_sort,omitempty"` 53 | ConversationID string `json:"conversation_id,omitempty"` 54 | LastReadEventID string `json:"last_read_event_id,omitempty"` 55 | } 56 | 57 | type ConversationMetadataUpdate struct { 58 | ID string `json:"id,omitempty"` 59 | Time string `json:"time,omitempty"` 60 | AffectsSort bool `json:"affects_sort,omitempty"` 61 | ConversationID string `json:"conversation_id,omitempty"` 62 | } 63 | 64 | type ConversationCreate struct { 65 | ID string `json:"id,omitempty"` 66 | Time string `json:"time,omitempty"` 67 | AffectsSort bool `json:"affects_sort,omitempty"` 68 | ConversationID string `json:"conversation_id,omitempty"` 69 | RequestID string `json:"request_id,omitempty"` 70 | } 71 | 72 | type ConversationDelete struct { 73 | ID string `json:"id,omitempty"` 74 | Time string `json:"time,omitempty"` 75 | AffectsSort bool `json:"affects_sort,omitempty"` 76 | ConversationID string `json:"conversation_id,omitempty"` 77 | LastEventID string `json:"last_event_id,omitempty"` 78 | } 79 | 80 | type ConversationNameUpdate struct { 81 | ID string `json:"id,omitempty"` 82 | Time string `json:"time,omitempty"` 83 | ConversationID string `json:"conversation_id,omitempty"` 84 | ConversationName string `json:"conversation_name,omitempty"` 85 | ByUserID string `json:"by_user_id,omitempty"` 86 | AffectsSort bool `json:"affects_sort,omitempty"` 87 | } 88 | 89 | type ParticipantsJoin struct { 90 | ID string `json:"id,omitempty"` 91 | Time string `json:"time,omitempty"` 92 | AffectsSort bool `json:"affects_sort,omitempty"` 93 | ConversationID string `json:"conversation_id,omitempty"` 94 | SenderID string `json:"sender_id,omitempty"` 95 | Participants []Participant `json:"participants,omitempty"` 96 | } 97 | 98 | type ConversationType string 99 | 100 | const ( 101 | ConversationTypeOneToOne ConversationType = "ONE_TO_ONE" 102 | ConversationTypeGroupDM ConversationType = "GROUP_DM" 103 | ) 104 | 105 | type PaginationStatus string 106 | 107 | const ( 108 | PaginationStatusAtEnd PaginationStatus = "AT_END" 109 | PaginationStatusHasMore PaginationStatus = "HAS_MORE" 110 | ) 111 | 112 | type Conversation struct { 113 | ConversationID string `json:"conversation_id,omitempty"` 114 | Type ConversationType `json:"type,omitempty"` 115 | Name string `json:"name,omitempty"` 116 | AvatarImageHttps string `json:"avatar_image_https,omitempty"` 117 | Avatar Avatar `json:"avatar,omitempty"` 118 | SortEventID string `json:"sort_event_id,omitempty"` 119 | SortTimestamp string `json:"sort_timestamp,omitempty"` 120 | CreateTime string `json:"create_time,omitempty"` 121 | CreatedByUserID string `json:"created_by_user_id,omitempty"` 122 | Participants []Participant `json:"participants,omitempty"` 123 | NSFW bool `json:"nsfw,omitempty"` 124 | NotificationsDisabled bool `json:"notifications_disabled,omitempty"` 125 | MentionNotificationsDisabled bool `json:"mention_notifications_disabled,omitempty"` 126 | LastReadEventID string `json:"last_read_event_id,omitempty"` 127 | ReadOnly bool `json:"read_only,omitempty"` 128 | Trusted bool `json:"trusted,omitempty"` 129 | Muted bool `json:"muted,omitempty"` 130 | LowQuality bool `json:"low_quality,omitempty"` 131 | Status PaginationStatus `json:"status,omitempty"` 132 | MinEntryID string `json:"min_entry_id,omitempty"` 133 | MaxEntryID string `json:"max_entry_id,omitempty"` 134 | } 135 | 136 | type Image struct { 137 | OriginalInfo OriginalInfo `json:"original_info,omitempty"` 138 | } 139 | 140 | type Avatar struct { 141 | Image Image `json:"image,omitempty"` 142 | } 143 | 144 | type Participant struct { 145 | UserID string `json:"user_id,omitempty"` 146 | LastReadEventID string `json:"last_read_event_id,omitempty"` 147 | IsAdmin bool `json:"is_admin,omitempty"` 148 | JoinTime string `json:"join_time,omitempty"` 149 | JoinConversationEventID string `json:"join_conversation_event_id,omitempty"` 150 | } 151 | 152 | type MessageDelete struct { 153 | ID string `json:"id,omitempty"` 154 | Time string `json:"time,omitempty"` 155 | AffectsSort bool `json:"affects_sort,omitempty"` 156 | RequestID string `json:"request_id,omitempty"` 157 | ConversationID string `json:"conversation_id,omitempty"` 158 | Messages []MessagesDeleted `json:"messages,omitempty"` 159 | } 160 | 161 | type MessagesDeleted struct { 162 | MessageID string `json:"message_id,omitempty"` 163 | MessageCreateEventID string `json:"message_create_event_id,omitempty"` 164 | } 165 | 166 | type TrustConversation struct { 167 | ID string `json:"id,omitempty"` 168 | Time string `json:"time,omitempty"` 169 | AffectsSort bool `json:"affects_sort,omitempty"` 170 | ConversationID string `json:"conversation_id,omitempty"` 171 | Reason string `json:"reason,omitempty"` 172 | } 173 | -------------------------------------------------------------------------------- /pkg/twittermeow/data/types/user.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | type User struct { 4 | ID int64 `json:"id,omitempty"` 5 | IDStr string `json:"id_str,omitempty"` 6 | Name string `json:"name,omitempty"` 7 | ScreenName string `json:"screen_name,omitempty"` 8 | ProfileImageURL string `json:"profile_image_url,omitempty"` 9 | ProfileImageURLHTTPS string `json:"profile_image_url_https,omitempty"` 10 | Following bool `json:"following,omitempty"` 11 | FollowRequestSent bool `json:"follow_request_sent,omitempty"` 12 | Description string `json:"description,omitempty"` 13 | Entities Entities `json:"entities,omitempty"` 14 | Verified bool `json:"verified,omitempty"` 15 | IsBlueVerified bool `json:"is_blue_verified,omitempty"` 16 | ExtIsBlueVerified bool `json:"ext_is_blue_verified,omitempty"` 17 | Protected bool `json:"protected,omitempty"` 18 | IsProtected bool `json:"is_protected,omitempty"` 19 | Blocking bool `json:"blocking,omitempty"` 20 | IsBlocked bool `json:"is_blocked,omitempty"` 21 | IsSecretDMAble bool `json:"is_secret_dm_able,omitempty"` 22 | IsDMAble bool `json:"is_dm_able,omitempty"` 23 | SubscribedBy bool `json:"subscribed_by,omitempty"` 24 | CanMediaTag bool `json:"can_media_tag,omitempty"` 25 | CreatedAt string `json:"created_at,omitempty"` 26 | Location string `json:"location,omitempty"` 27 | FriendsCount int `json:"friends_count,omitempty"` 28 | SocialProof int `json:"social_proof,omitempty"` 29 | RoundedScore int `json:"rounded_score,omitempty"` 30 | FollowersCount int `json:"followers_count,omitempty"` 31 | ConnectingUserCount int `json:"connecting_user_count,omitempty"` 32 | ConnectingUserIDs []any `json:"connecting_user_ids,omitempty"` 33 | SocialProofsOrdered []any `json:"social_proofs_ordered,omitempty"` 34 | Tokens []any `json:"tokens,omitempty"` 35 | Inline bool `json:"inline,omitempty"` 36 | } 37 | 38 | type UserEntities struct { 39 | URL URL `json:"url,omitempty"` 40 | Description Description `json:"description,omitempty"` 41 | } 42 | 43 | type URL struct { 44 | URLs []URLs `json:"urls,omitempty"` 45 | } 46 | 47 | type Description struct { 48 | URLs []URLs `json:"urls,omitempty"` 49 | } 50 | -------------------------------------------------------------------------------- /pkg/twittermeow/errors.go: -------------------------------------------------------------------------------- 1 | package twittermeow 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "strings" 7 | ) 8 | 9 | var ( 10 | ErrConnectSetEventHandler = errors.New("event handler must be set before connecting") 11 | ErrNotAuthenticatedYet = errors.New("client has not been authenticated yet") 12 | 13 | ErrAlreadyPollingUpdates = errors.New("client is already polling for user updates") 14 | ErrNotPollingUpdates = errors.New("client is not polling for user updates") 15 | ) 16 | 17 | type TwitterError struct { 18 | Message string `json:"message"` 19 | Code int `json:"code"` 20 | } 21 | 22 | var ( 23 | ErrCouldNotAuthenticate error = TwitterError{Code: 32} 24 | ErrUserSuspended error = TwitterError{Code: 63} 25 | ErrAccountSuspended error = TwitterError{Code: 63} 26 | ErrNotActive error = TwitterError{Code: 141} 27 | ErrAccountTemporarilyLocked error = TwitterError{Code: 326} 28 | ) 29 | 30 | func IsAuthError(err error) bool { 31 | return errors.Is(err, ErrCouldNotAuthenticate) || 32 | errors.Is(err, ErrUserSuspended) || 33 | errors.Is(err, ErrAccountSuspended) || 34 | errors.Is(err, ErrNotActive) || 35 | errors.Is(err, ErrAccountTemporarilyLocked) 36 | } 37 | 38 | func (te TwitterError) Is(other error) bool { 39 | var ote TwitterError 40 | if errors.As(other, &ote) { 41 | return te.Code == ote.Code || te.Message == ote.Message 42 | } 43 | return false 44 | } 45 | 46 | func (te TwitterError) Error() string { 47 | return fmt.Sprintf("%d: %s", te.Code, te.Message) 48 | } 49 | 50 | type TwitterErrors struct { 51 | Errors []TwitterError `json:"errors"` 52 | } 53 | 54 | func (te *TwitterErrors) Error() string { 55 | if te == nil || len(te.Errors) == 0 { 56 | return "no errors" 57 | } else if len(te.Errors) == 1 { 58 | return te.Errors[0].Error() 59 | } else { 60 | errs := make([]string, len(te.Errors)) 61 | for i, e := range te.Errors { 62 | errs[i] = e.Error() 63 | } 64 | return strings.Join(errs, ", ") 65 | } 66 | } 67 | 68 | func (te *TwitterErrors) Unwrap() []error { 69 | errs := make([]error, len(te.Errors)) 70 | for i, e := range te.Errors { 71 | errs[i] = e 72 | } 73 | return errs 74 | } 75 | -------------------------------------------------------------------------------- /pkg/twittermeow/headers.go: -------------------------------------------------------------------------------- 1 | package twittermeow 2 | 3 | import ( 4 | "net/http" 5 | 6 | "go.mau.fi/mautrix-twitter/pkg/twittermeow/cookies" 7 | "go.mau.fi/mautrix-twitter/pkg/twittermeow/data/endpoints" 8 | ) 9 | 10 | const BrowserName = "Chrome" 11 | const ChromeVersion = "131" 12 | const ChromeVersionFull = ChromeVersion + ".0.6778.85" 13 | const UserAgent = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/" + ChromeVersion + ".0.0.0 Safari/537.36" 14 | const SecCHUserAgent = `"Chromium";v="` + ChromeVersion + `", "Google Chrome";v="` + ChromeVersion + `", "Not-A.Brand";v="99"` 15 | const SecCHFullVersionList = `"Chromium";v="` + ChromeVersionFull + `", "Google Chrome";v="` + ChromeVersionFull + `", "Not-A.Brand";v="99.0.0.0"` 16 | const OSName = "Linux" 17 | const OSVersion = "6.8.0" 18 | const SecCHPlatform = `"` + OSName + `"` 19 | const SecCHPlatformVersion = `"` + OSVersion + `"` 20 | const SecCHMobile = "?0" 21 | const SecCHModel = "" 22 | const SecCHPrefersColorScheme = "light" 23 | 24 | const UDID = OSName + "/" + BrowserName 25 | 26 | var BaseHeaders = http.Header{ 27 | "Accept": []string{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7"}, 28 | "Accept-Language": []string{"en-US,en;q=0.9"}, 29 | "User-Agent": []string{UserAgent}, 30 | "Sec-Ch-Ua": []string{SecCHUserAgent}, 31 | "Sec-Ch-Ua-Platform": []string{SecCHPlatform}, 32 | "Sec-Ch-Ua-Mobile": []string{SecCHMobile}, 33 | "Referer": []string{endpoints.BASE_URL + "/"}, 34 | "Origin": []string{endpoints.BASE_URL}, 35 | //"Sec-Ch-Prefers-Color-Scheme": []string{SecCHPrefersColorScheme}, 36 | //"Sec-Ch-Ua-Full-Version-List": []string{SecCHFullVersionList}, 37 | //"Sec-Ch-Ua-Model": []string{SecCHModel}, 38 | //"Sec-Ch-Ua-Platform-Version": []string{SecCHPlatformVersion}, 39 | } 40 | 41 | type HeaderOpts struct { 42 | WithAuthBearer bool 43 | WithNonAuthBearer bool 44 | WithCookies bool 45 | WithXGuestToken bool 46 | WithXTwitterHeaders bool 47 | WithXCsrfToken bool 48 | WithXClientUUID bool 49 | Referer string 50 | Origin string 51 | Extra map[string]string 52 | } 53 | 54 | func (c *Client) buildHeaders(opts HeaderOpts) http.Header { 55 | if opts.Extra == nil { 56 | opts.Extra = make(map[string]string) 57 | } 58 | 59 | headers := BaseHeaders.Clone() 60 | if opts.WithCookies { 61 | opts.Extra["cookie"] = c.cookies.String() 62 | } 63 | 64 | if opts.WithAuthBearer || opts.WithNonAuthBearer { 65 | authTokens := c.session.GetAuthTokens() 66 | // check if client is authenticated here 67 | // var bearerToken string 68 | // if c.isAuthenticated() && !opts.WithNonAuthBearer { 69 | // bearerToken = authTokens.authenticated 70 | // } else { 71 | // bearerToken = authTokens.notAuthenticated 72 | // } 73 | opts.Extra["authorization"] = authTokens.notAuthenticated 74 | } 75 | 76 | if opts.WithXGuestToken { 77 | opts.Extra["x-guest-token"] = c.cookies.Get(cookies.XGuestToken) 78 | } 79 | 80 | if opts.WithXClientUUID { 81 | opts.Extra["x-client-uuid"] = c.session.clientUUID 82 | } 83 | 84 | if opts.WithXTwitterHeaders { 85 | opts.Extra["x-twitter-active-user"] = "yes" 86 | opts.Extra["x-twitter-client-language"] = "en" 87 | } 88 | 89 | if opts.WithXCsrfToken { 90 | opts.Extra["x-csrf-token"] = c.cookies.Get(cookies.XCt0) 91 | opts.Extra["x-twitter-auth-type"] = "OAuth2Session" 92 | } 93 | 94 | if opts.Origin != "" { 95 | opts.Extra["origin"] = opts.Origin 96 | } 97 | 98 | if opts.Referer != "" { 99 | opts.Extra["referer"] = opts.Referer 100 | } 101 | 102 | for k, v := range opts.Extra { 103 | headers.Set(k, v) 104 | } 105 | 106 | return headers 107 | } 108 | -------------------------------------------------------------------------------- /pkg/twittermeow/http.go: -------------------------------------------------------------------------------- 1 | package twittermeow 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "errors" 8 | "fmt" 9 | "io" 10 | "net/http" 11 | "strings" 12 | "time" 13 | 14 | "github.com/rs/zerolog" 15 | 16 | "go.mau.fi/mautrix-twitter/pkg/twittermeow/data/endpoints" 17 | "go.mau.fi/mautrix-twitter/pkg/twittermeow/data/types" 18 | ) 19 | 20 | var MaxHTTPRetries = 5 21 | 22 | var ( 23 | ErrRedirectAttempted = errors.New("redirect attempted") 24 | ErrRequestCreateFailed = errors.New("failed to create request") 25 | ErrRequestFailed = errors.New("failed to send request") 26 | ErrResponseReadFailed = errors.New("failed to read response body") 27 | ErrMaxRetriesReached = errors.New("maximum retries reached") 28 | ) 29 | 30 | func (c *Client) MakeRequest(ctx context.Context, url string, method string, headers http.Header, payload []byte, contentType types.ContentType) (*http.Response, []byte, error) { 31 | log := zerolog.Ctx(ctx).With(). 32 | Str("url", url). 33 | Str("method", method). 34 | Str("function", "MakeRequest"). 35 | Logger() 36 | var attempts int 37 | for { 38 | attempts++ 39 | start := time.Now() 40 | resp, respDat, err := c.makeRequestDirect(ctx, url, method, headers, payload, contentType) 41 | dur := time.Since(start) 42 | if err == nil { 43 | var logEvt *zerolog.Event 44 | if strings.HasPrefix(url, endpoints.DM_USER_UPDATES_URL) { 45 | logEvt = log.Trace() 46 | } else { 47 | logEvt = log.Debug() 48 | } 49 | logEvt. 50 | Dur("duration", dur). 51 | Msg("Request successful") 52 | return resp, respDat, nil 53 | } else if resp != nil && resp.StatusCode >= 400 && resp.StatusCode < 500 { 54 | log.Err(err). 55 | Dur("duration", dur). 56 | Msg("Request failed") 57 | return nil, nil, err 58 | } else if attempts > MaxHTTPRetries { 59 | log.Err(err). 60 | Dur("duration", dur). 61 | Msg("Request failed, giving up") 62 | return nil, nil, fmt.Errorf("%w: %w", ErrMaxRetriesReached, err) 63 | } else if errors.Is(err, ErrRedirectAttempted) { 64 | location := resp.Header.Get("Location") 65 | c.Logger.Err(err). 66 | Str("location", location). 67 | Dur("duration", dur). 68 | Msg("Redirect attempted") 69 | return resp, nil, err 70 | } else if ctx.Err() != nil { 71 | return resp, nil, ctx.Err() 72 | } 73 | log.Err(err). 74 | Dur("duration", dur). 75 | Msg("Request failed, retrying") 76 | time.Sleep(time.Duration(attempts) * 3 * time.Second) 77 | } 78 | } 79 | 80 | func (c *Client) makeRequestDirect(ctx context.Context, url string, method string, headers http.Header, payload []byte, contentType types.ContentType) (*http.Response, []byte, error) { 81 | newRequest, err := http.NewRequestWithContext(ctx, method, url, bytes.NewReader(payload)) 82 | if err != nil { 83 | return nil, nil, fmt.Errorf("%w: %w", ErrRequestCreateFailed, err) 84 | } 85 | 86 | if contentType != types.ContentTypeNone { 87 | headers.Set("content-type", string(contentType)) 88 | } 89 | 90 | newRequest.Header = headers 91 | 92 | response, err := c.HTTP.Do(newRequest) 93 | defer func() { 94 | if response != nil && response.Body != nil { 95 | _ = response.Body.Close() 96 | } 97 | }() 98 | if err != nil { 99 | if errors.Is(err, ErrRedirectAttempted) { 100 | return response, nil, err 101 | } 102 | return nil, nil, fmt.Errorf("%w: %w", ErrRequestFailed, err) 103 | } 104 | 105 | responseBody, err := io.ReadAll(response.Body) 106 | if err != nil { 107 | return nil, nil, fmt.Errorf("%w: %w", ErrResponseReadFailed, err) 108 | } 109 | if response.StatusCode >= 400 { 110 | var respErr TwitterErrors 111 | if json.Unmarshal(responseBody, &respErr) == nil { 112 | return response, responseBody, fmt.Errorf("HTTP %d: %w", response.StatusCode, &respErr) 113 | } else if len(responseBody) == 0 { 114 | return response, responseBody, fmt.Errorf("HTTP %d (no response body)", response.StatusCode) 115 | } else if len(responseBody) < 512 { 116 | return response, responseBody, fmt.Errorf("HTTP %d: %s", response.StatusCode, responseBody) 117 | } 118 | return response, responseBody, fmt.Errorf("HTTP %d (%d bytes of data)", response.StatusCode, len(responseBody)) 119 | } 120 | 121 | return response, responseBody, nil 122 | } 123 | -------------------------------------------------------------------------------- /pkg/twittermeow/jot_client.go: -------------------------------------------------------------------------------- 1 | package twittermeow 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | 9 | "go.mau.fi/mautrix-twitter/pkg/twittermeow/crypto" 10 | "go.mau.fi/mautrix-twitter/pkg/twittermeow/data/endpoints" 11 | "go.mau.fi/mautrix-twitter/pkg/twittermeow/data/payload" 12 | "go.mau.fi/mautrix-twitter/pkg/twittermeow/data/types" 13 | ) 14 | 15 | type JotClient struct { 16 | client *Client 17 | } 18 | 19 | func (c *Client) newJotClient() *JotClient { 20 | return &JotClient{ 21 | client: c, 22 | } 23 | } 24 | 25 | func (jc *JotClient) sendClientLoggingEvent(ctx context.Context, category payload.JotLoggingCategory, debug bool, body []interface{}) error { 26 | if true { 27 | return nil 28 | } 29 | logPayloadBytes, err := json.Marshal(body) 30 | if err != nil { 31 | return err 32 | } 33 | 34 | clientLogPayload := &payload.JotClientEventPayload{ 35 | Category: category, 36 | Debug: debug, 37 | Log: string(logPayloadBytes), 38 | } 39 | 40 | clientLogPayloadBytes, err := clientLogPayload.Encode() 41 | if err != nil { 42 | return err 43 | } 44 | 45 | clientTransactionID, err := crypto.SignTransaction(jc.client.session.animationToken, jc.client.session.verificationToken, endpoints.JOT_CLIENT_EVENT_URL, http.MethodPost) 46 | if err != nil { 47 | return err 48 | } 49 | 50 | extraHeaders := map[string]string{ 51 | "accept": "*/*", 52 | "sec-fetch-site": "same-site", 53 | "sec-fetch-mode": "cors", 54 | "sec-fetch-dest": "empty", 55 | "x-client-transaction-id": clientTransactionID, 56 | } 57 | 58 | headerOpts := HeaderOpts{ 59 | WithAuthBearer: true, 60 | WithCookies: true, 61 | WithXGuestToken: true, 62 | WithXTwitterHeaders: true, 63 | Origin: endpoints.BASE_URL, 64 | Referer: endpoints.BASE_URL + "/", 65 | Extra: extraHeaders, 66 | } 67 | 68 | clientLogResponse, _, err := jc.client.MakeRequest(ctx, endpoints.JOT_CLIENT_EVENT_URL, http.MethodPost, jc.client.buildHeaders(headerOpts), clientLogPayloadBytes, types.ContentTypeForm) 69 | if err != nil { 70 | return err 71 | } 72 | 73 | if clientLogResponse.StatusCode > 204 { 74 | return fmt.Errorf("failed to send jot client event, status code: %d", clientLogResponse.StatusCode) 75 | } 76 | 77 | return nil 78 | } 79 | -------------------------------------------------------------------------------- /pkg/twittermeow/media.go: -------------------------------------------------------------------------------- 1 | package twittermeow 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "crypto/md5" 7 | "encoding/hex" 8 | "encoding/json" 9 | "errors" 10 | "fmt" 11 | "mime/multipart" 12 | "net/http" 13 | "net/textproto" 14 | "slices" 15 | "time" 16 | 17 | "go.mau.fi/util/ffmpeg" 18 | "go.mau.fi/util/random" 19 | 20 | "go.mau.fi/mautrix-twitter/pkg/twittermeow/data/endpoints" 21 | "go.mau.fi/mautrix-twitter/pkg/twittermeow/data/payload" 22 | "go.mau.fi/mautrix-twitter/pkg/twittermeow/data/response" 23 | "go.mau.fi/mautrix-twitter/pkg/twittermeow/data/types" 24 | ) 25 | 26 | func (c *Client) UploadMedia(ctx context.Context, params *payload.UploadMediaQuery, mediaBytes []byte) (*response.FinalizedUploadMediaResponse, error) { 27 | params.Command = "INIT" 28 | if mediaBytes != nil { 29 | params.TotalBytes = len(mediaBytes) 30 | } 31 | 32 | encodedQuery, err := params.Encode() 33 | if err != nil { 34 | return nil, err 35 | } 36 | 37 | var finalizedMediaResultBytes []byte 38 | 39 | url := fmt.Sprintf("%s?%s", endpoints.UPLOAD_MEDIA_URL, string(encodedQuery)) 40 | headerOpts := HeaderOpts{ 41 | WithNonAuthBearer: true, 42 | WithXCsrfToken: true, 43 | WithCookies: true, 44 | Origin: endpoints.BASE_URL, 45 | Referer: endpoints.BASE_URL + "/", 46 | Extra: map[string]string{ 47 | "sec-fetch-dest": "empty", 48 | "sec-fetch-mode": "cors", 49 | "sec-fetch-site": "same-origin", 50 | "accept": "*/*", 51 | }, 52 | } 53 | headers := c.buildHeaders(headerOpts) 54 | 55 | _, respBody, err := c.MakeRequest(ctx, url, http.MethodPost, headers, nil, types.ContentTypeNone) 56 | if err != nil { 57 | return nil, err 58 | } 59 | 60 | initUploadResponse := &response.InitUploadMediaResponse{} 61 | err = json.Unmarshal(respBody, initUploadResponse) 62 | if err != nil { 63 | return nil, err 64 | } 65 | 66 | segmentIndex := 0 67 | if mediaBytes != nil { 68 | for chunk := range slices.Chunk(mediaBytes, 6*1024*1024) { 69 | appendMediaPayload, contentType, err := c.newMediaAppendPayload(chunk) 70 | if err != nil { 71 | return nil, err 72 | } 73 | headers.Add("content-type", contentType) 74 | 75 | url = fmt.Sprintf("%s?command=APPEND&media_id=%s&segment_index=%d", endpoints.UPLOAD_MEDIA_URL, initUploadResponse.MediaIDString, segmentIndex) 76 | resp, respBody, err := c.MakeRequest(ctx, url, http.MethodPost, headers, appendMediaPayload, types.ContentTypeNone) 77 | if err != nil { 78 | return nil, err 79 | } 80 | 81 | if resp.StatusCode > 204 { 82 | return nil, fmt.Errorf("failed to append media bytes for media with id %s (status_code=%d, response_body=%s)", initUploadResponse.MediaIDString, resp.StatusCode, string(respBody)) 83 | } 84 | segmentIndex++ 85 | } 86 | 87 | var originalMd5 string 88 | if params.MediaCategory == payload.MEDIA_CATEGORY_DM_IMAGE { 89 | md5Hash := md5.Sum(mediaBytes) 90 | originalMd5 = hex.EncodeToString(md5Hash[:]) 91 | } 92 | 93 | finalizeMediaQuery := &payload.UploadMediaQuery{ 94 | Command: "FINALIZE", 95 | MediaID: initUploadResponse.MediaIDString, 96 | OriginalMD5: originalMd5, 97 | } 98 | 99 | encodedQuery, err = finalizeMediaQuery.Encode() 100 | if err != nil { 101 | return nil, err 102 | } 103 | 104 | url = fmt.Sprintf("%s?%s", endpoints.UPLOAD_MEDIA_URL, string(encodedQuery)) 105 | headers.Del("content-type") 106 | resp, respBody, err := c.MakeRequest(ctx, url, http.MethodPost, headers, nil, types.ContentTypeNone) 107 | if err != nil { 108 | return nil, err 109 | } 110 | 111 | if resp.StatusCode > 204 { 112 | return nil, fmt.Errorf("failed to finalize media with id %s (status_code=%d, response_body=%s)", initUploadResponse.MediaIDString, resp.StatusCode, string(respBody)) 113 | } 114 | 115 | finalizedMediaResultBytes = respBody 116 | } else { 117 | _, finalizedMediaResultBytes, err = c.GetMediaUploadStatus(ctx, initUploadResponse.MediaIDString, headers) 118 | if err != nil { 119 | return nil, err 120 | } 121 | } 122 | 123 | finalizedMediaResult := &response.FinalizedUploadMediaResponse{} 124 | err = json.Unmarshal(finalizedMediaResultBytes, finalizedMediaResult) 125 | if err != nil { 126 | return nil, err 127 | } 128 | 129 | if finalizedMediaResult.ProcessingInfo.State == response.ProcessingStatePending || finalizedMediaResult.ProcessingInfo.State == response.ProcessingStateInProgress { 130 | // might need to check for error processing states here, I have not encountered any though so I wouldn't know what they look like/are 131 | for finalizedMediaResult.ProcessingInfo.State != response.ProcessingStateSucceeded { 132 | finalizedMediaResult, _, err = c.GetMediaUploadStatus(ctx, finalizedMediaResult.MediaIDString, headers) 133 | if err != nil { 134 | return nil, err 135 | } 136 | c.Logger.Debug(). 137 | Int("progress_percent", finalizedMediaResult.ProcessingInfo.ProgressPercent). 138 | Int("status_check_interval_seconds", finalizedMediaResult.ProcessingInfo.CheckAfterSecs). 139 | Str("media_id", finalizedMediaResult.MediaIDString). 140 | Str("state", string(finalizedMediaResult.ProcessingInfo.State)). 141 | Msg("Waiting for X to finish processing our media upload...") 142 | time.Sleep(time.Second * time.Duration(finalizedMediaResult.ProcessingInfo.CheckAfterSecs)) 143 | } 144 | } 145 | 146 | return finalizedMediaResult, nil 147 | } 148 | 149 | func (c *Client) GetMediaUploadStatus(ctx context.Context, mediaID string, h http.Header) (*response.FinalizedUploadMediaResponse, []byte, error) { 150 | url := fmt.Sprintf("%s?command=STATUS&media_id=%s", endpoints.UPLOAD_MEDIA_URL, mediaID) 151 | resp, respBody, err := c.MakeRequest(ctx, url, http.MethodGet, h, nil, types.ContentTypeNone) 152 | if err != nil { 153 | return nil, nil, err 154 | } 155 | 156 | if resp.StatusCode > 204 { 157 | return nil, nil, fmt.Errorf("failed to get status of uploaded media with id %s (status_code=%d, response_body=%s)", mediaID, resp.StatusCode, string(respBody)) 158 | } 159 | 160 | mediaStatusResult := &response.FinalizedUploadMediaResponse{} 161 | return mediaStatusResult, respBody, json.Unmarshal(respBody, mediaStatusResult) 162 | } 163 | 164 | func (c *Client) newMediaAppendPayload(mediaBytes []byte) ([]byte, string, error) { 165 | var appendMediaPayload bytes.Buffer 166 | writer := multipart.NewWriter(&appendMediaPayload) 167 | 168 | err := writer.SetBoundary("----WebKitFormBoundary" + random.String(16)) 169 | if err != nil { 170 | return nil, "", fmt.Errorf("failed to set boundary (%s)", err.Error()) 171 | } 172 | 173 | partHeader := textproto.MIMEHeader{ 174 | "Content-Disposition": []string{`form-data; name="media"; filename="blob"`}, 175 | "Content-Type": []string{"application/octet-stream"}, 176 | } 177 | 178 | mediaPart, err := writer.CreatePart(partHeader) 179 | if err != nil { 180 | return nil, "", fmt.Errorf("failed to create multipart writer (%s)", err.Error()) 181 | } 182 | 183 | _, err = mediaPart.Write(mediaBytes) 184 | if err != nil { 185 | return nil, "", fmt.Errorf("failed to write data to multipart section (%s)", err.Error()) 186 | } 187 | 188 | err = writer.Close() 189 | if err != nil { 190 | return nil, "", fmt.Errorf("failed to close multipart writer (%s)", err.Error()) 191 | } 192 | 193 | return appendMediaPayload.Bytes(), writer.FormDataContentType(), nil 194 | } 195 | 196 | func (c *Client) ConvertAudioPayload(ctx context.Context, mediaBytes []byte, mimeType string) ([]byte, error) { 197 | if !ffmpeg.Supported() { 198 | return nil, errors.New("ffmpeg is required to send voice message") 199 | } 200 | 201 | // A video part is required to send voice message. 202 | return ffmpeg.ConvertBytes(ctx, mediaBytes, ".mp4", []string{"-f", "lavfi", "-i", "color=black:s=854x480:r=30"}, []string{"-c:v", "h264", "-c:a", "aac", "-tune", "stillimage", "-shortest"}, mimeType) 203 | } 204 | -------------------------------------------------------------------------------- /pkg/twittermeow/messaging.go: -------------------------------------------------------------------------------- 1 | package twittermeow 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | 9 | "go.mau.fi/mautrix-twitter/pkg/twittermeow/data/endpoints" 10 | "go.mau.fi/mautrix-twitter/pkg/twittermeow/data/payload" 11 | "go.mau.fi/mautrix-twitter/pkg/twittermeow/data/response" 12 | "go.mau.fi/mautrix-twitter/pkg/twittermeow/data/types" 13 | 14 | "github.com/google/uuid" 15 | ) 16 | 17 | func (c *Client) GetInitialInboxState(ctx context.Context, params *payload.DMRequestQuery) (*response.InboxInitialStateResponse, error) { 18 | encodedQuery, err := params.Encode() 19 | if err != nil { 20 | return nil, err 21 | } 22 | url := fmt.Sprintf("%s?%s", endpoints.INBOX_INITIAL_STATE_URL, string(encodedQuery)) 23 | 24 | _, respBody, err := c.makeAPIRequest(ctx, apiRequestOpts{ 25 | URL: url, 26 | Method: http.MethodGet, 27 | WithClientUUID: true, 28 | }) 29 | if err != nil { 30 | return nil, err 31 | } 32 | 33 | data := response.InboxInitialStateResponse{} 34 | return &data, json.Unmarshal(respBody, &data) 35 | } 36 | 37 | func (c *Client) GetDMUserUpdates(ctx context.Context, params *payload.DMRequestQuery) (*response.GetDMUserUpdatesResponse, error) { 38 | encodedQuery, err := params.Encode() 39 | if err != nil { 40 | return nil, err 41 | } 42 | url := fmt.Sprintf("%s?%s", endpoints.DM_USER_UPDATES_URL, string(encodedQuery)) 43 | 44 | _, respBody, err := c.makeAPIRequest(ctx, apiRequestOpts{ 45 | URL: url, 46 | Method: http.MethodGet, 47 | WithClientUUID: true, 48 | }) 49 | if err != nil { 50 | return nil, err 51 | } 52 | 53 | data := response.GetDMUserUpdatesResponse{} 54 | return &data, json.Unmarshal(respBody, &data) 55 | } 56 | 57 | func (c *Client) MarkConversationRead(ctx context.Context, params *payload.MarkConversationReadQuery) error { 58 | encodedQueryBody, err := params.Encode() 59 | if err != nil { 60 | return err 61 | } 62 | 63 | url := fmt.Sprintf(endpoints.CONVERSATION_MARK_READ_URL, params.ConversationID) 64 | _, _, err = c.makeAPIRequest(ctx, apiRequestOpts{ 65 | URL: url, 66 | Method: http.MethodPost, 67 | WithClientUUID: true, 68 | Body: encodedQueryBody, 69 | ContentType: types.ContentTypeForm, 70 | }) 71 | if err != nil { 72 | return err 73 | } 74 | 75 | return nil 76 | } 77 | 78 | func (c *Client) FetchConversationContext(ctx context.Context, conversationID string, params *payload.DMRequestQuery, context payload.ContextInfo) (*response.ConversationDMResponse, error) { 79 | params.Context = context 80 | encodedQuery, err := params.Encode() 81 | if err != nil { 82 | return nil, err 83 | } 84 | url := fmt.Sprintf("%s?%s", fmt.Sprintf(endpoints.CONVERSATION_FETCH_MESSAGES, conversationID), string(encodedQuery)) 85 | 86 | _, respBody, err := c.makeAPIRequest(ctx, apiRequestOpts{ 87 | URL: url, 88 | Method: http.MethodGet, 89 | WithClientUUID: true, 90 | }) 91 | if err != nil { 92 | return nil, err 93 | } 94 | 95 | data := response.ConversationDMResponse{} 96 | return &data, json.Unmarshal(respBody, &data) 97 | } 98 | 99 | func (c *Client) FetchTrustedThreads(ctx context.Context, params *payload.DMRequestQuery) (*response.InboxTimelineResponse, error) { 100 | encodedQuery, err := params.Encode() 101 | if err != nil { 102 | return nil, err 103 | } 104 | 105 | _, respBody, err := c.makeAPIRequest(ctx, apiRequestOpts{ 106 | URL: fmt.Sprintf("%s?%s", endpoints.TRUSTED_INBOX_TIMELINE_URL, string(encodedQuery)), 107 | Method: http.MethodGet, 108 | WithClientUUID: true, 109 | }) 110 | if err != nil { 111 | return nil, err 112 | } 113 | 114 | data := response.InboxTimelineResponse{} 115 | return &data, json.Unmarshal(respBody, &data) 116 | } 117 | 118 | func (c *Client) SendDirectMessage(ctx context.Context, pl *payload.SendDirectMessagePayload) (*response.TwitterInboxData, error) { 119 | if pl.RequestID == "" { 120 | pl.RequestID = uuid.NewString() 121 | } 122 | 123 | jsonBody, err := pl.Encode() 124 | if err != nil { 125 | return nil, err 126 | } 127 | 128 | query, _ := (payload.DMSendQuery{}).Default().Encode() 129 | _, respBody, err := c.makeAPIRequest(ctx, apiRequestOpts{ 130 | URL: endpoints.SEND_DM_URL + "?" + string(query), 131 | Method: http.MethodPost, 132 | WithClientUUID: true, 133 | Body: jsonBody, 134 | Referer: fmt.Sprintf("%s/%s", endpoints.BASE_MESSAGES_URL, pl.ConversationID), 135 | Origin: endpoints.BASE_URL, 136 | ContentType: types.ContentTypeJSON, 137 | }) 138 | if err != nil { 139 | return nil, err 140 | } 141 | 142 | data := response.TwitterInboxData{} 143 | return &data, json.Unmarshal(respBody, &data) 144 | } 145 | 146 | func (c *Client) EditDirectMessage(ctx context.Context, payload *payload.EditDirectMessagePayload) (*types.Message, error) { 147 | if payload.RequestID == "" { 148 | payload.RequestID = uuid.NewString() 149 | } 150 | 151 | encodedQuery, err := payload.Encode() 152 | if err != nil { 153 | return nil, err 154 | } 155 | 156 | _, respBody, err := c.makeAPIRequest(ctx, apiRequestOpts{ 157 | URL: fmt.Sprintf("%s?%s", endpoints.EDIT_DM_URL, string(encodedQuery)), 158 | Method: http.MethodPost, 159 | WithClientUUID: true, 160 | Referer: fmt.Sprintf("%s/%s", endpoints.BASE_MESSAGES_URL, payload.ConversationID), 161 | Origin: endpoints.BASE_URL, 162 | ContentType: types.ContentTypeForm, 163 | }) 164 | if err != nil { 165 | return nil, err 166 | } 167 | 168 | data := types.Message{} 169 | return &data, json.Unmarshal(respBody, &data) 170 | } 171 | 172 | func (c *Client) SendTypingNotification(ctx context.Context, conversationID string) error { 173 | variables := &payload.SendTypingNotificationVariables{ 174 | ConversationID: conversationID, 175 | } 176 | 177 | GQLPayload := &payload.GraphQLPayload{ 178 | Variables: variables, 179 | QueryID: "HL96-xZ3Y81IEzAdczDokg", 180 | } 181 | 182 | jsonBody, err := GQLPayload.Encode() 183 | if err != nil { 184 | return err 185 | } 186 | 187 | _, _, err = c.makeAPIRequest(ctx, apiRequestOpts{ 188 | URL: endpoints.SEND_TYPING_NOTIFICATION, 189 | Method: http.MethodPost, 190 | WithClientUUID: true, 191 | Referer: fmt.Sprintf("%s/%s", endpoints.BASE_MESSAGES_URL, conversationID), 192 | Origin: endpoints.BASE_URL, 193 | ContentType: types.ContentTypeJSON, 194 | Body: jsonBody, 195 | }) 196 | return err 197 | } 198 | 199 | // keep in mind this only deletes the message for you 200 | func (c *Client) DeleteMessageForMe(ctx context.Context, variables *payload.DMMessageDeleteMutationVariables) (*response.DMMessageDeleteMutationResponse, error) { 201 | if variables.RequestID == "" { 202 | variables.RequestID = uuid.NewString() 203 | } 204 | 205 | GQLPayload := &payload.GraphQLPayload{ 206 | Variables: variables, 207 | QueryID: "BJ6DtxA2llfjnRoRjaiIiw", 208 | } 209 | 210 | jsonBody, err := GQLPayload.Encode() 211 | if err != nil { 212 | return nil, err 213 | } 214 | 215 | _, respBody, err := c.makeAPIRequest(ctx, apiRequestOpts{ 216 | URL: endpoints.GRAPHQL_MESSAGE_DELETION_MUTATION, 217 | Method: http.MethodPost, 218 | WithClientUUID: true, 219 | Body: jsonBody, 220 | Origin: endpoints.BASE_URL, 221 | ContentType: types.ContentTypeJSON, 222 | }) 223 | if err != nil { 224 | return nil, err 225 | } 226 | 227 | data := response.DMMessageDeleteMutationResponse{} 228 | return &data, json.Unmarshal(respBody, &data) 229 | } 230 | 231 | func (c *Client) DeleteConversation(ctx context.Context, conversationID string, payload *payload.DMRequestQuery) error { 232 | encodedQueryBody, err := payload.Encode() 233 | if err != nil { 234 | return err 235 | } 236 | 237 | resp, respBody, err := c.makeAPIRequest(ctx, apiRequestOpts{ 238 | URL: fmt.Sprintf(endpoints.DELETE_CONVERSATION_URL, conversationID), 239 | Method: http.MethodPost, 240 | WithClientUUID: true, 241 | Body: encodedQueryBody, 242 | Referer: endpoints.BASE_MESSAGES_URL, 243 | Origin: endpoints.BASE_URL, 244 | ContentType: types.ContentTypeForm, 245 | }) 246 | if err != nil { 247 | return err 248 | } 249 | 250 | if resp.StatusCode > 204 { 251 | return fmt.Errorf("failed to delete conversation by id %s (status_code=%d, response_body=%s)", conversationID, resp.StatusCode, string(respBody)) 252 | } 253 | 254 | return nil 255 | } 256 | 257 | func (c *Client) PinConversation(ctx context.Context, conversationID string) (*response.PinConversationResponse, error) { 258 | graphQlPayload := payload.GraphQLPayload{ 259 | Variables: payload.PinAndUnpinConversationVariables{ 260 | ConversationID: conversationID, 261 | Label: payload.LABEL_TYPE_PINNED, 262 | }, 263 | QueryID: "o0aymgGiJY-53Y52YSUGVA", 264 | } 265 | 266 | jsonBody, err := graphQlPayload.Encode() 267 | if err != nil { 268 | return nil, err 269 | } 270 | 271 | _, respBody, err := c.makeAPIRequest(ctx, apiRequestOpts{ 272 | URL: endpoints.PIN_CONVERSATION_URL, 273 | Method: http.MethodPost, 274 | WithClientUUID: true, 275 | Body: jsonBody, 276 | Referer: endpoints.BASE_MESSAGES_URL, 277 | Origin: endpoints.BASE_URL, 278 | ContentType: types.ContentTypeJSON, 279 | }) 280 | if err != nil { 281 | return nil, err 282 | } 283 | 284 | data := response.PinConversationResponse{} 285 | return &data, json.Unmarshal(respBody, &data) 286 | } 287 | 288 | func (c *Client) UnpinConversation(ctx context.Context, conversationID string) (*response.UnpinConversationResponse, error) { 289 | graphQlPayload := payload.GraphQLPayload{ 290 | Variables: payload.PinAndUnpinConversationVariables{ 291 | ConversationID: conversationID, 292 | LabelType: payload.LABEL_TYPE_PINNED, 293 | }, 294 | QueryID: "_TQxP2Rb0expwVP9ktGrTQ", 295 | } 296 | 297 | jsonBody, err := graphQlPayload.Encode() 298 | if err != nil { 299 | return nil, err 300 | } 301 | 302 | _, respBody, err := c.makeAPIRequest(ctx, apiRequestOpts{ 303 | URL: endpoints.UNPIN_CONVERSATION_URL, 304 | Method: http.MethodPost, 305 | WithClientUUID: true, 306 | Body: jsonBody, 307 | Referer: endpoints.BASE_MESSAGES_URL, 308 | Origin: endpoints.BASE_URL, 309 | ContentType: types.ContentTypeJSON, 310 | }) 311 | if err != nil { 312 | return nil, err 313 | } 314 | 315 | data := response.UnpinConversationResponse{} 316 | return &data, json.Unmarshal(respBody, &data) 317 | } 318 | 319 | func (c *Client) React(ctx context.Context, reactionPayload *payload.ReactionActionPayload, remove bool) (*response.ReactionResponse, error) { 320 | graphQlPayload := payload.GraphQLPayload{ 321 | Variables: reactionPayload, 322 | QueryID: "VyDyV9pC2oZEj6g52hgnhA", 323 | } 324 | 325 | url := endpoints.ADD_REACTION_URL 326 | if remove { 327 | url = endpoints.REMOVE_REACTION_URL 328 | graphQlPayload.QueryID = "bV_Nim3RYHsaJwMkTXJ6ew" 329 | } 330 | 331 | jsonBody, err := graphQlPayload.Encode() 332 | if err != nil { 333 | return nil, err 334 | } 335 | 336 | _, respBody, err := c.makeAPIRequest(ctx, apiRequestOpts{ 337 | URL: url, 338 | Method: http.MethodPost, 339 | WithClientUUID: true, 340 | Body: jsonBody, 341 | Origin: endpoints.BASE_URL, 342 | ContentType: types.ContentTypeJSON, 343 | }) 344 | if err != nil { 345 | return nil, err 346 | } 347 | 348 | data := response.ReactionResponse{} 349 | return &data, json.Unmarshal(respBody, &data) 350 | } 351 | -------------------------------------------------------------------------------- /pkg/twittermeow/methods/html.go: -------------------------------------------------------------------------------- 1 | package methods 2 | 3 | import ( 4 | "regexp" 5 | 6 | "go.mau.fi/mautrix-twitter/pkg/twittermeow/data/payload" 7 | ) 8 | 9 | var ( 10 | metaTagRegex = regexp.MustCompile(``) 11 | migrateFormDataRegex = regexp.MustCompile(`]* action="([^"]+)"[^>]*>[\s\S]*?]* name="tok" value="([^"]+)"[^>]*>[\s\S]*?]* name="data" value="([^"]+)"[^>]*>`) 12 | mainScriptURLRegex = regexp.MustCompile(`https:\/\/(?:[A-Za-z0-9.-]+)\/responsive-web\/client-web\/main\.[0-9A-Za-z]+\.js`) 13 | bearerTokenRegex = regexp.MustCompile(`(Bearer\s[A-Za-z0-9%]+)`) 14 | guestTokenRegex = regexp.MustCompile(`gt=([0-9]+)`) 15 | verificationTokenRegex = regexp.MustCompile(`meta name="twitter-site-verification" content="([^"]+)"`) 16 | countryCodeRegex = regexp.MustCompile(`"country":\s*"([A-Z]{2})"`) 17 | ondemandSRegex = regexp.MustCompile(`"ondemand.s":"([a-f0-9]+)"`) 18 | variableIndexesRegex = regexp.MustCompile(`const\[\w{1,2},\w{1,2}]=\[.+?\(\w{1,2}\[(\d{1,2})],16\).+?\(\w{1,2}\[(\d{1,2})],16\).+?\(\w{1,2}\[(\d{1,2})],16\).+?\(\w{1,2}\[(\d{1,2})],16\)`) 19 | ) 20 | 21 | func ParseMigrateURL(html string) (string, bool) { 22 | match := metaTagRegex.FindStringSubmatch(html) 23 | if len(match) > 1 { 24 | return match[1], true 25 | } 26 | return "", false 27 | } 28 | 29 | func ParseMigrateRequestData(html string) (string, *payload.MigrationRequestPayload) { 30 | match := migrateFormDataRegex.FindStringSubmatch(html) 31 | if len(match) < 4 { 32 | return "", nil 33 | } 34 | 35 | return match[1], &payload.MigrationRequestPayload{Tok: match[2], Data: match[3]} 36 | } 37 | 38 | func ParseMainScriptURL(html string) string { 39 | match := mainScriptURLRegex.FindStringSubmatch(html) 40 | if len(match) < 1 { 41 | return "" 42 | } 43 | return match[0] 44 | } 45 | 46 | func ParseBearerTokens(js []byte) [][]byte { 47 | return bearerTokenRegex.FindAll(js, -1) 48 | } 49 | 50 | func ParseVariableIndexes(js []byte) [][]byte { 51 | return variableIndexesRegex.FindSubmatch(js) 52 | } 53 | 54 | func ParseGuestToken(html string) string { 55 | match := guestTokenRegex.FindStringSubmatch(html) 56 | if len(match) < 1 { 57 | return "" 58 | } 59 | return match[1] 60 | } 61 | 62 | func ParseVerificationToken(html string) string { 63 | match := verificationTokenRegex.FindStringSubmatch(html) 64 | if len(match) < 1 { 65 | return "" 66 | } 67 | return match[1] 68 | } 69 | 70 | func ParseCountry(html string) string { 71 | match := countryCodeRegex.FindStringSubmatch(html) 72 | if len(match) < 2 { 73 | return "" 74 | } 75 | return match[1] 76 | } 77 | 78 | func ParseOndemandS(html string) string { 79 | match := ondemandSRegex.FindStringSubmatch(html) 80 | if len(match) < 2 { 81 | return "" 82 | } 83 | return match[1] 84 | } 85 | -------------------------------------------------------------------------------- /pkg/twittermeow/methods/methods.go: -------------------------------------------------------------------------------- 1 | package methods 2 | 3 | import ( 4 | "maps" 5 | "slices" 6 | "sort" 7 | "strconv" 8 | "strings" 9 | "time" 10 | 11 | "go.mau.fi/mautrix-twitter/pkg/twittermeow/data/types" 12 | ) 13 | 14 | const TwitterEpoch = 1288834974657 15 | 16 | func ParseSnowflakeInt(msgID string) int64 { 17 | secs, err := strconv.ParseInt(msgID, 10, 64) 18 | if err != nil { 19 | return 0 20 | } 21 | return (secs >> 22) + TwitterEpoch 22 | } 23 | 24 | func ParseSnowflake(msgID string) time.Time { 25 | msec := ParseSnowflakeInt(msgID) 26 | if msec == 0 { 27 | return time.Time{} 28 | } 29 | return time.UnixMilli(msec) 30 | } 31 | 32 | func SortConversationsByTimestamp(conversations map[string]*types.Conversation) []*types.Conversation { 33 | return slices.SortedFunc(maps.Values(conversations), func(a, b *types.Conversation) int { 34 | return strings.Compare(a.SortTimestamp, b.SortTimestamp) 35 | }) 36 | } 37 | 38 | func SortMessagesByTime(messages []*types.Message) { 39 | slices.SortFunc(messages, func(a, b *types.Message) int { 40 | return strings.Compare(a.ID, b.ID) 41 | }) 42 | } 43 | 44 | func CreateConversationID(conversationIDs []string) string { 45 | sort.Strings(conversationIDs) 46 | return strings.Join(conversationIDs, "-") 47 | } 48 | -------------------------------------------------------------------------------- /pkg/twittermeow/polling.go: -------------------------------------------------------------------------------- 1 | package twittermeow 2 | 3 | import ( 4 | "context" 5 | "sync/atomic" 6 | "time" 7 | 8 | "github.com/rs/zerolog" 9 | 10 | "go.mau.fi/mautrix-twitter/pkg/twittermeow/data/payload" 11 | "go.mau.fi/mautrix-twitter/pkg/twittermeow/data/types" 12 | ) 13 | 14 | var defaultPollingInterval = 10 * time.Second 15 | 16 | type PollingClient struct { 17 | client *Client 18 | currentCursor string 19 | stop atomic.Pointer[context.CancelFunc] 20 | activeConversationID string 21 | includeConversationID bool 22 | shortCircuit chan struct{} 23 | } 24 | 25 | // interval is the delay inbetween checking for new updates 26 | // default interval will be 10s 27 | func (c *Client) newPollingClient() *PollingClient { 28 | return &PollingClient{ 29 | client: c, 30 | shortCircuit: make(chan struct{}, 1), 31 | } 32 | } 33 | 34 | func (pc *PollingClient) startPolling(ctx context.Context) { 35 | ctx, cancel := context.WithCancel(ctx) 36 | if oldCancel := pc.stop.Swap(&cancel); oldCancel != nil { 37 | (*oldCancel)() 38 | } 39 | go pc.doPoll(ctx) 40 | } 41 | 42 | func (pc *PollingClient) doPoll(ctx context.Context) { 43 | tick := time.NewTicker(defaultPollingInterval) 44 | defer tick.Stop() 45 | log := zerolog.Ctx(ctx) 46 | var failing bool 47 | backoffInterval := defaultPollingInterval / 2 48 | for { 49 | select { 50 | case <-tick.C: 51 | case <-pc.shortCircuit: 52 | tick.Reset(defaultPollingInterval) 53 | case <-ctx.Done(): 54 | log.Debug().Err(ctx.Err()).Msg("Polling context canceled") 55 | return 56 | } 57 | err := pc.poll(ctx) 58 | if err != nil { 59 | log.Err(err).Msg("Failed to poll for updates") 60 | pc.client.eventHandler(&types.PollingError{Error: err}, nil) 61 | failing = true 62 | backoffInterval = min(backoffInterval*2, 180*time.Second) 63 | tick.Reset(backoffInterval) 64 | } else if failing { 65 | failing = false 66 | pc.client.eventHandler(&types.PollingError{}, nil) 67 | backoffInterval = defaultPollingInterval / 2 68 | tick.Reset(defaultPollingInterval) 69 | } 70 | } 71 | } 72 | 73 | func (pc *PollingClient) poll(ctx context.Context) error { 74 | userUpdatesQuery := (&payload.DMRequestQuery{}).Default() 75 | if pc.includeConversationID { 76 | userUpdatesQuery.ActiveConversationID = pc.activeConversationID 77 | } else { 78 | userUpdatesQuery.ActiveConversationID = "" 79 | } 80 | pc.includeConversationID = !pc.includeConversationID 81 | if pc.currentCursor != "" { 82 | userUpdatesQuery.Cursor = pc.currentCursor 83 | } 84 | 85 | userUpdatesResponse, err := pc.client.GetDMUserUpdates(ctx, &userUpdatesQuery) 86 | if err != nil { 87 | return err 88 | } 89 | 90 | pc.client.eventHandler(nil, userUpdatesResponse.UserEvents) 91 | for _, entry := range userUpdatesResponse.UserEvents.Entries { 92 | parsed := entry.ParseWithErrorLog(&pc.client.Logger) 93 | if parsed != nil { 94 | pc.client.eventHandler(parsed, userUpdatesResponse.UserEvents) 95 | } 96 | } 97 | 98 | pc.SetCurrentCursor(userUpdatesResponse.UserEvents.Cursor) 99 | return nil 100 | } 101 | 102 | func (pc *PollingClient) SetCurrentCursor(cursor string) { 103 | pc.currentCursor = cursor 104 | } 105 | 106 | func (pc *PollingClient) stopPolling() { 107 | if cancel := pc.stop.Swap(nil); cancel != nil { 108 | (*cancel)() 109 | } 110 | pc.activeConversationID = "" 111 | } 112 | 113 | func (pc *PollingClient) SetActiveConversation(conversationID string) { 114 | pc.activeConversationID = conversationID 115 | pc.pollConversation(conversationID) 116 | } 117 | 118 | func (pc *PollingClient) pollConversation(conversationID string) { 119 | if pc.activeConversationID == conversationID { 120 | pc.includeConversationID = true 121 | select { 122 | case <-pc.shortCircuit: 123 | default: 124 | } 125 | pc.shortCircuit <- struct{}{} 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /pkg/twittermeow/search.go: -------------------------------------------------------------------------------- 1 | package twittermeow 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | 9 | "go.mau.fi/mautrix-twitter/pkg/twittermeow/data/endpoints" 10 | "go.mau.fi/mautrix-twitter/pkg/twittermeow/data/payload" 11 | "go.mau.fi/mautrix-twitter/pkg/twittermeow/data/response" 12 | ) 13 | 14 | func (c *Client) Search(ctx context.Context, params payload.SearchQuery) (*response.SearchResponse, error) { 15 | encodedQuery, err := params.Encode() 16 | if err != nil { 17 | return nil, err 18 | } 19 | 20 | _, respBody, err := c.makeAPIRequest(ctx, apiRequestOpts{ 21 | URL: fmt.Sprintf("%s?%s", endpoints.SEARCH_TYPEAHEAD_URL, string(encodedQuery)), 22 | Method: http.MethodGet, 23 | WithClientUUID: true, 24 | Referer: endpoints.BASE_MESSAGES_URL + "/compose", 25 | }) 26 | if err != nil { 27 | return nil, err 28 | } 29 | 30 | data := response.SearchResponse{} 31 | return &data, json.Unmarshal(respBody, &data) 32 | } 33 | -------------------------------------------------------------------------------- /pkg/twittermeow/session_loader.go: -------------------------------------------------------------------------------- 1 | package twittermeow 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "net/http" 8 | neturl "net/url" 9 | "time" 10 | 11 | "go.mau.fi/mautrix-twitter/pkg/twittermeow/cookies" 12 | "go.mau.fi/mautrix-twitter/pkg/twittermeow/crypto" 13 | "go.mau.fi/mautrix-twitter/pkg/twittermeow/data/endpoints" 14 | "go.mau.fi/mautrix-twitter/pkg/twittermeow/data/payload" 15 | "go.mau.fi/mautrix-twitter/pkg/twittermeow/data/response" 16 | "go.mau.fi/mautrix-twitter/pkg/twittermeow/data/types" 17 | "go.mau.fi/mautrix-twitter/pkg/twittermeow/methods" 18 | 19 | "github.com/google/go-querystring/query" 20 | "github.com/google/uuid" 21 | ) 22 | 23 | var ( 24 | errCookieGuestIDNotFound = errors.New("failed to retrieve and set 'guest_id' cookie from Set-Cookie response headers") 25 | ) 26 | 27 | // retrieved from main page resp, its a 2 year old timestamp; looks constant 28 | const fetchedTime = 1661971138705 29 | 30 | type SessionAuthTokens struct { 31 | authenticated string 32 | notAuthenticated string 33 | } 34 | 35 | type SessionLoader struct { 36 | client *Client 37 | currentUser *response.AccountSettingsResponse 38 | verificationToken string 39 | loadingAnims *[4][16][11]int 40 | variableIndexes *[4]int 41 | animationToken string 42 | country string 43 | clientUUID string 44 | authTokens *SessionAuthTokens 45 | } 46 | 47 | func (c *Client) newSessionLoader() *SessionLoader { 48 | return &SessionLoader{ 49 | client: c, 50 | clientUUID: uuid.NewString(), 51 | authTokens: &SessionAuthTokens{}, 52 | } 53 | } 54 | 55 | func (s *SessionLoader) SetCurrentUser(data *response.AccountSettingsResponse) { 56 | s.currentUser = data 57 | } 58 | 59 | func (s *SessionLoader) GetCurrentUser() *response.AccountSettingsResponse { 60 | return s.currentUser 61 | } 62 | 63 | func (s *SessionLoader) isAuthenticated() bool { 64 | return s.currentUser != nil /*&& s.currentUser.ScreenName != ""*/ 65 | } 66 | 67 | func (s *SessionLoader) LoadPage(ctx context.Context, url string) error { 68 | mainPageURL, err := neturl.Parse(url) 69 | if err != nil { 70 | return fmt.Errorf("failed to parse URL %q: %w", url, err) 71 | } 72 | extraHeaders := map[string]string{ 73 | "upgrade-insecure-requests": "1", 74 | "sec-fetch-site": "none", 75 | "sec-fetch-user": "?1", 76 | "sec-fetch-dest": "document", 77 | } 78 | mainPageResp, mainPageRespBody, err := s.client.MakeRequest(ctx, url, http.MethodGet, s.client.buildHeaders(HeaderOpts{Extra: extraHeaders, WithCookies: true}), nil, types.ContentTypeNone) 79 | if err != nil { 80 | return fmt.Errorf("failed to send main page request: %w", err) 81 | } 82 | 83 | s.client.cookies.UpdateFromResponse(mainPageResp) 84 | if s.client.cookies.IsCookieEmpty(cookies.XGuestID) { 85 | s.client.Logger.Err(errCookieGuestIDNotFound).Msg("No GuestID found in response headers") 86 | return errCookieGuestIDNotFound 87 | } 88 | 89 | mainPageHTML := string(mainPageRespBody) 90 | migrationURL, migrationRequired := methods.ParseMigrateURL(mainPageHTML) 91 | if migrationRequired { 92 | s.client.Logger.Debug().Msg("Migrating session from twitter.com") 93 | extraHeaders = map[string]string{ 94 | "upgrade-insecure-requests": "1", 95 | "sec-fetch-site": "cross-site", 96 | "sec-fetch-mode": "navigate", 97 | "sec-fetch-dest": "document", 98 | } 99 | migrationPageResp, migrationPageRespBody, err := s.client.MakeRequest(ctx, migrationURL, http.MethodGet, s.client.buildHeaders(HeaderOpts{Extra: extraHeaders, Referer: fmt.Sprintf("https://%s/", mainPageURL.Host), WithCookies: true}), nil, types.ContentTypeNone) 100 | if err != nil { 101 | return fmt.Errorf("failed to send migration request: %w", err) 102 | } 103 | 104 | migrationPageHTML := string(migrationPageRespBody) 105 | migrationFormURL, migrationFormPayload := methods.ParseMigrateRequestData(migrationPageHTML) 106 | if migrationFormPayload != nil { 107 | migrationForm, err := query.Values(migrationFormPayload) 108 | if err != nil { 109 | return fmt.Errorf("failed to parse migration form data: %w", err) 110 | } 111 | migrationPayload := []byte(migrationForm.Encode()) 112 | extraHeaders["origin"] = endpoints.TWITTER_BASE_URL 113 | 114 | s.client.disableRedirects() 115 | mainPageResp, _, err = s.client.MakeRequest(ctx, migrationFormURL, http.MethodPost, s.client.buildHeaders(HeaderOpts{Extra: extraHeaders, Referer: endpoints.TWITTER_BASE_URL + "/", WithCookies: true}), migrationPayload, types.ContentTypeForm) 116 | if err == nil || !errors.Is(err, ErrRedirectAttempted) { 117 | return fmt.Errorf("failed to make request to main page, server did not respond with a redirect response") 118 | } 119 | s.client.enableRedirects() 120 | s.client.cookies.UpdateFromResponse(mainPageResp) // update the cookies received from the redirected response headers 121 | 122 | migrationFormURL = endpoints.BASE_URL + mainPageResp.Header.Get("Location") 123 | mainPageResp, mainPageRespBody, err = s.client.MakeRequest(ctx, migrationFormURL, http.MethodGet, s.client.buildHeaders(HeaderOpts{Extra: extraHeaders, Referer: endpoints.TWITTER_BASE_URL + "/", WithCookies: true}), migrationPayload, types.ContentTypeForm) 124 | if err != nil { 125 | return fmt.Errorf("failed to send main page request after migration: %w", err) 126 | } 127 | 128 | mainPageHTML := string(mainPageRespBody) 129 | err = s.client.parseMainPageHTML(ctx, mainPageResp, mainPageHTML) 130 | if err != nil { 131 | return fmt.Errorf("failed to parse main page HTML after migration: %w", err) 132 | } 133 | 134 | err = s.doInitialClientLoggingEvents(ctx) 135 | if err != nil { 136 | return fmt.Errorf("failed to perform initial client logging events after migration: %w", err) 137 | } 138 | 139 | } else { 140 | return fmt.Errorf("failed to find form request data in migration response (HTTP %d)", migrationPageResp.StatusCode) 141 | } 142 | } else { 143 | // most likely means... already authenticated 144 | mainPageHTML := string(mainPageRespBody) 145 | err = s.client.parseMainPageHTML(ctx, mainPageResp, mainPageHTML) 146 | if err != nil { 147 | return fmt.Errorf("failed to parse main page HTML: %w", err) 148 | } 149 | 150 | err = s.doInitialClientLoggingEvents(ctx) 151 | if err != nil { 152 | return fmt.Errorf("failed to perform initial client logging events after migration: %w", err) 153 | } 154 | } 155 | return nil 156 | } 157 | 158 | func (s *SessionLoader) doCookiesMetaDataLoad(ctx context.Context) error { 159 | logData := []interface{}{ 160 | &payload.JotLogPayload{Description: "rweb:cookiesMetadata:load", Product: "rweb", EventValue: time.Until(time.UnixMilli(fetchedTime)).Milliseconds()}, 161 | } 162 | return s.client.performJotClientEvent(ctx, payload.JotLoggingCategoryPerftown, false, logData) 163 | } 164 | 165 | func (s *SessionLoader) doInitialClientLoggingEvents(ctx context.Context) error { 166 | err := s.doCookiesMetaDataLoad(ctx) 167 | if err != nil { 168 | return err 169 | } 170 | country := s.GetCountry() 171 | logData := []interface{}{ 172 | &payload.JotLogPayload{ 173 | Description: "rweb:init:storePrepare", 174 | Product: "rweb", 175 | DurationMS: 9, 176 | }, 177 | &payload.JotLogPayload{ 178 | Description: "rweb:ttft:perfSupported", 179 | Product: "rweb", 180 | DurationMS: 1, 181 | }, 182 | &payload.JotLogPayload{ 183 | Description: "rweb:ttft:perfSupported:" + country, 184 | Product: "rweb", 185 | DurationMS: 1, 186 | }, 187 | &payload.JotLogPayload{ 188 | Description: "rweb:ttft:connect", 189 | Product: "rweb", 190 | DurationMS: 165, 191 | }, 192 | &payload.JotLogPayload{ 193 | Description: "rweb:ttft:connect:" + country, 194 | Product: "rweb", 195 | DurationMS: 165, 196 | }, 197 | &payload.JotLogPayload{ 198 | Description: "rweb:ttft:process", 199 | Product: "rweb", 200 | DurationMS: 177, 201 | }, 202 | &payload.JotLogPayload{ 203 | Description: "rweb:ttft:process:" + country, 204 | Product: "rweb", 205 | DurationMS: 177, 206 | }, 207 | &payload.JotLogPayload{ 208 | Description: "rweb:ttft:response", 209 | Product: "rweb", 210 | DurationMS: 212, 211 | }, 212 | &payload.JotLogPayload{ 213 | Description: "rweb:ttft:response:" + country, 214 | Product: "rweb", 215 | DurationMS: 212, 216 | }, 217 | &payload.JotLogPayload{ 218 | Description: "rweb:ttft:interactivity", 219 | Product: "rweb", 220 | DurationMS: 422, 221 | }, 222 | &payload.JotLogPayload{ 223 | Description: "rweb:ttft:interactivity:" + country, 224 | Product: "rweb", 225 | DurationMS: 422, 226 | }, 227 | } 228 | err = s.client.performJotClientEvent(ctx, payload.JotLoggingCategoryPerftown, false, logData) 229 | if err != nil { 230 | return err 231 | } 232 | 233 | triggeredTimestamp := time.Now().UnixMilli() 234 | logData = []interface{}{ 235 | &payload.JotDebugLogPayload{ 236 | Category: payload.JotDebugLoggingCategoryClientEvent, 237 | TriggeredOn: triggeredTimestamp, 238 | FormatVersion: 2, 239 | Items: []interface{}{}, 240 | EventNamespace: payload.EventNamespace{ 241 | Page: "cookie_compliance_banner", 242 | Action: "impression", 243 | Client: "m5", 244 | }, 245 | ClientEventSequenceStartTimestamp: triggeredTimestamp, 246 | ClientEventSequenceNumber: 0, 247 | ClientAppID: "3033300", 248 | }, 249 | } 250 | 251 | err = s.client.performJotClientEvent(ctx, "", true, logData) 252 | if err != nil { 253 | return err 254 | } 255 | 256 | return nil 257 | } 258 | 259 | func (s *SessionLoader) GetCountry() string { 260 | return s.country 261 | } 262 | 263 | func (s *SessionLoader) SetCountry(country string) { 264 | s.country = country 265 | } 266 | 267 | func (s *SessionLoader) SetVerificationToken(verificationToken string, anims *[4][16][11]int) { 268 | s.verificationToken = verificationToken 269 | s.loadingAnims = anims 270 | } 271 | 272 | func (s *SessionLoader) SetVariableIndexes(indexes *[4]int) { 273 | s.variableIndexes = indexes 274 | } 275 | 276 | func (s *SessionLoader) CalculateAnimationToken() { 277 | if s.variableIndexes != nil && s.loadingAnims != nil && s.verificationToken != "" { 278 | s.animationToken = crypto.GenerateAnimationState(s.variableIndexes, s.loadingAnims, s.verificationToken) 279 | } 280 | } 281 | 282 | func (s *SessionLoader) GetVerificationToken() string { 283 | return s.verificationToken 284 | } 285 | 286 | func (s *SessionLoader) SetAuthTokens(authenticatedToken, notAuthenticatedToken string) { 287 | s.authTokens.authenticated = authenticatedToken 288 | s.authTokens.notAuthenticated = notAuthenticatedToken 289 | } 290 | 291 | func (s *SessionLoader) GetAuthTokens() *SessionAuthTokens { 292 | return s.authTokens 293 | } 294 | -------------------------------------------------------------------------------- /pkg/twittermeow/stream_client.go: -------------------------------------------------------------------------------- 1 | package twittermeow 2 | 3 | import ( 4 | "bufio" 5 | "context" 6 | "encoding/json" 7 | "net/http" 8 | "net/url" 9 | "strings" 10 | "sync/atomic" 11 | "time" 12 | 13 | "github.com/rs/zerolog" 14 | 15 | "go.mau.fi/mautrix-twitter/pkg/twittermeow/data/endpoints" 16 | "go.mau.fi/mautrix-twitter/pkg/twittermeow/data/payload" 17 | "go.mau.fi/mautrix-twitter/pkg/twittermeow/data/response" 18 | "go.mau.fi/mautrix-twitter/pkg/twittermeow/data/types" 19 | ) 20 | 21 | type StreamClient struct { 22 | client *Client 23 | 24 | stop atomic.Pointer[context.CancelFunc] 25 | oldConversationID string 26 | conversationID string 27 | sessionID string 28 | heartbeatInterval time.Duration 29 | shortCircuit chan struct{} 30 | } 31 | 32 | func (c *Client) newStreamClient() *StreamClient { 33 | return &StreamClient{ 34 | client: c, 35 | heartbeatInterval: 25 * time.Second, 36 | shortCircuit: make(chan struct{}, 1), 37 | } 38 | } 39 | 40 | func (sc *StreamClient) startOrUpdateEventStream(conversationID string) { 41 | ctx := sc.client.Logger.With().Str("action", "event stream").Logger().WithContext(context.Background()) 42 | if sc.conversationID == "" { 43 | sc.conversationID = conversationID 44 | go sc.start(ctx) 45 | } else { 46 | sc.oldConversationID = sc.conversationID 47 | sc.conversationID = conversationID 48 | select { 49 | case <-sc.shortCircuit: 50 | default: 51 | } 52 | sc.shortCircuit <- struct{}{} 53 | } 54 | } 55 | 56 | func (sc *StreamClient) start(ctx context.Context) { 57 | ctx, cancel := context.WithCancel(ctx) 58 | if oldCancel := sc.stop.Swap(&cancel); oldCancel != nil { 59 | (*oldCancel)() 60 | } 61 | eventsURL, err := url.Parse(endpoints.PIPELINE_EVENTS_URL) 62 | if err != nil { 63 | zerolog.Ctx(ctx).Err(err) 64 | return 65 | } 66 | 67 | q := url.Values{ 68 | "topic": []string{getSubscriptionTopic(sc.conversationID)}, 69 | } 70 | eventsURL.RawQuery = q.Encode() 71 | 72 | req, err := http.NewRequest(http.MethodGet, eventsURL.String(), nil) 73 | if err != nil { 74 | zerolog.Ctx(ctx).Err(err) 75 | return 76 | } 77 | 78 | extraHeaders := map[string]string{ 79 | "accept": "text/event-stream", 80 | "cache-control": "no-cache", 81 | } 82 | headerOpts := HeaderOpts{ 83 | WithCookies: true, 84 | Extra: extraHeaders, 85 | } 86 | req.Header = sc.client.buildHeaders(headerOpts) 87 | 88 | resp, err := sc.client.HTTP.Do(req) 89 | if err != nil { 90 | zerolog.Ctx(ctx).Err(err).Msg("failed to connect to event stream") 91 | return 92 | } 93 | defer resp.Body.Close() 94 | 95 | if resp.StatusCode != http.StatusOK { 96 | zerolog.Ctx(ctx).Debug().Int("code", resp.StatusCode).Str("status", resp.Status).Msg("failed to connect to event stream") 97 | return 98 | } 99 | scanner := bufio.NewScanner(resp.Body) 100 | for scanner.Scan() { 101 | line := scanner.Text() 102 | if len(line) == 0 || line == ":" { 103 | continue 104 | } 105 | index := strings.Index(line, ":") 106 | field := line[:index] 107 | value := line[index+1:] 108 | if field != "data" { 109 | zerolog.Ctx(ctx).Warn().Str("field", field).Str("value", value).Msg("unhandled stream event") 110 | continue 111 | } 112 | var evt response.StreamEvent 113 | err := json.Unmarshal([]byte(value), &evt) 114 | if err != nil { 115 | zerolog.Ctx(ctx).Err(err).Str("value", value).Msg("error decoding stream event") 116 | continue 117 | } 118 | zerolog.Ctx(ctx).Trace().Any("evt", evt).Msg("stream event") 119 | 120 | config := evt.Payload.Config 121 | if config != nil { 122 | if config.HeartbeatMillis > 0 { 123 | sc.heartbeatInterval = time.Duration(config.HeartbeatMillis) * time.Millisecond 124 | } 125 | if config.SessionID != "" { 126 | noHeartbeat := sc.sessionID == "" 127 | sc.sessionID = config.SessionID 128 | if noHeartbeat { 129 | go sc.startHeartbeat(ctx) 130 | } 131 | } 132 | } else { 133 | sc.client.streamEventHandler(evt) 134 | } 135 | } 136 | } 137 | 138 | func getSubscriptionTopic(conversationID string) string { 139 | return "/dm_update/" + conversationID + ",/dm_typing/" + conversationID 140 | } 141 | 142 | func (sc *StreamClient) startHeartbeat(ctx context.Context) { 143 | tick := time.NewTicker(sc.heartbeatInterval) 144 | defer tick.Stop() 145 | 146 | sc.heartbeat(ctx) 147 | for { 148 | select { 149 | case <-tick.C: 150 | sc.heartbeat(ctx) 151 | case <-sc.shortCircuit: 152 | tick.Reset(sc.heartbeatInterval) 153 | sc.heartbeat(ctx) 154 | case <-ctx.Done(): 155 | return 156 | } 157 | } 158 | } 159 | 160 | func (sc *StreamClient) heartbeat(ctx context.Context) { 161 | payload := &payload.UpdateSubscriptionsPayload{ 162 | SubTopics: getSubscriptionTopic(sc.conversationID), 163 | UnsubTopics: getSubscriptionTopic(sc.oldConversationID), 164 | } 165 | if sc.oldConversationID != "" { 166 | sc.oldConversationID = "" 167 | } 168 | encodedPayload, err := payload.Encode() 169 | if err != nil { 170 | zerolog.Ctx(ctx).Err(err) 171 | return 172 | } 173 | 174 | _, _, err = sc.client.makeAPIRequest(ctx, apiRequestOpts{ 175 | URL: endpoints.PIPELINE_UPDATE_URL, 176 | Method: http.MethodPost, 177 | ContentType: types.ContentTypeForm, 178 | Body: encodedPayload, 179 | Headers: map[string]string{ 180 | "livepipeline-session": sc.sessionID, 181 | }, 182 | }) 183 | if err != nil { 184 | zerolog.Ctx(ctx).Err(err) 185 | } 186 | } 187 | 188 | func (sc *StreamClient) stopStream() { 189 | if cancel := sc.stop.Swap(nil); cancel != nil { 190 | (*cancel)() 191 | } 192 | sc.conversationID = "" 193 | sc.sessionID = "" 194 | } 195 | --------------------------------------------------------------------------------