├── .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 |
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 | 
3 | [](LICENSE)
4 | [](https://github.com/mautrix/twitter/releases)
5 | [](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
")
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(`