From bbe7cac74442e7e5f9f89028377fb86d1f8eee7d Mon Sep 17 00:00:00 2001 From: 0xzer <0xzer@users.noreply.github.com> Date: Fri, 9 Aug 2024 04:17:30 +0200 Subject: [PATCH] current progress state --- .gitignore | 1 + ROADMAP.md | 40 +-- build.sh | 2 +- cmd/mautrix-twitter/main.go | 24 +- go.mod | 16 +- go.sum | 25 +- pkg/connector/backfill.go | 51 ++++ pkg/connector/client.go | 148 +++++++++- pkg/connector/client_sync.go | 77 +++++ pkg/connector/config.go | 12 + pkg/connector/connector.go | 71 +++-- pkg/connector/handlematrix.go | 119 ++++++++ pkg/connector/handletwit.go | 121 ++++++++ pkg/connector/login.go | 154 +++++++++- pkg/connector/mapping.go | 312 ++++++++++++++++++++ pkg/twittermeow/client.go | 17 +- pkg/twittermeow/client_test.go | 1 - pkg/twittermeow/cookies/cookies.go | 21 +- pkg/twittermeow/data/endpoints/endpoints.go | 2 + pkg/twittermeow/data/payload/form.go | 1 + pkg/twittermeow/data/payload/json.go | 11 + pkg/twittermeow/data/response/events.go | 36 ++- pkg/twittermeow/data/response/inbox.go | 7 +- pkg/twittermeow/data/response/messaging.go | 11 + pkg/twittermeow/data/types/entities.go | 50 +++- pkg/twittermeow/data/types/messaging.go | 57 +++- pkg/twittermeow/event/event.go | 26 +- pkg/twittermeow/media.go | 3 - pkg/twittermeow/messaging.go | 34 +++ pkg/twittermeow/methods/methods.go | 2 +- pkg/twittermeow/session_loader.go | 2 + 31 files changed, 1329 insertions(+), 125 deletions(-) create mode 100644 pkg/connector/backfill.go create mode 100644 pkg/connector/client_sync.go create mode 100644 pkg/connector/config.go create mode 100644 pkg/connector/handlematrix.go create mode 100644 pkg/connector/handletwit.go create mode 100644 pkg/connector/mapping.go diff --git a/.gitignore b/.gitignore index 51e6171..4a2aaed 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ logs/ !example-config.yaml !.pre-commit-config.yaml /start +/pkg/mautrix \ No newline at end of file diff --git a/ROADMAP.md b/ROADMAP.md index 9051d81..6697bb7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,35 +1,35 @@ # Features & roadmap * Matrix → Twitter - * [ ] Message content - * [ ] Text + * [+] Message content + * [+] Text * [ ] Formatting - * [ ] Media - * [ ] Images - * [ ] Videos - * [ ] Gifs - * [ ] Message reactions + * [+] Media + * [+] Images + * [+] Videos + * [+] Gifs + * [+] Message reactions * [ ] Typing notifications * [ ] Read receipts * Twitter → Matrix - * [ ] Message content - * [ ] Text + * [+] Message content + * [+] Text * [ ] Formatting - * [ ] Media - * [ ] Images - * [ ] Videos - * [ ] Gifs - * [ ] Message reactions - * [ ] Message history - * [ ] When creating portal + * [+] Media + * [+] Images + * [+] Videos + * [+] Gifs + * [+] Message reactions + * [+] Message history + * [+] When creating portal * [ ] Missed messages - * [ ] Avatars + * [+] Avatars * [ ] † Typing notifications * [ ] † Read receipts * Misc - * [ ] Automatic portal creation - * [ ] At startup - * [ ] When receiving invite or message + * [+] Automatic portal creation + * [+] At startup + * [+] When receiving invite or message * [ ] Provisioning API for logging in * [ ] Private chat creation by inviting Matrix puppet of Twitter user to new room * [ ] Option to use own Matrix account for messages sent from other Twitter clients diff --git a/build.sh b/build.sh index dbf0d43..2de0860 100755 --- a/build.sh +++ b/build.sh @@ -1,4 +1,4 @@ #!/bin/sh MAUTRIX_VERSION=$(cat go.mod | grep 'maunium.net/go/mautrix ' | awk '{ print $2 }' | head -n1) GO_LDFLAGS="-s -w -X main.Tag=$(git describe --exact-match --tags 2>/dev/null) -X main.Commit=$(git rev-parse HEAD) -X 'main.BuildTime=`date -Iseconds`' -X 'maunium.net/go/mautrix.GoModVersion=$MAUTRIX_VERSION'" -go build -ldflags="$GO_LDFLAGS" ./cmd/mautrix-twitter "$@" +go build -ldflags="$GO_LDFLAGS" ./cmd/mautrix-twitter "$@" \ No newline at end of file diff --git a/cmd/mautrix-twitter/main.go b/cmd/mautrix-twitter/main.go index cf1fefe..b1b423d 100644 --- a/cmd/mautrix-twitter/main.go +++ b/cmd/mautrix-twitter/main.go @@ -1,4 +1,4 @@ -// mautrix-twitter - A Matrix-Slack puppeting bridge. +// mautrix-twitter - A Matrix-Twitter puppeting bridge. // Copyright (C) 2024 Tulir Asokan // // This program is free software: you can redistribute it and/or modify @@ -16,5 +16,27 @@ package main +import ( + "go.mau.fi/mautrix-twitter/pkg/connector" + "maunium.net/go/mautrix/bridgev2/matrix/mxmain" +) + +// Information to find out exactly which commit the bridge was built from. +// These are filled at build time with the -X linker flag. +var ( + Tag = "unknown" + Commit = "unknown" + BuildTime = "unknown" +) + func main() { + m := mxmain.BridgeMain{ + Name: "mautrix-twitter", + URL: "https://github.com/mautrix/twitter", + Description: "A Matrix-Twitter puppeting bridge.", + Version: "0.16.0", + Connector: connector.NewConnector(), + } + m.InitVersion(Tag, Commit, BuildTime) + m.Run() } diff --git a/go.mod b/go.mod index 417d134..c00c1c7 100644 --- a/go.mod +++ b/go.mod @@ -7,25 +7,33 @@ require ( github.com/google/uuid v1.6.0 github.com/mattn/go-colorable v0.1.13 github.com/rs/zerolog v1.33.0 - go.mau.fi/util v0.6.0 + go.mau.fi/util v0.6.1-0.20240802175451-b430ebbffc98 golang.org/x/net v0.27.0 - maunium.net/go/mautrix v0.19.1-0.20240719130542-cc5f225bc61c + maunium.net/go/mautrix v0.19.1-0.20240720173515-24ead553b23b ) +replace maunium.net/go/mautrix v0.19.1-0.20240720173515-24ead553b23b => ./pkg/mautrix + require ( github.com/coreos/go-systemd/v22 v22.5.0 // indirect github.com/gorilla/mux v1.8.0 // indirect github.com/gorilla/websocket v1.5.0 // indirect + github.com/lib/pq v1.10.9 // indirect github.com/mattn/go-isatty v0.0.19 // indirect - github.com/tidwall/gjson v1.17.1 // indirect + github.com/mattn/go-sqlite3 v1.14.22 // indirect + github.com/rs/xid v1.5.0 // indirect + github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect + github.com/tidwall/gjson v1.17.3 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.0 // indirect github.com/tidwall/sjson v1.2.5 // indirect github.com/yuin/goldmark v1.7.4 // indirect go.mau.fi/zeroconfig v0.1.3 // indirect golang.org/x/crypto v0.25.0 // indirect - golang.org/x/exp v0.0.0-20240707233637-46b078467d37 // indirect + golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect golang.org/x/sys v0.22.0 // indirect + golang.org/x/text v0.16.0 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + maunium.net/go/mauflag v1.0.0 // indirect ) diff --git a/go.sum b/go.sum index f2337e0..6d5d436 100644 --- a/go.sum +++ b/go.sum @@ -16,22 +16,29 @@ github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rs/xid v1.5.0 h1:mKX4bl4iPYJtEIxp6CYiUuLQ/8DYMoz0PUdtGgMFRVc= github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8= github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= -github.com/tidwall/gjson v1.17.1 h1:wlYEnwqAHgzmhNUFfw7Xalt2JzQvsMx2Se4PcoFCT/U= -github.com/tidwall/gjson v1.17.1/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.17.3 h1:bwWLZU7icoKRG+C+0PNwIKC6FCJO/Q3p2pZvuP0jN94= +github.com/tidwall/gjson v1.17.3/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs= @@ -40,14 +47,14 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/yuin/goldmark v1.7.4 h1:BDXOHExt+A7gwPCJgPIIq7ENvceR7we7rOS9TNoLZeg= github.com/yuin/goldmark v1.7.4/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= -go.mau.fi/util v0.6.0 h1:W6SyB3Bm/GjenQ5iq8Z8WWdN85Gy2xS6L0wmnR7SVjg= -go.mau.fi/util v0.6.0/go.mod h1:ljYdq3sPfpICc3zMU+/mHV/sa4z0nKxc67hSBwnrk8U= +go.mau.fi/util v0.6.1-0.20240802175451-b430ebbffc98 h1:gJ0peWecBm6TtlxKFVIc1KbooXSCHtPfsfb2Eha5A0A= +go.mau.fi/util v0.6.1-0.20240802175451-b430ebbffc98/go.mod h1:S1juuPWGau2GctPY3FR/4ec/MDLhAG2QPhdnUwpzWIo= go.mau.fi/zeroconfig v0.1.3 h1:As9wYDKmktjmNZW5i1vn8zvJlmGKHeVxHVIBMXsm4kM= go.mau.fi/zeroconfig v0.1.3/go.mod h1:NcSJkf180JT+1IId76PcMuLTNa1CzsFFZ0nBygIQM70= golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30= golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M= -golang.org/x/exp v0.0.0-20240707233637-46b078467d37 h1:uLDX+AfeFCct3a2C7uIWBKMJIR3CJMhcgfrUAqjRK6w= -golang.org/x/exp v0.0.0-20240707233637-46b078467d37/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= +golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= +golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys= golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -55,6 +62,8 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= +golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -62,5 +71,5 @@ gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -maunium.net/go/mautrix v0.19.1-0.20240719130542-cc5f225bc61c h1:wjQNGvMn85CRf8bqwqXsg55hvEuxIO8WH0SvOhzv/1E= -maunium.net/go/mautrix v0.19.1-0.20240719130542-cc5f225bc61c/go.mod h1:UE+mSQ4sDUuJMbjN0aB9EjQSGgXd48AzMvZ6+QJV1k8= +maunium.net/go/mauflag v1.0.0 h1:YiaRc0tEI3toYtJMRIfjP+jklH45uDHtT80nUamyD4M= +maunium.net/go/mauflag v1.0.0/go.mod h1:nLivPOpTpHnpzEh8jEdSL9UqO9+/KBJFmNRlwKfkPeA= diff --git a/pkg/connector/backfill.go b/pkg/connector/backfill.go new file mode 100644 index 0000000..2b6c4e3 --- /dev/null +++ b/pkg/connector/backfill.go @@ -0,0 +1,51 @@ +package connector + +import ( + "context" + "log" + "os" + + "go.mau.fi/mautrix-twitter/pkg/twittermeow/data/payload" + "go.mau.fi/mautrix-twitter/pkg/twittermeow/data/types" + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/networkid" +) + +var _ bridgev2.BackfillingNetworkAPI = (*TwitterClient)(nil) + +func (tc *TwitterClient) FetchMessages(ctx context.Context, params bridgev2.FetchMessagesParams) (*bridgev2.FetchMessagesResponse, error) { + conversationId := string(params.Portal.PortalKey.ID) + cursor := params.Cursor + //count := params.Count + + reqQuery := payload.DmRequestQuery{}.Default() + reqQuery.Count = 25 + messageResp, err := tc.client.FetchConversationContext(conversationId, reqQuery, payload.CONTEXT_FETCH_DM_CONVERSATION_HISTORY) + if err != nil { + return nil, err + } + + if cursor != "" { + log.Println("found cursor:", params) + os.Exit(1) + } + + messages, err := messageResp.ConversationTimeline.GetMessageEntriesByConversationID(conversationId, true) + if err != nil { + return nil, err + } + + backfilledMessages, err := tc.MessagesToBackfillMessages(ctx, messages, messageResp.ConversationTimeline.GetConversationByID(conversationId)) + if err != nil { + return nil, err + } + + fetchMessagesResp := &bridgev2.FetchMessagesResponse{ + Messages: backfilledMessages, + Cursor: networkid.PaginationCursor(messageResp.ConversationTimeline.MinEntryID), + HasMore: messageResp.ConversationTimeline.Status == types.HAS_MORE, + Forward: params.Forward, + } + + return fetchMessagesResp, nil +} \ No newline at end of file diff --git a/pkg/connector/client.go b/pkg/connector/client.go index 241df4d..e713d77 100644 --- a/pkg/connector/client.go +++ b/pkg/connector/client.go @@ -1,4 +1,4 @@ -// mautrix-twitter - A Matrix-Slack puppeting bridge. +// mautrix-twitter - A Matrix-Twitter puppeting bridge. // Copyright (C) 2024 Tulir Asokan // // This program is free software: you can redistribute it and/or modify @@ -18,19 +18,68 @@ package connector import ( "context" - + "fmt" + "github.com/rs/zerolog" + "go.mau.fi/mautrix-twitter/pkg/twittermeow" + "go.mau.fi/mautrix-twitter/pkg/twittermeow/cookies" + "go.mau.fi/mautrix-twitter/pkg/twittermeow/data/payload" + "go.mau.fi/mautrix-twitter/pkg/twittermeow/data/types" + "go.mau.fi/mautrix-twitter/pkg/twittermeow/event" + "go.mau.fi/mautrix-twitter/pkg/twittermeow/methods" + "maunium.net/go/mautrix/bridge/status" "maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2/networkid" + bridgeEvt "maunium.net/go/mautrix/event" ) type TwitterClient struct { + connector *TwitterConnector + client *twittermeow.Client + + userLogin *bridgev2.UserLogin + + userCache map[string]types.User +} + +var ( + _ bridgev2.NetworkAPI = (*TwitterClient)(nil) + _ bridgev2.ReactionHandlingNetworkAPI = (*TwitterClient)(nil) +) + +func NewTwitterClient(ctx context.Context, tc *TwitterConnector, login *bridgev2.UserLogin) (*TwitterClient, error) { + log := zerolog.Ctx(ctx).With(). + Str("component", "twitter_client"). + Str("user_login_id", string(login.ID)). + Logger() + + meta := login.Metadata.(*UserLoginMetadata) + clientOpts := &twittermeow.ClientOpts{ + Cookies: cookies.NewCookiesFromString(meta.Cookies), + WithJOTClient: true, + } + twitClient := &TwitterClient{ + client: twittermeow.NewClient(clientOpts, log), + userLogin: login, + userCache: make(map[string]types.User), + } + + twitClient.client.SetEventHandler(twitClient.HandleTwitterEvent) + + return twitClient, nil } -var _ bridgev2.NetworkAPI = (*TwitterClient)(nil) func (tc *TwitterClient) Connect(ctx context.Context) error { - //TODO implement me - panic("implement me") + if tc.client == nil { + tc.userLogin.BridgeState.Send(status.BridgeState{ + StateEvent: status.StateBadCredentials, + Error: "twitter-not-logged-in", + }) + return nil + } + + go tc.syncChannels(ctx) + return tc.client.Connect() } func (tc *TwitterClient) Disconnect() { @@ -54,21 +103,92 @@ func (tc *TwitterClient) IsThisUser(ctx context.Context, userID networkid.UserID } func (tc *TwitterClient) GetChatInfo(ctx context.Context, portal *bridgev2.Portal) (*bridgev2.ChatInfo, error) { - //TODO implement me - panic("implement me") + conversationId := string(portal.PortalKey.ID) + queryConversationPayload := payload.DmRequestQuery{}.Default() + queryConversationPayload.IncludeConversationInfo = true + conversationData, err := tc.client.FetchConversationContext(conversationId, queryConversationPayload, payload.CONTEXT_FETCH_DM_CONVERSATION) + if err != nil { + return nil, err + } + + conversations := conversationData.ConversationTimeline.Conversations + if len(conversations) <= 0 { + return nil, fmt.Errorf("failed to find conversation by id %s", string(conversationId)) + } + + conversation := conversations[conversationId] + users := conversationData.ConversationTimeline.Users + + methods.MergeMaps(tc.userCache, users) + + return tc.ConversationToChatInfo(&conversation), nil } func (tc *TwitterClient) GetUserInfo(ctx context.Context, ghost *bridgev2.Ghost) (*bridgev2.UserInfo, error) { - //TODO implement me - panic("implement me") + userInfo := tc.GetUserInfoBridge(string(ghost.ID)) + if userInfo == nil { + return nil, fmt.Errorf("failed to find user info in cache by id: %s", ghost.ID) + } + return userInfo, nil } func (tc *TwitterClient) GetCapabilities(ctx context.Context, portal *bridgev2.Portal) *bridgev2.NetworkRoomCapabilities { - //TODO implement me - panic("implement me") + return &bridgev2.NetworkRoomCapabilities{ + Captions: true, + Replies: true, + Reactions: true, + ReactionCount: 1, + } } -func (tc *TwitterClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.MatrixMessage) (message *bridgev2.MatrixMessageResponse, err error) { - //TODO implement me - panic("implement me") +func (tc *TwitterClient) convertToMatrix(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI, msg *event.XEventMessage) (*bridgev2.ConvertedMessage, error) { + partId := networkid.PartID("") + var MessageOptionalPartID *networkid.MessageOptionalPartID + if msg.ReplyData.ID != "" { + MessageOptionalPartID = &networkid.MessageOptionalPartID{ + MessageID: networkid.MessageID(msg.ReplyData.ID), + PartID: &partId, + } + } + + textPart := &bridgev2.ConvertedMessagePart{ + ID: partId, + Type: bridgeEvt.EventMessage, + Content: &bridgeEvt.MessageEventContent{ + MsgType: bridgeEvt.MsgText, + Body: msg.Text, + }, + } + + parts := make([]*bridgev2.ConvertedMessagePart, 0) + + if msg.Attachment != nil { + convertedAttachmentPart, indices, err := tc.TwitterAttachmentToMatrix(ctx, portal, intent, msg.Attachment) + if err != nil { + return nil, err + } + parts = append(parts, convertedAttachmentPart) + + RemoveEntityLinkFromText(textPart, indices) + } + + parts = append(parts, textPart) + + cm := &bridgev2.ConvertedMessage{ + ReplyTo: MessageOptionalPartID, + Parts: parts, + } + + return cm, nil } + +func (tc *TwitterClient) MakePortalKey(conv types.Conversation) networkid.PortalKey { + var receiver networkid.UserLoginID + if conv.Type == types.ONE_TO_ONE { + receiver = tc.userLogin.ID + } + return networkid.PortalKey{ + ID: networkid.PortalID(conv.ConversationID), + Receiver: receiver, + } +} \ No newline at end of file diff --git a/pkg/connector/client_sync.go b/pkg/connector/client_sync.go new file mode 100644 index 0000000..301cf31 --- /dev/null +++ b/pkg/connector/client_sync.go @@ -0,0 +1,77 @@ +package connector + +import ( + "context" + "fmt" + "github.com/rs/zerolog" + "go.mau.fi/mautrix-twitter/pkg/twittermeow/data/payload" + "go.mau.fi/mautrix-twitter/pkg/twittermeow/data/types" + "go.mau.fi/mautrix-twitter/pkg/twittermeow/methods" + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/simplevent" +) + +func (tc *TwitterClient) syncChannels(ctx context.Context) { + //log := zerolog.Ctx(ctx) + + reqQuery := payload.DmRequestQuery{}.Default() + initalInboxState, err := tc.client.GetInitialInboxState(reqQuery) + if err != nil { + panic(fmt.Sprintf("failed to fetch initial inbox state: %s", err.Error())) + } + + inboxData := initalInboxState.InboxInitialState + trustedInbox := inboxData.InboxTimelines.Trusted + cursor := trustedInbox.MinEntryID + paginationStatus := trustedInbox.Status + + // loop until no more threads can be found + // add backfill configuration to limit this later + for paginationStatus == types.HAS_MORE { + reqQuery.MaxID = cursor + nextInboxTimelineResponse, err := tc.client.FetchTrustedThreads(reqQuery) + if err != nil { + panic(fmt.Sprintf("failed to fetch threads in trusted inbox using cursor %s: %s", cursor, err.Error())) + } + + methods.MergeMaps(inboxData.Conversations, nextInboxTimelineResponse.InboxTimeline.Conversations) + methods.MergeMaps(inboxData.Users, nextInboxTimelineResponse.InboxTimeline.Users) + inboxData.Entries = append(inboxData.Entries, nextInboxTimelineResponse.InboxTimeline.Entries...) + + cursor = nextInboxTimelineResponse.InboxTimeline.MinEntryID + paginationStatus = nextInboxTimelineResponse.InboxTimeline.Status + } + + methods.MergeMaps(tc.userCache, inboxData.Users) + + + conversations, err := inboxData.Prettify() + if err != nil { + panic(fmt.Sprintf("failed to prettify inbox data after fetching conversations: %s", err.Error())) + } + + for _, convInboxData := range conversations { + conv := convInboxData.Conversation + methods.SortMessagesByTime(convInboxData.Messages) + messages := convInboxData.Messages + latestMessage := messages[len(messages)-1] + latestMessageTS, err := methods.UnixStringMilliToTime(latestMessage.MessageData.Time) + if err != nil { + panic(fmt.Sprintf("failed to convert latest message TS to time.Time: %s", err.Error())) + } + evt := &simplevent.ChatResync{ + EventMeta: simplevent.EventMeta{ + Type: bridgev2.RemoteEventChatResync, + LogContext: func(c zerolog.Context) zerolog.Context { + return c. + Str("portal_key", conv.ConversationID) + }, + PortalKey: tc.MakePortalKey(conv), + CreatePortal: true, + }, + ChatInfo: tc.ConversationToChatInfo(&conv), + LatestMessageTS: latestMessageTS, + } + tc.connector.br.QueueRemoteEvent(tc.userLogin, evt) + } +} \ No newline at end of file diff --git a/pkg/connector/config.go b/pkg/connector/config.go new file mode 100644 index 0000000..e49ea5c --- /dev/null +++ b/pkg/connector/config.go @@ -0,0 +1,12 @@ +package connector + +import ( + "go.mau.fi/util/configupgrade" +) + +type TwitterConfig struct { +} + +func (tc *TwitterConnector) GetConfig() (example string, data any, upgrader configupgrade.Upgrader) { + return "", nil, configupgrade.NoopUpgrader +} \ No newline at end of file diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index f0c2c88..83a36b4 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -1,4 +1,4 @@ -// mautrix-twitter - A Matrix-Slack puppeting bridge. +// mautrix-twitter - A Matrix-Twitter puppeting bridge. // Copyright (C) 2024 Tulir Asokan // // This program is free software: you can redistribute it and/or modify @@ -18,24 +18,35 @@ package connector import ( "context" - - "go.mau.fi/util/configupgrade" + "fmt" + "log" + "go.mau.fi/mautrix-twitter/pkg/twittermeow" + twitCookies "go.mau.fi/mautrix-twitter/pkg/twittermeow/cookies" + "go.mau.fi/mautrix-twitter/pkg/twittermeow/data/types" "maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2/database" ) -type TwitterConnector struct{} +type TwitterConnector struct{ + br *bridgev2.Bridge + + Config *TwitterConfig +} var _ bridgev2.NetworkConnector = (*TwitterConnector)(nil) +func NewConnector() *TwitterConnector { + return &TwitterConnector{} +} + func (tc *TwitterConnector) Init(bridge *bridgev2.Bridge) { - //TODO implement me - panic("implement me") + tc.br = bridge } func (tc *TwitterConnector) Start(ctx context.Context) error { - //TODO implement me - panic("implement me") + + log.Println("starting....") + return nil } func (tc *TwitterConnector) GetName() bridgev2.BridgeName { @@ -50,21 +61,47 @@ func (tc *TwitterConnector) GetName() bridgev2.BridgeName { } func (tc *TwitterConnector) GetDBMetaTypes() database.MetaTypes { - //TODO implement me - panic("implement me") + return database.MetaTypes{ + Reaction: nil, + Portal: nil, + Message: nil, + Ghost: nil, + UserLogin: func () any { + return &UserLoginMetadata{} + }, + } } func (tc *TwitterConnector) GetCapabilities() *bridgev2.NetworkGeneralCapabilities { - //TODO implement me - panic("implement me") + return &bridgev2.NetworkGeneralCapabilities{} } -func (tc *TwitterConnector) GetConfig() (example string, data any, upgrader configupgrade.Upgrader) { - //TODO implement me - panic("implement me") +type UserLoginMetadata struct { + Cookies string } func (tc *TwitterConnector) LoadUserLogin(ctx context.Context, login *bridgev2.UserLogin) error { - //TODO implement me - panic("implement me") + meta := login.Metadata.(*UserLoginMetadata) + clientOpts := &twittermeow.ClientOpts{ + Cookies: twitCookies.NewCookiesFromString(meta.Cookies), + WithJOTClient: true, + } + twitClient := &TwitterClient{ + connector: tc, + userLogin: login, + client: twittermeow.NewClient(clientOpts, login.Log), + userCache: make(map[string]types.User), + } + twitClient.client.SetEventHandler(twitClient.HandleTwitterEvent) + + _, currentUser, err := twitClient.client.LoadMessagesPage() + if err != nil { + return fmt.Errorf("failed to load messages page") + } + + + login.RemoteName = currentUser.ScreenName + login.Client = twitClient + + return nil } diff --git a/pkg/connector/handlematrix.go b/pkg/connector/handlematrix.go new file mode 100644 index 0000000..0b19660 --- /dev/null +++ b/pkg/connector/handlematrix.go @@ -0,0 +1,119 @@ +package connector + +import ( + "context" + "go.mau.fi/mautrix-twitter/pkg/twittermeow/data/payload" + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/database" + "maunium.net/go/mautrix/bridgev2/networkid" + "maunium.net/go/mautrix/event" +) + +var ( + MSG_TYPE_TO_MEDIA_TYPE = map[event.MessageType]payload.MediaType{ + event.MsgVideo: payload.MEDIA_TYPE_VIDEO_MP4, + event.MsgImage: payload.MEDIA_TYPE_IMAGE_JPEG, + } + MSG_TYPE_TO_MEDIA_CATEGORY = map[event.MessageType]payload.MediaCategory{ + event.MsgVideo: payload.MEDIA_CATEGORY_DM_VIDEO, + event.MsgImage: payload.MEDIA_CATEGORY_DM_IMAGE, + } +) + +func (tc *TwitterClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.MatrixMessage) (message *bridgev2.MatrixMessageResponse, err error) { + conversationId := string(msg.Portal.ID) + sendDMPayload := &payload.SendDirectMessagePayload{ + ConversationID: conversationId, + IncludeCards: 1, + IncludeQuoteCount: true, + RecipientIds: false, + DmUsers: false, + } + + if msg.ReplyTo != nil { + sendDMPayload.ReplyToDmID = string(msg.ReplyTo.ID) + } + + content := msg.Content + if content.FileName != "" && content.Body != content.FileName { + sendDMPayload.Text = content.Body + } + + switch content.MsgType { + case event.MsgText: + sendDMPayload.Text = content.Body + case event.MsgVideo, event.MsgImage: + file := content.GetFile() + data, err := tc.connector.br.Bot.DownloadMedia(ctx, file.URL, file) + if err != nil { + return nil, err + } + + uploadMediaParams := &payload.UploadMediaQuery{ + MediaType: MSG_TYPE_TO_MEDIA_TYPE[content.MsgType], + MediaCategory: MSG_TYPE_TO_MEDIA_CATEGORY[content.MsgType], + } + uploadedMediaResponse, err := tc.client.UploadMedia(uploadMediaParams, data) + if err != nil { + return nil, err + } + + tc.client.Logger.Debug().Any("media_info", uploadedMediaResponse).Msg("Successfully uploaded media to twitter's servers") + sendDMPayload.MediaID = uploadedMediaResponse.MediaIDString + default: + tc.client.Logger.Warn().Any("msg_type", content.MsgType).Msg("Found unhandled MsgType in HandleMatrixMessage function") + } + + resp, err := tc.client.SendDirectMessage(sendDMPayload) + if err != nil { + return nil, err + } + + messageData, err := resp.PrettifyMessages(conversationId) + if err != nil { + return nil, err + } + + respMessageData := messageData[0] + return &bridgev2.MatrixMessageResponse{ + DB: &database.Message{ + ID: networkid.MessageID(respMessageData.MessageID), + MXID: msg.Event.ID, + Room: msg.Portal.PortalKey, + SenderID: networkid.UserID(tc.client.GetCurrentUserID()), + Timestamp: respMessageData.SentAt, + }, + }, nil +} + +func (tc *TwitterClient) HandleMatrixReactionRemove(ctx context.Context, msg *bridgev2.MatrixReactionRemove) error { + return tc.doHandleMatrixReaction(true, string(msg.Portal.ID), string(msg.TargetReaction.MessageID), msg.TargetReaction.Emoji) +} + +func (tc *TwitterClient) PreHandleMatrixReaction(ctx context.Context, msg *bridgev2.MatrixReaction) (bridgev2.MatrixReactionPreResponse, error) { + return bridgev2.MatrixReactionPreResponse{ + SenderID: networkid.UserID(tc.userLogin.ID), + Emoji: msg.Content.RelatesTo.Key, + MaxReactions: 1, + }, nil +} + +func (tc *TwitterClient) HandleMatrixReaction(ctx context.Context, msg *bridgev2.MatrixReaction) (reaction *database.Reaction, err error) { + return nil, tc.doHandleMatrixReaction(false, string(msg.Portal.ID), string(msg.TargetMessage.ID), msg.PreHandleResp.Emoji) +} + +func (tc *TwitterClient) doHandleMatrixReaction(remove bool, conversationId, messageId, emoji string) error { + reactionPayload := &payload.ReactionActionPayload{ + ConversationID: conversationId, + MessageID: messageId, + ReactionTypes: []string{"Emoji"}, + EmojiReactions: []string{emoji}, + } + reactionResponse, err := tc.client.React(reactionPayload, remove) + if err != nil { + return err + } + + tc.client.Logger.Debug().Any("reactionResponse", reactionResponse).Any("payload", reactionPayload).Msg("Reaction response") + return nil +} \ No newline at end of file diff --git a/pkg/connector/handletwit.go b/pkg/connector/handletwit.go new file mode 100644 index 0000000..d376fc5 --- /dev/null +++ b/pkg/connector/handletwit.go @@ -0,0 +1,121 @@ +package connector + +import ( + "github.com/rs/zerolog" + "go.mau.fi/mautrix-twitter/pkg/twittermeow/data/types" + "go.mau.fi/mautrix-twitter/pkg/twittermeow/event" + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/networkid" +) + +func (tc *TwitterClient) HandleTwitterEvent(rawEvt any) { + switch evtData := rawEvt.(type) { + case event.XEventMessage: + sender := evtData.Sender + isFromMe := sender.IDStr == string(tc.userLogin.ID) + tc.connector.br.QueueRemoteEvent(tc.userLogin, &bridgev2.SimpleRemoteEvent[*event.XEventMessage]{ + Type: bridgev2.RemoteEventMessage, + ID: networkid.MessageID(evtData.MessageID), + LogContext: func(c zerolog.Context) zerolog.Context { + return c. + Str("message_id", evtData.MessageID). + Str("sender", sender.IDStr). + Str("sender_login", sender.ScreenName). + Bool("is_from_me", isFromMe) + }, + Sender: bridgev2.EventSender{ + IsFromMe: isFromMe, + SenderLogin: networkid.UserLoginID(sender.IDStr), + Sender: networkid.UserID(sender.IDStr), + }, + PortalKey: tc.MakePortalKey(evtData.Conversation), + Data: &evtData, + ConvertMessageFunc: tc.convertToMatrix, + CreatePortal: true, + Timestamp: evtData.CreatedAt, + }) + case event.XEventReaction: + reactionRemoteEvent := tc.wrapReaction(evtData) + tc.connector.br.QueueRemoteEvent(tc.userLogin, reactionRemoteEvent) + case event.XEventConversationRead: + /* + eventData := &simplevent.Receipt{ + EventMeta: simplevent.EventMeta{ + Type: bridgev2.RemoteEventReadReceipt, + LogContext: func(c zerolog.Context) zerolog.Context { + return c. + Str("conversation_id", evtData.Conversation.ConversationID). + Str("last_read_event_id", evtData.LastReadEventID). + Str("read_at", evtData.ReadAt.String()) + }, + PortalKey: tc.MakePortalKey(evtData.Conversation), + }, + LastTarget: networkid.MessageID(evtData.LastReadEventID), + Targets: []networkid.MessageID{networkid.MessageID(evtData.LastReadEventID)}, + } + */ + tc.client.Logger.Info(). + Str("conversation_id", evtData.Conversation.ConversationID). + Str("last_read_event_id", evtData.LastReadEventID). + Str("read_at", evtData.ReadAt.String()). + Msg("Conversation was read!") + case event.XEventConversationCreated: + // honestly not sure when this is ever called... ? might be when they initialize the conversation with me? + tc.client.Logger.Warn().Any("data", evtData).Msg("XEventConversationCreated") + case event.XEventMessageDeleted: + for _, deletedMsg := range evtData.Messages { + messageDeleteRemoteEvent := &bridgev2.SimpleRemoteEvent[*types.MessagesDeleted]{ + Type: bridgev2.RemoteEventMessageRemove, + PortalKey: tc.MakePortalKey(evtData.Conversation), + LogContext: func(c zerolog.Context) zerolog.Context { + return c. + Str("message_id", deletedMsg.MessageID). + Str("message_create_event_id", deletedMsg.MessageCreateEventID) + }, + TargetMessage: networkid.MessageID(deletedMsg.MessageID), + Timestamp: evtData.DeletedAt, + Data: &deletedMsg, + } + tc.connector.br.QueueRemoteEvent(tc.userLogin, messageDeleteRemoteEvent) + } + default: + tc.client.Logger.Warn().Any("event_data", evtData).Msg("Received unhandled event case from twitter library") + } +} + +func (tc *TwitterClient) wrapReaction(data event.XEventReaction) *bridgev2.SimpleRemoteEvent[*event.XEventReaction] { + var eventType bridgev2.RemoteEventType + if data.Action == types.MessageReactionRemove { + eventType = bridgev2.RemoteEventReactionRemove + } else { + eventType = bridgev2.RemoteEventReaction + } + + var receiver networkid.UserLoginID + if data.Conversation.Type == types.ONE_TO_ONE { + receiver = networkid.UserLoginID(tc.userLogin.ID) + } + return &bridgev2.SimpleRemoteEvent[*event.XEventReaction]{ + Type: eventType, + Data: &data, + LogContext: func(c zerolog.Context) zerolog.Context { + return c. + Str("message_id", data.MessageID). + Str("sender", data.SenderID). + Str("reaction_key", data.ReactionKey). + Str("emoji_reaction", data.EmojiReaction) + }, + PortalKey: networkid.PortalKey{ + ID: networkid.PortalID(data.Conversation.ConversationID), + Receiver: receiver, + }, + EmojiID: "", + Emoji: data.EmojiReaction, + TargetMessage: networkid.MessageID(data.MessageID), + Timestamp: data.Time, + Sender: bridgev2.EventSender{ + IsFromMe: data.SenderID == string(tc.userLogin.ID), + Sender: networkid.UserID(data.SenderID), + }, + } +} \ No newline at end of file diff --git a/pkg/connector/login.go b/pkg/connector/login.go index fcd3ea9..e2a7fb2 100644 --- a/pkg/connector/login.go +++ b/pkg/connector/login.go @@ -1,4 +1,4 @@ -// mautrix-twitter - A Matrix-Slack puppeting bridge. +// mautrix-twitter - A Matrix-Twitter puppeting bridge. // Copyright (C) 2024 Tulir Asokan // // This program is free software: you can redistribute it and/or modify @@ -18,28 +18,124 @@ package connector import ( "context" + "fmt" + "go.mau.fi/mautrix-twitter/pkg/twittermeow" + twitCookies "go.mau.fi/mautrix-twitter/pkg/twittermeow/cookies" "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/database" + "maunium.net/go/mautrix/bridgev2/networkid" ) type TwitterLogin struct { + User *bridgev2.User + Cookies string } var _ bridgev2.LoginProcessCookies = (*TwitterLogin)(nil) func (tc *TwitterConnector) GetLoginFlows() []bridgev2.LoginFlow { - //TODO implement me - panic("implement me") + return []bridgev2.LoginFlow{ + { + Name: "Cookies", + Description: "Log in with your Twitter account using your cookies", + ID: "cookies", + }, + } } func (tc *TwitterConnector) CreateLogin(ctx context.Context, user *bridgev2.User, flowID string) (bridgev2.LoginProcess, error) { - //TODO implement me - panic("implement me") + if flowID != "cookies" { + return nil, fmt.Errorf("unknown login flow ID: %s", flowID) + } + return &TwitterLogin{User: user}, nil } func (t *TwitterLogin) Start(ctx context.Context) (*bridgev2.LoginStep, error) { - //TODO implement me - panic("implement me") + return &bridgev2.LoginStep{ + Type: bridgev2.LoginStepTypeCookies, + StepID: "fi.mau.twitter.login.enter_cookies", + Instructions: "Enter a JSON object with your cookies, or a cURL command copied from browser devtools.\n\nFor example: `{\"ct0\":\"123466-...\",\"auth_token\":\"abcde-...\"}`", + CookiesParams: &bridgev2.LoginCookiesParams{ + URL: "https://x.com", + UserAgent: "", + Fields: []bridgev2.LoginCookieField{ + { + ID: "ct0", + Required: true, + Sources: []bridgev2.LoginCookieFieldSource{ + { Type: bridgev2.LoginCookieTypeCookie, Name: "ct0" }, + }, + }, + { + ID: "auth_token", + Required: true, + Sources: []bridgev2.LoginCookieFieldSource{ + { Type: bridgev2.LoginCookieTypeCookie, Name: "auth_token" }, + }, + }, + /* + { + ID: "guest_id", + Required: false, + Sources: []bridgev2.LoginCookieFieldSource{ + { Type: bridgev2.LoginCookieTypeCookie, Name: "guest_id" }, + }, + }, + { + ID: "twid", + Required: false, + Sources: []bridgev2.LoginCookieFieldSource{ + { Type: bridgev2.LoginCookieTypeCookie, Name: "twid" }, + }, + }, + { + ID: "kdt", + Required: false, + Sources: []bridgev2.LoginCookieFieldSource{ + { Type: bridgev2.LoginCookieTypeCookie, Name: "kdt" }, + }, + }, + { + ID: "night_mode", + Required: false, + Sources: []bridgev2.LoginCookieFieldSource{ + { Type: bridgev2.LoginCookieTypeCookie, Name: "night_mode" }, + }, + }, + { + ID: "personalization_id", + Required: false, + Sources: []bridgev2.LoginCookieFieldSource{ + { Type: bridgev2.LoginCookieTypeCookie, Name: "personalization_id" }, + }, + }, + { + ID: "guest_id_marketing", + Required: false, + Sources: []bridgev2.LoginCookieFieldSource{ + { Type: bridgev2.LoginCookieTypeCookie, Name: "guest_id_marketing" }, + }, + }, + { + ID: "guest_id_ads", + Required: false, + Sources: []bridgev2.LoginCookieFieldSource{ + { Type: bridgev2.LoginCookieTypeCookie, Name: "guest_id_ads" }, + }, + }, + { + ID: "d_prefs", + Required: false, + Sources: []bridgev2.LoginCookieFieldSource{ + { Type: bridgev2.LoginCookieTypeCookie, Name: "d_prefs" }, + }, + }, + }, + */ + }, + }, + }, nil } func (t *TwitterLogin) Cancel() { @@ -48,6 +144,44 @@ func (t *TwitterLogin) Cancel() { } func (t *TwitterLogin) SubmitCookies(ctx context.Context, cookies map[string]string) (*bridgev2.LoginStep, error) { - //TODO implement me - panic("implement me") -} + cookieStruct := twitCookies.NewCookies(cookies) + meta := &UserLoginMetadata{ + Cookies: cookieStruct.String(), + } + + clientOpts := &twittermeow.ClientOpts{ + Cookies: cookieStruct, + WithJOTClient: true, + } + client := twittermeow.NewClient(clientOpts, t.User.Log) + + _, _, err := client.LoadMessagesPage() + if err != nil { + return nil, fmt.Errorf("failed to load messages page after submitting cookies") + } + + id := networkid.UserLoginID(client.GetCurrentUserID()) + ul, err := t.User.NewLogin( + ctx, + &database.UserLogin{ + ID: id, + Metadata: meta, + }, + &bridgev2.NewLoginParams{ + DeleteOnConflict: true, + DontReuseExisting: false, + }, + ) + if err != nil { + return nil, err + } + return &bridgev2.LoginStep{ + Type: bridgev2.LoginStepTypeComplete, + StepID: "fi.mau.twitter.login.complete", + Instructions: fmt.Sprintf("Successfully logged into @%s", ul.UserLogin.RemoteName), + CompleteParams: &bridgev2.LoginCompleteParams{ + UserLoginID: ul.ID, + UserLogin: ul, + }, + }, nil +} \ No newline at end of file diff --git a/pkg/connector/mapping.go b/pkg/connector/mapping.go new file mode 100644 index 0000000..9cb9847 --- /dev/null +++ b/pkg/connector/mapping.go @@ -0,0 +1,312 @@ +package connector + +import ( + "context" + "fmt" + "io" + "net/http" + "go.mau.fi/mautrix-twitter/pkg/twittermeow/data/types" + "go.mau.fi/mautrix-twitter/pkg/twittermeow/methods" + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/database" + "maunium.net/go/mautrix/bridgev2/networkid" + bridgeEvt "maunium.net/go/mautrix/event" +) + +func (tc *TwitterClient) MessagesToBackfillMessages(ctx context.Context, messages []types.Message, conv types.Conversation) ([]*bridgev2.BackfillMessage, error) { + backfilledMessages := make([]*bridgev2.BackfillMessage, 0) + selfUserId := tc.client.GetCurrentUserID() + for _, msg := range messages { + backfilledMessage, err := tc.MessageToBackfillMessage(ctx, msg, conv, selfUserId) + if err != nil { + return nil, err + } + backfilledMessages = append(backfilledMessages, backfilledMessage) + } + + return backfilledMessages, nil +} + +func (tc *TwitterClient) MessageToBackfillMessage(ctx context.Context, message types.Message, conv types.Conversation, selfUserId string) (*bridgev2.BackfillMessage, error) { + messageReactions, err := tc.MessageReactionsToBackfillReactions(message.MessageReactions, selfUserId) + if err != nil { + return nil, err + } + + sentAt, err := methods.UnixStringMilliToTime(message.MessageData.Time) + if err != nil { + return nil, err + } + + partId := networkid.PartID("") + parts := make([]*bridgev2.ConvertedMessagePart, 0) + + textPart := &bridgev2.ConvertedMessagePart{ + ID: partId, + Type: bridgeEvt.EventMessage, + Content: &bridgeEvt.MessageEventContent{ + MsgType: bridgeEvt.MsgText, + Body: message.MessageData.Text, + }, + } + + replyData := message.MessageData.ReplyData + var replyTo *networkid.MessageOptionalPartID + if replyData.ID != "" { + replyTo = &networkid.MessageOptionalPartID{ + MessageID: networkid.MessageID(replyData.ID), + PartID: &partId, + } + } + + if message.MessageData.Attachment != nil { + portal, err := tc.connector.br.GetPortalByKey(ctx, tc.MakePortalKey(conv)) + if err != nil { + return nil, err + } + + convertedAttachmentPart, indices, err := tc.TwitterAttachmentToMatrix(ctx, portal, tc.userLogin.Bridge.Matrix.BotIntent(), message.MessageData.Attachment) + if err != nil { + return nil, err + } + + RemoveEntityLinkFromText(textPart, indices) + parts = append(parts, convertedAttachmentPart) + } + + parts = append(parts, textPart) + return &bridgev2.BackfillMessage{ + ConvertedMessage: &bridgev2.ConvertedMessage{ + ReplyTo: replyTo, + Parts: parts, + }, + Sender: bridgev2.EventSender{ + IsFromMe: message.MessageData.SenderID == selfUserId, + Sender: networkid.UserID(message.MessageData.SenderID), + }, + ID: networkid.MessageID(message.MessageData.ID), + Timestamp: sentAt, + Reactions: messageReactions, + }, nil +} + +// bugged, displays an empty message +func RemoveEntityLinkFromText(msgPart *bridgev2.ConvertedMessagePart, indices []int) { + start, end := indices[0], indices[1] + msgPart.Content.Body = msgPart.Content.Body[:start] + msgPart.Content.Body[end:] +} + +func (tc *TwitterClient) MessageReactionsToBackfillReactions(reactions []types.MessageReaction, selfUserId string) ([]*bridgev2.BackfillReaction, error) { + backfillReactions := make([]*bridgev2.BackfillReaction, 0) + for _, reaction := range reactions { + reactionTime, err := methods.UnixStringMilliToTime(reaction.Time) + if err != nil { + return nil, err + } + + backfillReaction := &bridgev2.BackfillReaction{ + Timestamp: reactionTime, + Sender: bridgev2.EventSender{ + IsFromMe: reaction.SenderID == selfUserId, + Sender: networkid.UserID(reaction.SenderID), + }, + EmojiID: "", + Emoji: reaction.EmojiReaction, + } + backfillReactions = append(backfillReactions, backfillReaction) + } + return backfillReactions, nil +} + +func (tc *TwitterClient) ConversationToChatInfo(conv *types.Conversation) *bridgev2.ChatInfo { + memberList := tc.ParticipantsToMemberList(conv.Participants) + return &bridgev2.ChatInfo{ + Name: &conv.Name, + Avatar: MakeAvatar(conv.AvatarImageHttps), + Members: memberList, + Type: tc.ConversationTypeToRoomType(conv.Type), + CanBackfill: true, + } +} + +func (tc *TwitterClient) ConversationTypeToRoomType(convType types.ConversationType) *database.RoomType { + var roomType database.RoomType + switch convType { + case types.ONE_TO_ONE: + roomType = database.RoomTypeDM + case types.GROUP_DM: + roomType = database.RoomTypeGroupDM + } + + return &roomType +} + +func (tc *TwitterClient) ParticipantsToMemberList(participants []types.Participant) *bridgev2.ChatMemberList { + selfUserId := tc.client.GetCurrentUserID() + chatMembers := make([]bridgev2.ChatMember, len(participants)-1) + for _, participant := range participants { + chatMembers = append(chatMembers, tc.ParticipantToChatMember(participant, participant.UserID == selfUserId)) + } + + return &bridgev2.ChatMemberList{ + IsFull: true, + TotalMemberCount: len(participants), + Members: chatMembers, + } +} + +func (tc *TwitterClient) ParticipantToChatMember(participant types.Participant, isFromMe bool) bridgev2.ChatMember { + return bridgev2.ChatMember{ + EventSender: bridgev2.EventSender{ + IsFromMe: isFromMe, + Sender: networkid.UserID(participant.UserID), + //SenderLogin: networkid.UserLoginID(participant.UserID), + }, + UserInfo: tc.GetUserInfoBridge(participant.UserID), + } +} + +func (tc *TwitterClient) GetUserInfoBridge(userId string) *bridgev2.UserInfo { + var userinfo *bridgev2.UserInfo + if userCacheEntry, ok := tc.userCache[userId]; ok { + userinfo = &bridgev2.UserInfo{ + Name: &userCacheEntry.Name, + Avatar: MakeAvatar(userCacheEntry.ProfileImageURL), + } + } + return userinfo +} + +func (tc *TwitterClient) TwitterAttachmentToMatrix(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI, attachment *types.Attachment) (*bridgev2.ConvertedMessagePart, []int, error) { + var attachmentInfo *types.AttachmentInfo + var attachmentURL string + var mimeType string + var indices []int + var msgType bridgeEvt.MessageType + if attachment.Photo != nil { + // image attachment + attachmentInfo = attachment.Photo + mimeType = "image/jpeg" // attachment doesn't include this specifically + msgType = bridgeEvt.MsgImage + attachmentURL = attachmentInfo.MediaURLHTTPS + indices = attachmentInfo.Indices + } else if attachment.Video != nil { + // video attachment + attachmentInfo = attachment.Video + mimeType = "video/mp4" + msgType = bridgeEvt.MsgVideo + + highestBitRateVariant, err := attachmentInfo.VideoInfo.GetHighestBitrateVariant() + if err != nil { + return nil, nil, err + } + attachmentURL = highestBitRateVariant.URL + indices = attachmentInfo.Indices + } else if attachment.AnimatedGif != nil { + // gif attachment + attachmentInfo = attachment.AnimatedGif + mimeType = "image/gif" + msgType = bridgeEvt.MsgVideo + + highestBitRateVariant, err := attachmentInfo.VideoInfo.GetHighestBitrateVariant() + if err != nil { + return nil, nil, err + } + attachmentURL = highestBitRateVariant.URL + indices = attachmentInfo.Indices + } + + attachmentBytes, err := DownloadPlainFile(ctx, tc.client.GetCookieString(), attachmentURL, "twitter attachment") + if err != nil { + return nil, nil, err + } + + content := convertTwitterAttachmentMetadata(attachmentInfo, mimeType, msgType, attachmentBytes) + err = uploadMedia(ctx, portal, intent, attachmentBytes, &content) + if err != nil { + return nil, nil, err + } + + return &bridgev2.ConvertedMessagePart{ + ID: networkid.PartID(fmt.Sprintf("attachment-%s", attachmentInfo.IDStr)), + Type: bridgeEvt.EventMessage, + Content: &content, + }, indices, nil +} + +func uploadMedia(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI, data []byte, content *bridgeEvt.MessageEventContent) error { + mxc, file, err := intent.UploadMedia(ctx, portal.MXID, data, "", content.Info.MimeType) + if err != nil { + return err + } + if file != nil { + content.File = file + } else { + content.URL = mxc + } + return nil +} + +func convertTwitterAttachmentMetadata(attachmentInfo *types.AttachmentInfo, mimeType string, msgType bridgeEvt.MessageType, attachmentBytes []byte) bridgeEvt.MessageEventContent { + content := bridgeEvt.MessageEventContent{ + Info: &bridgeEvt.FileInfo{ + MimeType: mimeType, + Size: len(attachmentBytes), + }, + MsgType: msgType, + Body: attachmentInfo.IDStr, + } + + + originalInfo := attachmentInfo.OriginalInfo + if originalInfo.Width != 0 { + content.Info.Width = originalInfo.Width + } + if originalInfo.Height != 0 { + content.Info.Height = originalInfo.Height + } + + if attachmentInfo.VideoInfo.DurationMillis != 0 { + content.Info.Duration = attachmentInfo.VideoInfo.DurationMillis + } + + + return content +} + +func MakeAvatar(avatarURL string) *bridgev2.Avatar { + // idk if this check is needed, maybe the Remove field is enough. Change later if it isn't needed + if avatarURL == "" { + return nil + } + return &bridgev2.Avatar{ + ID: networkid.AvatarID(avatarURL), + Get: func(ctx context.Context) ([]byte, error) { + return DownloadPlainFile(ctx, "", avatarURL, "avatar") + }, + Remove: avatarURL == "", + } +} + +func DownloadPlainFile(ctx context.Context, cookies, url, thing string) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("failed to prepare request: %w", err) + } + + if cookies != "" { + req.Header.Add("cookie", cookies) + } + + getResp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to download %s: %w", thing, err) + } + + data, err := io.ReadAll(getResp.Body) + _ = getResp.Body.Close() + if err != nil { + return nil, fmt.Errorf("failed to read %s data: %w", thing, err) + } + return data, nil +} \ No newline at end of file diff --git a/pkg/twittermeow/client.go b/pkg/twittermeow/client.go index 4c9dd31..3dd35f8 100644 --- a/pkg/twittermeow/client.go +++ b/pkg/twittermeow/client.go @@ -2,6 +2,7 @@ package twittermeow import ( "fmt" + "log" "net" "net/http" "net/url" @@ -27,7 +28,7 @@ type ClientOpts struct { EventHandler EventHandler WithJOTClient bool } -type EventHandler func(evt interface{}) +type EventHandler func(evt any) type Client struct { Logger zerolog.Logger cookies *cookies.Cookies @@ -68,7 +69,7 @@ func NewClient(opts *ClientOpts, logger zerolog.Logger) *Client { if opts.Cookies != nil { cli.cookies = opts.Cookies } else { - cli.cookies = cookies.NewCookies() + cli.cookies = cookies.NewCookies(nil) } if opts.Session != nil { @@ -80,6 +81,10 @@ func NewClient(opts *ClientOpts, logger zerolog.Logger) *Client { return &cli } +func (c *Client) GetCookieString() string { + return c.cookies.String() +} + func (c *Client) Connect() error { if c.eventHandler == nil { return ErrConnectPleaseSetEventHandler @@ -136,7 +141,7 @@ func (c *Client) GetCurrentUser() *response.AccountSettingsResponse { func (c *Client) GetCurrentUserID() string { twid := c.cookies.Get(cookies.XTwid) - return strings.Replace(twid, "u%3D", "", -1) + return strings.Replace(strings.Replace(twid, "u%3D", "", -1), "u=", "", -1) } func (c *Client) SetProxy(proxyAddr string) error { @@ -169,7 +174,9 @@ func (c *Client) SetProxy(proxyAddr string) error { } func (c *Client) isLoggedIn() bool { - return !c.cookies.IsCookieEmpty(cookies.XAuthToken) + isLoggedIn := !c.cookies.IsCookieEmpty(cookies.XAuthToken) + log.Println("is logged in:", isLoggedIn) + return isLoggedIn } func (c *Client) isAuthenticated() bool { @@ -222,7 +229,7 @@ func (c *Client) parseMainPageHTML(mainPageResp *http.Response, mainPageHTML str guestToken := methods.ParseGuestToken(mainPageHTML) if guestToken == "" { - if c.cookies.IsCookieEmpty(cookies.XGuestToken) || !c.isLoggedIn() { + if c.cookies.IsCookieEmpty(cookies.XGuestToken) && !c.isLoggedIn() { // most likely means your cookies are invalid / expired return fmt.Errorf("failed to find guest token by regex in redirected html response body (response_body=%s, status_code=%d)", mainPageHTML, mainPageResp.StatusCode) } diff --git a/pkg/twittermeow/client_test.go b/pkg/twittermeow/client_test.go index 62c1c44..8e1e807 100644 --- a/pkg/twittermeow/client_test.go +++ b/pkg/twittermeow/client_test.go @@ -379,7 +379,6 @@ func logAllTrustedConversations(initialInboxData *response.XInboxData) { for i, c := range conversations { conv := c.Conversation - log.Println() mostRecentMessage := c.Messages[0] cli.Logger.Info(). Int("conversation_inbox_position", i). diff --git a/pkg/twittermeow/cookies/cookies.go b/pkg/twittermeow/cookies/cookies.go index c27863a..7e19526 100644 --- a/pkg/twittermeow/cookies/cookies.go +++ b/pkg/twittermeow/cookies/cookies.go @@ -25,19 +25,22 @@ const ( ) type Cookies struct { - store map[XCookieName]string + store map[string]string lock sync.RWMutex } -func NewCookies() *Cookies { +func NewCookies(store map[string]string) *Cookies { + if store == nil { + store = make(map[string]string) + } return &Cookies{ - store: make(map[XCookieName]string), + store: store, lock: sync.RWMutex{}, } } func NewCookiesFromString(cookieStr string) *Cookies { - c := NewCookies() + c := NewCookies(nil) cookieStrings := strings.Split(cookieStr, ";") fakeHeader := http.Header{} for _, cookieStr := range cookieStrings { @@ -49,7 +52,7 @@ func NewCookiesFromString(cookieStr string) *Cookies { fakeResponse := &http.Response{Header: fakeHeader} for _, cookie := range fakeResponse.Cookies() { - c.store[XCookieName(cookie.Name)] = cookie.Value + c.store[cookie.Name] = cookie.Value } return c @@ -72,13 +75,13 @@ func (c *Cookies) IsCookieEmpty(key XCookieName) bool { func (c *Cookies) Get(key XCookieName) string { c.lock.RLock() defer c.lock.RUnlock() - return c.store[key] + return c.store[string(key)] } func (c *Cookies) Set(key XCookieName, value string) { c.lock.Lock() defer c.lock.Unlock() - c.store[key] = value + c.store[string(key)] = value } func (c *Cookies) UpdateFromResponse(r *http.Response) { @@ -86,10 +89,10 @@ func (c *Cookies) UpdateFromResponse(r *http.Response) { defer c.lock.Unlock() for _, cookie := range r.Cookies() { if cookie.MaxAge == 0 || cookie.Expires.Before(time.Now()) { - delete(c.store, XCookieName(cookie.Name)) + delete(c.store, cookie.Name) } else { //log.Println(fmt.Sprintf("updated cookie %s to value %s", cookie.Name, cookie.Value)) - c.store[XCookieName(cookie.Name)] = cookie.Value + c.store[cookie.Name] = cookie.Value } } } diff --git a/pkg/twittermeow/data/endpoints/endpoints.go b/pkg/twittermeow/data/endpoints/endpoints.go index 5f757ee..999edc5 100644 --- a/pkg/twittermeow/data/endpoints/endpoints.go +++ b/pkg/twittermeow/data/endpoints/endpoints.go @@ -27,6 +27,8 @@ const ( PIN_CONVERSATION_URL = BASE_URL + "/i/api/graphql/o0aymgGiJY-53Y52YSUGVA/DMPinnedInboxAppend_Mutation" UNPIN_CONVERSATION_URL = BASE_URL + "/i/api/graphql/_TQxP2Rb0expwVP9ktGrTQ/DMPinnedInboxDelete_Mutation" GET_PINNED_CONVERSATIONS_URL = BASE_URL + "/i/api/graphql/_gBQBgClVuMQb8efxWkbbQ/DMPinnedInboxQuery" + ADD_REACTION_URL = BASE_URL + "/i/api/graphql/VyDyV9pC2oZEj6g52hgnhA/useDMReactionMutationAddMutation" + REMOVE_REACTION_URL = BASE_URL + "/i/api/graphql/bV_Nim3RYHsaJwMkTXJ6ew/useDMReactionMutationRemoveMutation" JOT_CLIENT_EVENT_URL = API_BASE_URL + "/1.1/jot/client_event.json" JOT_CES_P2_URL = API_BASE_URL + "/1.1/jot/ces/p2" diff --git a/pkg/twittermeow/data/payload/form.go b/pkg/twittermeow/data/payload/form.go index b9b335c..8dc0c56 100644 --- a/pkg/twittermeow/data/payload/form.go +++ b/pkg/twittermeow/data/payload/form.go @@ -51,6 +51,7 @@ const ( type DmRequestQuery struct { ActiveConversationId string `url:"active_conversation_id,omitempty"` Cursor string `url:"cursor,omitempty"` + Count int `url:"count,omitempty"` Context ContextInfo `url:"context,omitempty"` MaxID string `url:"max_id,omitempty"` // when fetching messages, this is the message id NSFWFilteringEnabled bool `url:"nsfw_filtering_enabled"` diff --git a/pkg/twittermeow/data/payload/json.go b/pkg/twittermeow/data/payload/json.go index daad40e..17a33a6 100644 --- a/pkg/twittermeow/data/payload/json.go +++ b/pkg/twittermeow/data/payload/json.go @@ -44,3 +44,14 @@ type PinAndUnpinConversationVariables struct { LabelType LabelType `json:"label_type,omitempty"` Label LabelType `json:"label,omitempty"` } + +type ReactionActionPayload struct { + ConversationID string `json:"conversationId"` + MessageID string `json:"messageId"` + ReactionTypes []string `json:"reactionTypes"` + EmojiReactions []string `json:"emojiReactions"` +} + +func (p *ReactionActionPayload) Encode() ([]byte, error) { + return json.Marshal(p) +} \ No newline at end of file diff --git a/pkg/twittermeow/data/response/events.go b/pkg/twittermeow/data/response/events.go index fbbc351..6019460 100644 --- a/pkg/twittermeow/data/response/events.go +++ b/pkg/twittermeow/data/response/events.go @@ -40,7 +40,6 @@ func (data *XInboxData) GetConversationByID(conversationId string) types.Convers } func (data *XInboxData) ToEventEntries() ([]interface{}, error) { - // entries := make([]map[event.XEventType]interface{}, 0) entries := make([]interface{}, 0) if len(data.Entries) <= 0 { return entries, nil @@ -54,6 +53,37 @@ func (data *XInboxData) ToEventEntries() ([]interface{}, error) { return nil, err } switch entryType { + case event.XReactionCreatedEvent, event.XReactionDeletedEvent: + var reactionEventData types.MessageReaction + err = json.Unmarshal(jsonEvData, &reactionEventData) + if err != nil { + return nil, err + } + + reactionActionAt, err := methods.UnixStringMilliToTime(reactionEventData.Time) + if err != nil { + return nil, err + } + + updatedReactionEventData := event.XEventReaction{ + Conversation: data.GetConversationByID(reactionEventData.ConversationID), + Time: reactionActionAt, + ID: reactionEventData.ID, + ReactionKey: reactionEventData.ReactionKey, + EmojiReaction: reactionEventData.EmojiReaction, + AffectsSort: reactionEventData.AffectsSort, + SenderID: reactionEventData.SenderID, + MessageID: reactionEventData.MessageID, + } + switch entryType { + case event.XReactionCreatedEvent: + updatedReactionEventData.Action = types.MessageReactionAdd + case event.XReactionDeletedEvent: + updatedReactionEventData.Action = types.MessageReactionRemove + default: + break + } + updatedEntry = updatedReactionEventData case event.XMessageEvent: var messageEventData types.Message err = json.Unmarshal(jsonEvData, &messageEventData) @@ -73,9 +103,11 @@ func (data *XInboxData) ToEventEntries() ([]interface{}, error) { MessageID: messageEventData.MessageData.ID, CreatedAt: createdAt, Text: messageEventData.MessageData.Text, - Entities: messageEventData.MessageData.Entities, + Entities: &messageEventData.MessageData.Entities, Attachment: messageEventData.MessageData.Attachment, + ReplyData: messageEventData.MessageData.ReplyData, AffectsSort: messageEventData.AffectsSort, + Reactions: messageEventData.MessageReactions, } case event.XMessageDeleteEvent: var messageDeletedEventData types.MessageDeleted diff --git a/pkg/twittermeow/data/response/inbox.go b/pkg/twittermeow/data/response/inbox.go index 0ab91ca..6d5e8dc 100644 --- a/pkg/twittermeow/data/response/inbox.go +++ b/pkg/twittermeow/data/response/inbox.go @@ -61,8 +61,9 @@ type PrettifiedMessage struct { SentAt time.Time AffectsSort bool Text string - Attachment types.Attachment + Attachment *types.Attachment Entities types.Entities + Reactions []types.MessageReaction } func (data *XInboxData) PrettifyMessages(conversationId string) ([]PrettifiedMessage, error) { @@ -89,6 +90,7 @@ func (data *XInboxData) PrettifyMessages(conversationId string) ([]PrettifiedMes Attachment: msg.MessageData.Attachment, Entities: msg.MessageData.Entities, AffectsSort: msg.AffectsSort, + Reactions: msg.MessageReactions, } prettifiedMessages = append(prettifiedMessages, prettifiedMessage) } @@ -96,7 +98,7 @@ func (data *XInboxData) PrettifyMessages(conversationId string) ([]PrettifiedMes return prettifiedMessages, nil } -func (data *XInboxData) GetParticipantUsers(participants []types.Participants) []types.User { +func (data *XInboxData) GetParticipantUsers(participants []types.Participant) []types.User { result := make([]types.User, 0) for _, participant := range participants { result = append(result, data.GetUserByID(participant.UserID)) @@ -129,5 +131,6 @@ func (data *XInboxData) GetMessageEntriesByConversationID(conversationId string, if sortByTimestamp { methods.SortMessagesByTime(messages) } + return messages, nil } diff --git a/pkg/twittermeow/data/response/messaging.go b/pkg/twittermeow/data/response/messaging.go index ee0cbb2..53cd4ff 100644 --- a/pkg/twittermeow/data/response/messaging.go +++ b/pkg/twittermeow/data/response/messaging.go @@ -64,3 +64,14 @@ type UnpinConversationResponse struct { DmConversationLabelDelete string `json:"dm_conversation_label_delete,omitempty"` } `json:"data,omitempty"` } + +type ReactionResponse struct { + Data struct { + DeleteDmReaction struct { + Typename string `json:"__typename,omitempty"` + } `json:"delete_dm_reaction,omitempty"` + CreateDmReaction struct { + Typename string `json:"__typename,omitempty"` + } `json:"create_dm_reaction,omitempty"` + } `json:"data,omitempty"` +} \ No newline at end of file diff --git a/pkg/twittermeow/data/types/entities.go b/pkg/twittermeow/data/types/entities.go index 2d6b1c3..a4c9878 100644 --- a/pkg/twittermeow/data/types/entities.go +++ b/pkg/twittermeow/data/types/entities.go @@ -1,5 +1,12 @@ package types +import "fmt" + +type Attachment struct { + Video *AttachmentInfo `json:"video,omitempty"` + AnimatedGif *AttachmentInfo `json:"animated_gif,omitempty"` + Photo *AttachmentInfo `json:"photo,omitempty"` +} type Urls struct { URL string `json:"url,omitempty"` ExpandedURL string `json:"expanded_url,omitempty"` @@ -13,8 +20,9 @@ type Entities struct { Urls []Urls `json:"urls,omitempty"` } type OriginalInfo struct { - Width int `json:"width,omitempty"` - Height int `json:"height,omitempty"` + URL string `json:"url,omitempty"` + Width int `json:"width,omitempty"` + Height int `json:"height,omitempty"` } type Thumb struct { W int `json:"w,omitempty"` @@ -42,15 +50,32 @@ type Sizes struct { Large Large `json:"large,omitempty"` Medium Medium `json:"medium,omitempty"` } -type Variants struct { +type Variant struct { Bitrate int `json:"bitrate,omitempty"` ContentType string `json:"content_type,omitempty"` URL string `json:"url,omitempty"` } type VideoInfo struct { - AspectRatio []int `json:"aspect_ratio,omitempty"` - Variants []Variants `json:"variants,omitempty"` + AspectRatio []int `json:"aspect_ratio,omitempty"` + DurationMillis int `json:"duration_millis,omitempty"` + Variants []Variant `json:"variants,omitempty"` +} + +func (v *VideoInfo) GetHighestBitrateVariant() (Variant, error) { + if len(v.Variants) == 0 { + return Variant{}, fmt.Errorf("no variants available") + } + + maxVariant := v.Variants[0] + for _, variant := range v.Variants[1:] { + if variant.Bitrate > maxVariant.Bitrate { + maxVariant = variant + } + } + + return maxVariant, nil } + type Features struct { } type Rgb struct { @@ -71,12 +96,13 @@ type MediaStats struct { } type Ok struct { Palette []Palette `json:"palette,omitempty"` + ViewCount string `json:"view_count,omitempty"` } type R struct { - Ok Ok `json:"ok,omitempty"` + Ok any `json:"ok,omitempty"` } type MediaColor struct { - R R `json:"r,omitempty"` + R any `json:"r,omitempty"` TTL int `json:"ttl,omitempty"` } type AltTextR struct { @@ -84,15 +110,16 @@ type AltTextR struct { } type AltText struct { // this is weird, it can be both string or AltTextR struct object - R interface{} `json:"r,omitempty"` + R any `json:"r,omitempty"` TTL int `json:"ttl,omitempty"` } +// different for video/image/gif type Ext struct { - MediaStats MediaStats `json:"mediaStats,omitempty"` + MediaStats any `json:"mediaStats,omitempty"` MediaColor MediaColor `json:"mediaColor,omitempty"` AltText AltText `json:"altText,omitempty"` } -type AnimatedGif struct { +type AttachmentInfo struct { ID int64 `json:"id,omitempty"` IDStr string `json:"id_str,omitempty"` Indices []int `json:"indices,omitempty"` @@ -111,6 +138,3 @@ type AnimatedGif struct { Ext Ext `json:"ext,omitempty"` AudioOnly bool `json:"audio_only,omitempty"` } -type Attachment struct { - AnimatedGif AnimatedGif `json:"animated_gif,omitempty"` -} diff --git a/pkg/twittermeow/data/types/messaging.go b/pkg/twittermeow/data/types/messaging.go index 02b49fc..a2caae1 100644 --- a/pkg/twittermeow/data/types/messaging.go +++ b/pkg/twittermeow/data/types/messaging.go @@ -7,16 +7,43 @@ type MessageData struct { SenderID string `json:"sender_id,omitempty"` Text string `json:"text,omitempty"` Entities Entities `json:"entities,omitempty"` - Attachment Attachment `json:"attachment,omitempty"` + Attachment *Attachment `json:"attachment,omitempty"` + ReplyData ReplyData `json:"reply_data,omitempty"` } type Message struct { - ID string `json:"id,omitempty"` - Time string `json:"time,omitempty"` - AffectsSort bool `json:"affects_sort,omitempty"` - RequestID string `json:"request_id,omitempty"` - ConversationID string `json:"conversation_id,omitempty"` - MessageData MessageData `json:"message_data,omitempty"` + ID string `json:"id,omitempty"` + Time string `json:"time,omitempty"` + AffectsSort bool `json:"affects_sort,omitempty"` + RequestID string `json:"request_id,omitempty"` + ConversationID string `json:"conversation_id,omitempty"` + MessageData MessageData `json:"message_data,omitempty"` + MessageReactions []MessageReaction `json:"message_reactions,omitempty"` +} + +type ReplyData struct { + ID string `json:"id,omitempty"` + Time string `json:"time,omitempty"` + RecipientID string `json:"recipient_id,omitempty"` + SenderID string `json:"sender_id,omitempty"` + Text string `json:"text,omitempty"` +} + +type MessageReactionAction string +const ( + MessageReactionAdd MessageReactionAction = "reaction_add" + MessageReactionRemove MessageReactionAction = "reaction_remove" +) + +type MessageReaction struct { + ID string `json:"id,omitempty"` + Time string `json:"time,omitempty"` + ConversationID string `json:"conversation_id,omitempty"` + MessageID string `json:"message_id,omitempty"` + ReactionKey string `json:"reaction_key,omitempty"` + EmojiReaction string `json:"emoji_reaction,omitempty"` + SenderID string `json:"sender_id,omitempty"` + AffectsSort bool `json:"affects_sort,omitempty"` } type ConversationRead struct { @@ -59,11 +86,14 @@ const ( type Conversation struct { ConversationID string `json:"conversation_id,omitempty"` Type ConversationType `json:"type,omitempty"` + Name string `json:"name,omitempty"` + AvatarImageHttps string `json:"avatar_image_https,omitempty"` + Avatar Avatar `json:"avatar,omitempty"` SortEventID string `json:"sort_event_id,omitempty"` SortTimestamp string `json:"sort_timestamp,omitempty"` CreateTime string `json:"create_time,omitempty"` CreatedByUserID string `json:"created_by_user_id,omitempty"` - Participants []Participants `json:"participants,omitempty"` + Participants []Participant `json:"participants,omitempty"` Nsfw bool `json:"nsfw,omitempty"` NotificationsDisabled bool `json:"notifications_disabled,omitempty"` MentionNotificationsDisabled bool `json:"mention_notifications_disabled,omitempty"` @@ -71,12 +101,21 @@ type Conversation struct { ReadOnly bool `json:"read_only,omitempty"` Trusted bool `json:"trusted,omitempty"` Muted bool `json:"muted,omitempty"` + LowQuality bool `json:"low_quality,omitempty"` Status PaginationStatus `json:"status,omitempty"` MinEntryID string `json:"min_entry_id,omitempty"` MaxEntryID string `json:"max_entry_id,omitempty"` } -type Participants struct { +type Image struct { + OriginalInfo OriginalInfo `json:"original_info,omitempty"` +} + +type Avatar struct { + Image Image `json:"image,omitempty"` +} + +type Participant struct { UserID string `json:"user_id,omitempty"` LastReadEventID string `json:"last_read_event_id,omitempty"` IsAdmin bool `json:"is_admin,omitempty"` diff --git a/pkg/twittermeow/event/event.go b/pkg/twittermeow/event/event.go index eff5727..e18630c 100644 --- a/pkg/twittermeow/event/event.go +++ b/pkg/twittermeow/event/event.go @@ -2,7 +2,6 @@ package event import ( "time" - "go.mau.fi/mautrix-twitter/pkg/twittermeow/data/types" ) @@ -11,6 +10,8 @@ type XEventType string const ( XMessageEvent XEventType = "message" XMessageDeleteEvent XEventType = "message_delete" + XReactionCreatedEvent XEventType = "reaction_create" + XReactionDeletedEvent XEventType = "reaction_delete" XConversationReadEvent XEventType = "conversation_read" XConversationMetadataUpdateEvent XEventType = "conversation_metadata_update" XConversationCreateEvent XEventType = "conversation_create" @@ -25,21 +26,23 @@ type XEventMessage struct { Text string CreatedAt time.Time AffectsSort bool - Entities types.Entities - Attachment types.Attachment + Entities *types.Entities + Attachment *types.Attachment + ReplyData types.ReplyData + Reactions []types.MessageReaction } type XEventConversationRead struct { - EventID string Conversation types.Conversation + EventID string ReadAt time.Time AffectsSort bool LastReadEventID string } type XEventConversationCreated struct { - EventID string Conversation types.Conversation + EventID string CreatedAt time.Time AffectsSort bool RequestID string @@ -60,3 +63,16 @@ type XEventMessageDeleted struct { AffectsSort bool Messages []types.MessagesDeleted } + +type XEventReaction struct { + Conversation types.Conversation + Action types.MessageReactionAction + ID string + Time time.Time + MessageID string + ReactionKey string + EmojiReaction string + SenderID string + RecipientID string // empty for group chats + AffectsSort bool +} \ No newline at end of file diff --git a/pkg/twittermeow/media.go b/pkg/twittermeow/media.go index 1fc2069..b58f738 100644 --- a/pkg/twittermeow/media.go +++ b/pkg/twittermeow/media.go @@ -6,12 +6,10 @@ import ( "encoding/hex" "encoding/json" "fmt" - "log" "mime/multipart" "net/http" "net/textproto" "time" - "go.mau.fi/mautrix-twitter/pkg/twittermeow/data/endpoints" "go.mau.fi/mautrix-twitter/pkg/twittermeow/data/payload" "go.mau.fi/mautrix-twitter/pkg/twittermeow/data/response" @@ -88,7 +86,6 @@ func (c *Client) UploadMedia(params *payload.UploadMediaQuery, mediaBytes []byte } url = fmt.Sprintf("%s?%s", endpoints.UPLOAD_MEDIA_URL, string(encodedQuery)) - log.Println("url", url) headers.Del("content-type") resp, respBody, err = c.MakeRequest(url, "POST", headers, nil, types.NONE) if err != nil { diff --git a/pkg/twittermeow/messaging.go b/pkg/twittermeow/messaging.go index e93fc7f..4a89717 100644 --- a/pkg/twittermeow/messaging.go +++ b/pkg/twittermeow/messaging.go @@ -280,3 +280,37 @@ func (c *Client) UnpinConversation(conversationId string) (*response.UnpinConver data := response.UnpinConversationResponse{} return &data, json.Unmarshal(respBody, &data) } + +func (c *Client) React(reactionPayload *payload.ReactionActionPayload, remove bool) (*response.ReactionResponse, error) { + graphQlPayload := payload.GraphQLPayload{ + Variables: reactionPayload, + QueryID: "VyDyV9pC2oZEj6g52hgnhA", + } + + url := endpoints.ADD_REACTION_URL + if remove { + url = endpoints.REMOVE_REACTION_URL + graphQlPayload.QueryID = "bV_Nim3RYHsaJwMkTXJ6ew" + } + + jsonBody, err := graphQlPayload.Encode() + if err != nil { + return nil, err + } + + apiRequestOpts := apiRequestOpts{ + Url: url, + Method: "POST", + WithClientUUID: true, + Body: jsonBody, + Origin: endpoints.BASE_URL, + ContentType: types.JSON, + } + _, respBody, err := c.makeAPIRequest(apiRequestOpts) + if err != nil { + return nil, err + } + + data := response.ReactionResponse{} + return &data, json.Unmarshal(respBody, &data) +} diff --git a/pkg/twittermeow/methods/methods.go b/pkg/twittermeow/methods/methods.go index 90acffc..351d458 100644 --- a/pkg/twittermeow/methods/methods.go +++ b/pkg/twittermeow/methods/methods.go @@ -60,7 +60,7 @@ func SortMessagesByTime(messages []types.Message) { return errI == nil } - return timeI < timeJ + return timeJ < timeI }) } diff --git a/pkg/twittermeow/session_loader.go b/pkg/twittermeow/session_loader.go index d27cec1..c4e4e43 100644 --- a/pkg/twittermeow/session_loader.go +++ b/pkg/twittermeow/session_loader.go @@ -3,6 +3,7 @@ package twittermeow import ( "errors" "fmt" + "log" neturl "net/url" "go.mau.fi/mautrix-twitter/pkg/twittermeow/cookies" @@ -79,6 +80,7 @@ func (s *SessionLoader) LoadPage(url string) error { mainPageHTML := string(mainPageRespBody) migrationUrl, migrationRequired := methods.ParseMigrateURL(mainPageHTML) if migrationRequired { + log.Println("migration is required...") extraHeaders = map[string]string{ "upgrade-insecure-requests": "1", "sec-fetch-site": "cross-site",