diff --git a/Package.swift b/Package.swift index f153956a..221840bc 100644 --- a/Package.swift +++ b/Package.swift @@ -35,6 +35,11 @@ var products: [Product] = [ name: "IMessage", targets: ["IMessage"] ), + .library( + name: "IMessageBridgeKit", + type: .dynamic, + targets: ["IMessageBridgeKit"] + ), .executable(name: "imessage-cli", targets: ["IMessageCLI"]), .executable(name: "IMDatabaseTestBench", targets: ["IMDatabaseTestBench"]), ] @@ -102,6 +107,15 @@ var targets: [Target] = [ ], path: "src/IMessage/Sources/IMessage" ), + .target( + name: "IMessageBridgeKit", + dependencies: [ + "IMessage", + "IMessageCore", + "PlatformSDK", + ], + path: "src/IMessage/Sources/IMessageBridgeKit" + ), .target( name: "IMessagePrivateSPI", path: "src/IMessage/Sources/IMessagePrivateSPI", diff --git a/bridge-readme.md b/bridge-readme.md new file mode 100644 index 00000000..c9c1d81d --- /dev/null +++ b/bridge-readme.md @@ -0,0 +1,20 @@ +# mautrix-imessage bridgev2 + +This repo includes a bridgev2 Matrix bridge entrypoint at `cmd/mautrix-imessage`. + +The bridge is integrated with the Swift iMessage runtime through `IMessageBridgeKit`, a Swift dynamic library with a C ABI. It does not shell out to `imessage-cli`. + +## Build + +```sh +swift build --product IMessageBridgeKit +swift_bin="$(swift build --show-bin-path)" +CGO_LDFLAGS="-L${swift_bin} -Wl,-rpath,${swift_bin}" \ + go build -tags nocrypto ./cmd/mautrix-imessage +``` + +The `nocrypto` tag avoids linking mautrix's optional libolm dependency while still keeping cgo enabled for the Swift bridge library. + +The bridge currently supports local-login, startup chat/message sync, live state-sync events, Matrix text/file sends, replies, edits, unsend, reactions, read receipts, and typing. + +It must run on macOS with the usual platform-imessage permissions for Messages data and UI automation. diff --git a/cmd/mautrix-imessage/main.go b/cmd/mautrix-imessage/main.go new file mode 100644 index 00000000..79b2ce5b --- /dev/null +++ b/cmd/mautrix-imessage/main.go @@ -0,0 +1,26 @@ +package main + +import ( + "maunium.net/go/mautrix/bridgev2/matrix/mxmain" + + "github.com/beeper/platform-imessage/pkg/connector" +) + +var ( + Tag = "unknown" + Commit = "unknown" + BuildTime = "unknown" +) + +var m = mxmain.BridgeMain{ + Name: "mautrix-imessage", + URL: "https://github.com/beeper/platform-imessage", + Description: "A Matrix-iMessage bridge using platform-imessage and mautrix bridgev2.", + Version: "0.1.0", + Connector: &connector.Connector{}, +} + +func main() { + m.InitVersion(Tag, Commit, BuildTime) + m.Run() +} diff --git a/go.mod b/go.mod new file mode 100644 index 00000000..4eb85535 --- /dev/null +++ b/go.mod @@ -0,0 +1,39 @@ +module github.com/beeper/platform-imessage + +go 1.25.0 + +toolchain go1.26.2 + +require ( + github.com/rs/zerolog v1.35.1 + go.mau.fi/util v0.9.9-0.20260511124621-9241e81bdf25 + maunium.net/go/mautrix v0.27.1-0.20260513120123-5fba7e3afae4 +) + +require ( + filippo.io/edwards25519 v1.2.0 // indirect + github.com/coder/websocket v1.8.14 // indirect + github.com/coreos/go-systemd/v22 v22.7.0 // indirect + github.com/lib/pq v1.12.3 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-sqlite3 v1.14.44 // indirect + github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 // indirect + github.com/rs/xid v1.6.0 // indirect + github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect + github.com/tidwall/gjson v1.18.0 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + github.com/tidwall/sjson v1.2.5 // indirect + github.com/yuin/goldmark v1.8.2 // indirect + go.mau.fi/zeroconfig v0.2.0 // indirect + golang.org/x/crypto v0.50.0 // indirect + golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect + golang.org/x/net v0.53.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.43.0 // indirect + golang.org/x/text v0.36.0 // indirect + gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + maunium.net/go/mauflag v1.0.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 00000000..54347cfe --- /dev/null +++ b/go.sum @@ -0,0 +1,69 @@ +filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= +filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= +github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= +github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= +github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= +github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= +github.com/coreos/go-systemd/v22 v22.7.0 h1:LAEzFkke61DFROc7zNLX/WA2i5J8gYqe0rSj9KI28KA= +github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ= +github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8= +github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= +github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 h1:WDsQxOJDy0N1VRAjXLpi8sCEZRSGarLWQevDxpTBRrM= +github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI= +github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw= +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.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/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/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE= +github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +go.mau.fi/util v0.9.9-0.20260511124621-9241e81bdf25 h1:YPEmc+li7TF6C9AdRTcSLMb6yCHdF27/wNT7kFLIVNg= +go.mau.fi/util v0.9.9-0.20260511124621-9241e81bdf25/go.mod h1:jE9FfhbgEgAwxei6lomO9v8zdCIATcquONUu4vjRwSs= +go.mau.fi/zeroconfig v0.2.0 h1:e/OGEERqVRRKlgaro7E6bh8xXiKFSXB3eNNIud7FUjU= +go.mau.fi/zeroconfig v0.2.0/go.mod h1:J0Vn0prHNOm493oZoQ84kq83ZaNCYZnq+noI1b1eN8w= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= +golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= +golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= +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/mauflag v1.0.0 h1:YiaRc0tEI3toYtJMRIfjP+jklH45uDHtT80nUamyD4M= +maunium.net/go/mauflag v1.0.0/go.mod h1:nLivPOpTpHnpzEh8jEdSL9UqO9+/KBJFmNRlwKfkPeA= +maunium.net/go/mautrix v0.27.1-0.20260513120123-5fba7e3afae4 h1:zNC9eVAhw8FhKpM3AxNAh/iy75UEYX91uJUvqqAYlvo= +maunium.net/go/mautrix v0.27.1-0.20260513120123-5fba7e3afae4/go.mod h1:3sOGhXi3P1V6/NruTA0gujkvTypXVUraWktCuTGyDuM= diff --git a/mautrix-imessage b/mautrix-imessage new file mode 100755 index 00000000..16e09769 Binary files /dev/null and b/mautrix-imessage differ diff --git a/pkg/connector/backfill.go b/pkg/connector/backfill.go new file mode 100644 index 00000000..3a20f17d --- /dev/null +++ b/pkg/connector/backfill.go @@ -0,0 +1,131 @@ +package connector + +import ( + "context" + "strings" + + "github.com/beeper/platform-imessage/pkg/imessage" + "github.com/beeper/platform-imessage/pkg/imessageid" + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/database" + "maunium.net/go/mautrix/bridgev2/networkid" +) + +var _ bridgev2.BackfillingNetworkAPI = (*Client)(nil) + +func (c *Client) FetchMessages(ctx context.Context, params bridgev2.FetchMessagesParams) (*bridgev2.FetchMessagesResponse, error) { + threadID := string(params.Portal.ID) + pagination := backfillPagination(params) + page, err := c.IM.Messages(threadID, pagination) + if err != nil { + return nil, err + } + + messages := make([]*bridgev2.BackfillMessage, 0, len(page.Items)) + for _, msg := range page.Items { + if msg.ThreadID == "" { + msg.ThreadID = threadID + } + converted, err := c.convertMessageFromIMessage(ctx, params.Portal, c.Main.Bridge.Bot, msg) + if err != nil { + return nil, err + } + reactions := make([]*bridgev2.BackfillReaction, 0, len(msg.Reactions)) + for _, reaction := range msg.Reactions { + reactions = append(reactions, backfillReactionFromIMessage(reaction)) + } + messages = append(messages, &bridgev2.BackfillMessage{ + ConvertedMessage: converted, + Sender: c.sender(msg), + ID: imessageid.MakeMessageID(msg.ID), + TxnID: networkid.TransactionID(msg.ID), + Timestamp: messageTimestamp(msg), + StreamOrder: msg.Timestamp, + Reactions: reactions, + }) + } + + return &bridgev2.FetchMessagesResponse{ + Messages: messages, + Cursor: networkid.PaginationCursor(nextBackfillCursor(page, params.Forward)), + HasMore: page.HasMore, + Forward: params.Forward, + }, nil +} + +func backfillPagination(params bridgev2.FetchMessagesParams) *imessage.Pagination { + cursor := string(params.Cursor) + if cursor != "" && !isIMessagePaginationCursor(cursor) { + if anchorCursor := cursorFromMessage(params.AnchorMessage); isIMessagePaginationCursor(anchorCursor) { + cursor = anchorCursor + } + } + if cursor == "" { + cursor = cursorFromMessage(params.AnchorMessage) + } + if cursor == "" { + return nil + } + direction := "before" + if params.Forward { + direction = "after" + } + limit := params.Count + if limit < 0 { + limit = 0 + } + return &imessage.Pagination{ + Cursor: cursor, + Direction: direction, + Limit: limit, + } +} + +func cursorFromMessage(msg *database.Message) string { + if msg == nil { + return "" + } + if meta, ok := msg.Metadata.(*imessageid.MessageMetadata); ok && meta.Cursor != "" { + return meta.Cursor + } + return string(msg.ID) +} + +func isIMessagePaginationCursor(cursor string) bool { + cursor = strings.TrimSpace(cursor) + if cursor == "" { + return false + } + for _, ch := range cursor { + if ch < '0' || ch > '9' { + return false + } + } + return true +} + +func nextBackfillCursor(page *imessage.Page[imessage.Message], forward bool) string { + if page == nil { + return "" + } + if forward && page.NewestCursor != "" { + return page.NewestCursor + } + if !forward && page.OldestCursor != "" { + return page.OldestCursor + } + if len(page.Items) == 0 { + return "" + } + if forward { + return messagePaginationCursor(page.Items[len(page.Items)-1]) + } + return messagePaginationCursor(page.Items[0]) +} + +func messagePaginationCursor(msg imessage.Message) string { + if msg.Cursor != "" { + return msg.Cursor + } + return msg.ID +} diff --git a/pkg/connector/capabilities.go b/pkg/connector/capabilities.go new file mode 100644 index 00000000..dada9680 --- /dev/null +++ b/pkg/connector/capabilities.go @@ -0,0 +1,123 @@ +package connector + +import ( + "context" + "time" + + "go.mau.fi/util/jsontime" + "go.mau.fi/util/ptr" + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/event" +) + +const maxTextLength = 200_000 +const maxFileSize = 100 * 1024 * 1024 + +var supportedIMessageReactions = []string{"❤️", "👍", "👎", "HAHA", "‼️", "❓"} + +var generalCaps = &bridgev2.NetworkGeneralCapabilities{ + ImplicitReadReceipts: false, + Provisioning: bridgev2.ProvisioningCapabilities{ + ResolveIdentifier: bridgev2.ResolveIdentifierCapabilities{ + CreateDM: true, + LookupEmail: true, + AnyPhone: true, + ContactList: true, + Search: true, + }, + GroupCreation: map[string]bridgev2.GroupTypeCapabilities{ + "group": { + TypeDescription: "iMessage group", + Name: bridgev2.GroupFieldCapability{ + Allowed: true, + }, + Participants: bridgev2.GroupFieldCapability{ + Allowed: true, + Required: true, + MinLength: 2, + }, + }, + }, + }, +} + +func (c *Connector) GetCapabilities() *bridgev2.NetworkGeneralCapabilities { + return generalCaps +} + +func (c *Connector) GetBridgeInfoVersion() (info, capabilities int) { + return 1, 2 +} + +var roomCaps = &event.RoomFeatures{ + ID: "com.beeper.imessage.capabilities.2026_06_05", + Formatting: map[event.FormattingFeature]event.CapabilitySupportLevel{ + event.FmtBold: event.CapLevelDropped, + event.FmtItalic: event.CapLevelDropped, + event.FmtStrikethrough: event.CapLevelDropped, + event.FmtInlineLink: event.CapLevelDropped, + event.FmtUserLink: event.CapLevelDropped, + }, + File: map[event.CapabilityMsgType]*event.FileFeatures{ + event.MsgImage: { + MimeTypes: map[string]event.CapabilitySupportLevel{ + "image/jpeg": event.CapLevelFullySupported, + "image/png": event.CapLevelFullySupported, + "image/gif": event.CapLevelFullySupported, + "image/webp": event.CapLevelFullySupported, + }, + Caption: event.CapLevelDropped, + MaxSize: maxFileSize, + }, + event.MsgVideo: { + MimeTypes: map[string]event.CapabilitySupportLevel{ + "video/mp4": event.CapLevelFullySupported, + "video/webm": event.CapLevelFullySupported, + "video/ogg": event.CapLevelFullySupported, + }, + Caption: event.CapLevelDropped, + MaxSize: maxFileSize, + }, + event.MsgAudio: { + MimeTypes: map[string]event.CapabilitySupportLevel{ + "audio/mpeg": event.CapLevelFullySupported, + "audio/mp4": event.CapLevelFullySupported, + "audio/ogg": event.CapLevelFullySupported, + "audio/wav": event.CapLevelFullySupported, + "audio/webm": event.CapLevelFullySupported, + "audio/aac": event.CapLevelFullySupported, + }, + Caption: event.CapLevelDropped, + MaxSize: maxFileSize, + }, + event.MsgFile: { + MimeTypes: map[string]event.CapabilitySupportLevel{"*/*": event.CapLevelFullySupported}, + Caption: event.CapLevelDropped, + MaxSize: maxFileSize, + }, + }, + MaxTextLength: maxTextLength, + Reply: event.CapLevelFullySupported, + Edit: event.CapLevelFullySupported, + EditMaxAge: ptr.Ptr(jsontime.S(15 * time.Minute)), + Delete: event.CapLevelFullySupported, + DeleteForMe: false, + DeleteMaxAge: ptr.Ptr(jsontime.S(2 * time.Minute)), + Reaction: event.CapLevelFullySupported, + ReactionCount: 1, + AllowedReactions: supportedIMessageReactions, + CustomEmojiReactions: false, + ReadReceipts: true, + TypingNotifications: true, + MarkAsUnread: true, + DeleteChat: true, +} + +func (c *Client) GetCapabilities(ctx context.Context, portal *bridgev2.Portal) *event.RoomFeatures { + caps := roomCaps.Clone() + if portal != nil && len(recipientsFromThreadID(string(portal.ID))) > 0 { + caps.ID = "com.beeper.imessage.capabilities.2026_06_05.synthetic" + caps.File = nil + } + return caps +} diff --git a/pkg/connector/chatinfo.go b/pkg/connector/chatinfo.go new file mode 100644 index 00000000..f9595d34 --- /dev/null +++ b/pkg/connector/chatinfo.go @@ -0,0 +1,444 @@ +package connector + +import ( + "context" + "sort" + "strings" + + "github.com/beeper/platform-imessage/pkg/imessage" + "github.com/beeper/platform-imessage/pkg/imessageid" + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/database" + "maunium.net/go/mautrix/bridgev2/networkid" + "maunium.net/go/mautrix/event" +) + +var ( + _ bridgev2.IdentifierResolvingNetworkAPI = (*Client)(nil) + _ bridgev2.ContactListingNetworkAPI = (*Client)(nil) + _ bridgev2.UserSearchingNetworkAPI = (*Client)(nil) + _ bridgev2.GhostDMCreatingNetworkAPI = (*Client)(nil) + _ bridgev2.GroupCreatingNetworkAPI = (*Client)(nil) + _ bridgev2.IdentifierValidatingNetwork = (*Connector)(nil) +) + +func (c *Connector) ValidateUserID(id networkid.UserID) bool { + return validIMessageIdentifier(string(id)) +} + +func (c *Client) GetChatInfo(ctx context.Context, portal *bridgev2.Portal) (*bridgev2.ChatInfo, error) { + thread, err := c.IM.Chat(string(portal.ID)) + if err != nil { + return nil, err + } + if thread == nil { + return &bridgev2.ChatInfo{}, nil + } + return c.chatInfoFromThread(*thread), nil +} + +func (c *Client) GetUserInfo(ctx context.Context, ghost *bridgev2.Ghost) (*bridgev2.UserInfo, error) { + return c.userInfoFromUser(imessage.User{ID: string(ghost.ID)}), nil +} + +func (c *Client) ResolveIdentifier(ctx context.Context, identifier string, createChat bool) (*bridgev2.ResolveIdentifierResponse, error) { + identifier = normalizeIMessageIdentifier(identifier) + if !validIMessageIdentifier(identifier) { + return nil, matrixUnsupported("invalid iMessage identifier") + } + user := imessage.User{ID: identifier, Username: identifier} + resp := &bridgev2.ResolveIdentifierResponse{ + UserID: imessageid.MakeUserID(identifier), + UserInfo: c.userInfoFromUser(user), + } + if createChat { + resp.Chat = c.syntheticChatResponse([]string{identifier}, "") + } + return resp, nil +} + +func (c *Client) CreateChatWithGhost(ctx context.Context, ghost *bridgev2.Ghost) (*bridgev2.CreateChatResponse, error) { + if ghost == nil || !validIMessageIdentifier(string(ghost.ID)) { + return nil, matrixUnsupported("invalid iMessage user ID") + } + return c.syntheticChatResponse([]string{string(ghost.ID)}, ""), nil +} + +func (c *Client) CreateGroup(ctx context.Context, params *bridgev2.GroupCreateParams) (*bridgev2.CreateChatResponse, error) { + if params == nil { + return nil, matrixUnsupported("missing iMessage group parameters") + } + selfIdentifiers := c.currentUserIdentifiers() + participants := make([]string, 0, len(params.Participants)) + for _, participant := range params.Participants { + participant := normalizeIMessageIdentifier(string(participant)) + if selfIdentifiers[participant] { + continue + } + if !validIMessageIdentifier(participant) { + return nil, matrixUnsupported("invalid iMessage group participant") + } + participants = append(participants, participant) + } + if len(participants) < 2 { + return nil, matrixUnsupported("iMessage groups need at least two recipients") + } + name := "" + if params.Name != nil { + name = strings.TrimSpace(params.Name.Name) + } + existingThread, err := c.existingThreadWithParticipants(participants) + if err != nil { + return nil, err + } + if existingThread != nil { + return c.chatResponseFromThread(*existingThread), nil + } + return c.syntheticChatResponse(participants, name), nil +} + +func (c *Client) currentUserIdentifiers() map[string]bool { + identifiers := map[string]bool{} + if c == nil || c.IM == nil { + return identifiers + } + currentUser, err := c.IM.CurrentUser() + if err != nil || currentUser == nil { + return identifiers + } + for _, identifier := range []string{currentUser.ID, currentUser.Email, currentUser.PhoneNumber} { + identifier = normalizeIMessageIdentifier(identifier) + if identifier != "" { + identifiers[identifier] = true + identifiers[canonicalIMessageIdentifier(identifier)] = true + } + } + return identifiers +} + +func (c *Client) GetContactList(ctx context.Context) ([]*bridgev2.ResolveIdentifierResponse, error) { + contacts, err := c.contactResponses("") + if err != nil { + return nil, err + } + return contacts, nil +} + +func (c *Client) SearchUsers(ctx context.Context, query string) ([]*bridgev2.ResolveIdentifierResponse, error) { + return c.contactResponses(strings.ToLower(strings.TrimSpace(query))) +} + +func (c *Client) contactResponses(filter string) ([]*bridgev2.ResolveIdentifierResponse, error) { + page, err := c.IM.Chats(nil) + if err != nil { + return nil, err + } + seen := map[string]*bridgev2.ResolveIdentifierResponse{} + for _, thread := range page.Items { + for _, participant := range thread.Participants.Items { + if participant.ID == "" || isSelfParticipant(participant) { + continue + } + info := c.userInfoFromUser(participant) + if filter != "" && !contactMatches(filter, participant, info) { + continue + } + seen[participant.ID] = &bridgev2.ResolveIdentifierResponse{ + UserID: imessageid.MakeUserID(participant.ID), + UserInfo: info, + } + } + } + out := make([]*bridgev2.ResolveIdentifierResponse, 0, len(seen)) + for _, resp := range seen { + out = append(out, resp) + } + sort.Slice(out, func(i, j int) bool { + return string(out[i].UserID) < string(out[j].UserID) + }) + return out, nil +} + +func normalizeIMessageIdentifier(identifier string) string { + return strings.TrimSpace(identifier) +} + +func canonicalIMessageIdentifier(identifier string) string { + return strings.ToLower(normalizeIMessageIdentifier(identifier)) +} + +func canonicalPortalParticipantID(identifier string) string { + canonical := canonicalIMessageIdentifier(identifier) + if canonical == "" { + return normalizeIMessageIdentifier(identifier) + } + return canonical +} + +func validIMessageIdentifier(identifier string) bool { + identifier = normalizeIMessageIdentifier(identifier) + return identifier != "" && + !strings.Contains(identifier, ";-;") && + !strings.Contains(identifier, ",") && + !strings.ContainsAny(identifier, "\x00\r\n\t") +} + +func contactMatches(filter string, user imessage.User, info *bridgev2.UserInfo) bool { + fields := []string{user.ID, user.Username, user.PhoneNumber, user.Email, user.FullName, user.Nickname} + if info != nil && info.Name != nil { + fields = append(fields, *info.Name) + } + for _, field := range fields { + if strings.Contains(strings.ToLower(field), filter) { + return true + } + } + return false +} + +func (c *Client) syntheticChatResponse(participants []string, name string) *bridgev2.CreateChatResponse { + for i, participant := range participants { + participants[i] = canonicalPortalParticipantID(participant) + } + sort.Strings(participants) + threadID := "any;-;" + strings.Join(participants, ",") + roomType := database.RoomTypeDM + if len(participants) > 1 { + roomType = database.RoomTypeDefault + threadID = "group;-;" + strings.Join(participants, ",") + } + members := bridgev2.ChatMemberMap{} + for _, participant := range participants { + userID := imessageid.MakeUserID(participant) + members.Set(bridgev2.ChatMember{ + EventSender: bridgev2.EventSender{Sender: userID}, + Membership: event.MembershipJoin, + UserInfo: c.userInfoFromUser(imessage.User{ID: participant, Username: participant}), + }) + } + members.Set(c.selfChatMember()) + info := &bridgev2.ChatInfo{ + Type: &roomType, + Members: &bridgev2.ChatMemberList{ + IsFull: true, + TotalMemberCount: len(members), + MemberMap: members, + }, + } + if name != "" { + info.Name = &name + } + return &bridgev2.CreateChatResponse{ + PortalKey: portalKey(threadID, c.UserLogin.ID), + PortalInfo: info, + } +} + +func (c *Client) chatResponseFromThread(thread imessage.Thread) *bridgev2.CreateChatResponse { + return &bridgev2.CreateChatResponse{ + PortalKey: portalKey(thread.ID, c.UserLogin.ID), + PortalInfo: c.chatInfoFromThread(thread), + } +} + +func (c *Client) existingThreadWithParticipants(participants []string) (*imessage.Thread, error) { + if c == nil || c.IM == nil { + return nil, nil + } + expected := canonicalParticipantSet(participants) + if len(expected) < 2 { + return nil, nil + } + + var pagination *imessage.Pagination + for pageCount := 0; pageCount < 200; pageCount++ { + page, err := c.IM.Chats(pagination) + if err != nil { + return nil, err + } + for _, thread := range page.Items { + if thread.Type != imessage.ThreadTypeGroup && !threadIDIsGroup(thread.ID) { + continue + } + if participantSetMatchesThread(expected, thread, c.currentUserIdentifiers()) { + return &thread, nil + } + } + if !page.HasMore || page.OldestCursor == "" { + break + } + pagination = &imessage.Pagination{Cursor: page.OldestCursor, Direction: "before"} + } + return nil, nil +} + +func canonicalParticipantSet(participants []string) map[string]struct{} { + out := make(map[string]struct{}, len(participants)) + for _, participant := range participants { + if canonical := canonicalIMessageIdentifier(participant); canonical != "" { + out[canonical] = struct{}{} + } + } + return out +} + +func participantSetMatchesThread(expected map[string]struct{}, thread imessage.Thread, selfIdentifiers map[string]bool) bool { + actual := make(map[string]struct{}, len(thread.Participants.Items)) + for _, participant := range thread.Participants.Items { + canonical := canonicalIMessageIdentifier(participant.ID) + if canonical == "" || isSelfParticipant(participant) || selfIdentifiers[normalizeIMessageIdentifier(participant.ID)] || selfIdentifiers[canonical] { + continue + } + actual[canonical] = struct{}{} + } + if len(actual) != len(expected) { + return false + } + for participant := range expected { + if _, ok := actual[participant]; !ok { + return false + } + } + return true +} + +func (c *Client) chatInfoFromThread(thread imessage.Thread) *bridgev2.ChatInfo { + roomType := database.RoomTypeDefault + if thread.Type == imessage.ThreadTypeSingle && !threadIDIsGroup(thread.ID) { + roomType = database.RoomTypeDM + } + + selfIdentifiers := c.currentUserIdentifiers() + memberMap := bridgev2.ChatMemberMap{} + for _, participant := range participantsForChatInfo(thread, roomType, selfIdentifiers) { + if isSelfParticipant(participant) || selfIdentifiers[normalizeIMessageIdentifier(participant.ID)] { + continue + } + userID := imessageid.MakeUserID(participant.ID) + memberMap.Set(bridgev2.ChatMember{ + EventSender: bridgev2.EventSender{Sender: userID}, + Membership: event.MembershipJoin, + UserInfo: c.userInfoFromUser(participant), + }) + } + memberMap.Set(c.selfChatMember()) + + info := &bridgev2.ChatInfo{ + Members: &bridgev2.ChatMemberList{ + IsFull: true, + TotalMemberCount: len(memberMap), + MemberMap: memberMap, + }, + Type: &roomType, + CanBackfill: true, + } + if thread.Title != "" { + info.Name = &thread.Title + } + if thread.ImgURL != "" { + info.Avatar = c.avatarFromURL(thread.ImgURL) + } + return info +} + +func (c *Client) selfChatMember() bridgev2.ChatMember { + return bridgev2.ChatMember{ + EventSender: bridgev2.EventSender{ + IsFromMe: true, + SenderLogin: c.UserLogin.ID, + Sender: c.GetUserID(), + ForceDMUser: false, + }, + Membership: event.MembershipJoin, + } +} + +func isSelfParticipant(participant imessage.User) bool { + return participant.IsSelf != nil && *participant.IsSelf +} + +func threadIDIsGroup(threadID string) bool { + return strings.Contains(threadID, ";+;") || strings.HasPrefix(threadID, "group;-;") +} + +func participantsForChatInfo(thread imessage.Thread, roomType database.RoomType, selfIdentifierMaps ...map[string]bool) []imessage.User { + selfIdentifiers := map[string]bool{} + if len(selfIdentifierMaps) > 0 && selfIdentifierMaps[0] != nil { + selfIdentifiers = selfIdentifierMaps[0] + } + participants := make([]imessage.User, 0, len(thread.Participants.Items)) + for _, participant := range thread.Participants.Items { + if participant.ID == "" || isSelfParticipant(participant) || selfIdentifiers[normalizeIMessageIdentifier(participant.ID)] { + continue + } + participants = append(participants, participant) + } + if roomType != database.RoomTypeDM || len(participants) <= 1 { + return participants + } + if identifier := singleThreadIdentifier(thread.ID); identifier != "" { + for _, participant := range participants { + if participant.ID == identifier { + return []imessage.User{participant} + } + } + } + return participants[:1] +} + +func singleThreadIdentifier(threadID string) string { + parts := strings.SplitN(threadID, ";", 3) + if len(parts) != 3 || parts[1] != "-" { + return "" + } + switch parts[0] { + case "any", "iMessage", "SMS", "RCS": + return parts[2] + default: + return "" + } +} + +func (c *Client) userInfoFromUser(user imessage.User) *bridgev2.UserInfo { + displayName := user.FullName + if displayName == "" { + displayName = user.Nickname + } + if displayName == "" { + displayName = user.Email + } + if displayName == "" { + displayName = user.PhoneNumber + } + if displayName == "" { + displayName = user.ID + } + + info := &bridgev2.UserInfo{ + Name: &displayName, + } + if user.ImgURL != "" { + info.Avatar = c.avatarFromURL(user.ImgURL) + } + return info +} + +func (c *Client) avatarFromURL(rawURL string) *bridgev2.Avatar { + if rawURL == "" { + return nil + } + return &bridgev2.Avatar{ + ID: networkid.AvatarID(rawURL), + Get: func(ctx context.Context) ([]byte, error) { + data, _, err := c.readAttachmentURL(ctx, rawURL, 0) + return data, err + }, + } +} + +func receiver(login *bridgev2.UserLogin) networkid.UserLoginID { + if login == nil { + return "" + } + return login.ID +} diff --git a/pkg/connector/client.go b/pkg/connector/client.go new file mode 100644 index 00000000..5beb46eb --- /dev/null +++ b/pkg/connector/client.go @@ -0,0 +1,304 @@ +package connector + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/beeper/platform-imessage/pkg/imessage" + "github.com/beeper/platform-imessage/pkg/imessageid" + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/networkid" + "maunium.net/go/mautrix/bridgev2/simplevent" + "maunium.net/go/mautrix/bridgev2/status" +) + +type Client struct { + Main *Connector + UserLogin *bridgev2.UserLogin + IM *imessage.Client + + stopEventLock sync.Mutex + stopEventLoop chan struct{} + loggedIn atomic.Bool +} + +var _ bridgev2.NetworkAPI = (*Client)(nil) +var _ bridgev2.NetworkAPIWithUserID = (*Client)(nil) + +func (c *Client) Connect(ctx context.Context) { + c.loggedIn.Store(false) + c.UserLogin.BridgeState.Send(status.BridgeState{StateEvent: status.StateConnecting}) + if !c.Main.Config.ShouldSkipPermissionValidation() { + authStatus, err := c.IM.AuthorizationStatus() + if err != nil { + c.UserLogin.BridgeState.Send(status.BridgeState{ + StateEvent: status.StateBadCredentials, + Error: "IMESSAGE_PERMISSION_CHECK_FAILED", + Message: err.Error(), + }) + return + } + if !authStatus.Authorized { + c.UserLogin.BridgeState.Send(status.BridgeState{ + StateEvent: status.StateBadCredentials, + Error: "IMESSAGE_PERMISSIONS_MISSING", + Message: missingPermissionMessage(authStatus), + }) + return + } + } + if _, err := c.IM.CurrentUser(); err != nil { + c.UserLogin.BridgeState.Send(status.BridgeState{ + StateEvent: status.StateBadCredentials, + Error: "IMESSAGE_NOT_AVAILABLE", + Message: err.Error(), + }) + return + } + automationState := c.automationBridgeState() + stopEventLoop := c.resetEventLoop() + if err := c.IM.StartEvents(); err != nil { + c.stopCurrentEventLoop() + c.UserLogin.BridgeState.Send(status.BridgeState{ + StateEvent: status.StateUnknownError, + Error: "IMESSAGE_EVENT_WATCH_FAILED", + Message: err.Error(), + }) + return + } + c.loggedIn.Store(true) + if automationState != nil { + c.UserLogin.BridgeState.Send(*automationState) + } else { + c.UserLogin.BridgeState.Send(status.BridgeState{StateEvent: status.StateConnected}) + } + go c.syncExistingChats() + go c.eventLoop(stopEventLoop) +} + +func (c *Client) automationBridgeState() *status.BridgeState { + authStatus, err := c.IM.AuthorizationStatus() + if err != nil || authStatus == nil || authStatus.Automation.Available || authStatus.Automation.Status == "" { + return nil + } + return bridgeStateForAutomationStatus(authStatus.Automation) +} + +func bridgeStateForAutomationStatus(automation imessage.AutomationStatus) *status.BridgeState { + if automation.Available || automation.Status == "" { + return nil + } + message := automation.Message + if message == "" { + message = "Messages.app automation is unavailable" + } + return &status.BridgeState{ + StateEvent: status.StateConnected, + Error: "IMESSAGE_AUTOMATION_UNAVAILABLE", + Message: message, + Reason: automation.Reason, + Info: map[string]interface{}{ + "automation_status": automation.Status, + "frontmost_bundle_id": automation.FrontmostBundleID, + "frontmost_name": automation.FrontmostName, + "messages_running": automation.MessagesRunning, + "messages_active": automation.MessagesActive, + "messages_hidden": automation.MessagesHidden, + "messages_window_count": automation.MessagesWindowCount, + "messages_status_reason": automation.Reason, + "messages_status_message": message, + }, + } +} + +func missingPermissionMessage(authStatus *imessage.AuthorizationStatus) string { + if authStatus == nil { + return "Local iMessage permissions are missing" + } + var missing []string + for _, permission := range authStatus.Permissions { + if permission.Required && !permission.Authorized { + missing = append(missing, permission.Title) + } + } + if len(missing) == 0 { + return "Local iMessage permissions are missing" + } + return "Missing local iMessage permissions: " + strings.Join(missing, ", ") +} + +func (c *Client) Disconnect() { + c.loggedIn.Store(false) + c.stopCurrentEventLoop() +} + +func (c *Client) stopCurrentEventLoop() { + c.stopEventLock.Lock() + defer c.stopEventLock.Unlock() + if c.stopEventLoop != nil { + close(c.stopEventLoop) + c.stopEventLoop = nil + } +} + +func (c *Client) IsLoggedIn() bool { + return c.loggedIn.Load() +} + +func (c *Client) LogoutRemote(ctx context.Context) { + c.Disconnect() +} + +func (c *Client) IsThisUser(ctx context.Context, userID networkid.UserID) bool { + return userID == c.GetUserID() +} + +func (c *Client) GetUserID() networkid.UserID { + return imessageid.MakeUserID(string(c.UserLogin.ID)) +} + +func (c *Client) resetEventLoop() <-chan struct{} { + c.stopEventLock.Lock() + defer c.stopEventLock.Unlock() + if c.stopEventLoop != nil { + close(c.stopEventLoop) + } + c.stopEventLoop = make(chan struct{}) + return c.stopEventLoop +} + +func (c *Client) eventLoop(stopEventLoop <-chan struct{}) { + timeout := c.Main.Config.EventPollTimeoutMS + if timeout <= 0 { + timeout = 30000 + } + + for { + select { + case <-stopEventLoop: + return + default: + } + + events, err := c.IM.NextEvents(timeout) + if err != nil { + c.UserLogin.Log.Warn().Err(err).Msg("Failed to poll iMessage events") + time.Sleep(5 * time.Second) + continue + } + for _, evt := range events { + if err := c.handleStateSyncEvent(evt); err != nil { + c.UserLogin.Log.Warn().Err(err).Msg("Failed to handle iMessage event") + } + } + } +} + +func (c *Client) handleStateSyncEvent(evt imessage.StateSyncEvent) error { + if evt.Type != "state_sync" { + if evt.Type == "user_activity" { + c.handleUserActivity(evt) + } + return nil + } + switch evt.ObjectName { + case "message": + return c.handleMessageStateSync(evt) + case "message_reaction": + return c.handleReactionStateSync(evt) + case "thread": + return c.handleThreadStateSync(evt) + default: + return nil + } +} + +func (c *Client) handleUserActivity(evt imessage.StateSyncEvent) { + if evt.ThreadID == "" || evt.ParticipantID == "" { + return + } + timeout := 120 * time.Second + if evt.DurationMS > 0 { + timeout = time.Duration(evt.DurationMS) * time.Millisecond + } + if evt.ActivityType != "typing" { + timeout = 0 + } + meta := c.baseEventMeta(evt.ThreadID).WithType(bridgev2.RemoteEventTyping) + meta.Sender = bridgev2.EventSender{Sender: imessageid.MakeUserID(evt.ParticipantID)} + c.UserLogin.QueueRemoteEvent(&simplevent.Typing{ + EventMeta: meta, + Timeout: timeout, + Type: bridgev2.TypingTypeText, + }) +} + +func (c *Client) syncExistingChats() { + page, err := c.IM.Chats(nil) + if err != nil { + c.UserLogin.Log.Warn().Err(err).Msg("Failed to sync iMessage chats") + return + } + for _, thread := range page.Items { + thread := thread + c.reIDKnownSyntheticPortals(context.Background(), thread) + c.queueThreadReadState(thread) + c.UserLogin.QueueRemoteEvent(&simplevent.ChatResync{ + EventMeta: c.baseEventMeta(thread.ID).WithType(bridgev2.RemoteEventChatResync), + ChatInfo: c.chatInfoFromThread(thread), + LatestMessageTS: threadLatestMessageTimestamp(thread), + }) + messages, err := c.IM.Messages(thread.ID, nil) + if err != nil { + c.UserLogin.Log.Warn().Err(err).Str("thread_id", thread.ID).Msg("Failed to sync iMessage messages") + continue + } + for _, message := range messages.Items { + evt := imessage.StateSyncEvent{ + Type: "state_sync", + ObjectName: "message", + MutationType: "upsert", + } + evt.Entries, _ = json.Marshal([]imessage.Message{message}) + if err := c.handleMessageStateSync(evt); err != nil { + c.UserLogin.Log.Warn().Err(err).Str("thread_id", thread.ID).Msg("Failed to queue iMessage message") + } + } + } +} + +func firstSentMessage(messages []imessage.Message) (*imessage.Message, error) { + if len(messages) == 0 { + return nil, errors.New("send succeeded but returned no message ID") + } + return &messages[0], nil +} + +func messageTimestamp(message imessage.Message) time.Time { + if message.Timestamp == 0 { + return time.Now() + } + return time.UnixMilli(message.Timestamp) +} + +func threadLatestMessageTimestamp(thread imessage.Thread) time.Time { + if thread.PartialLastMessage != nil { + return messageTimestamp(*thread.PartialLastMessage) + } + if thread.Timestamp != 0 { + return time.UnixMilli(thread.Timestamp) + } + return time.Time{} +} + +func matrixUnsupported(msg string) error { + return bridgev2.WrapErrorInStatus(fmt.Errorf("%s", msg)). + WithErrorAsMessage(). + WithIsCertain(true) +} diff --git a/pkg/connector/commands.go b/pkg/connector/commands.go new file mode 100644 index 00000000..512b9ccd --- /dev/null +++ b/pkg/connector/commands.go @@ -0,0 +1,335 @@ +package connector + +import ( + "context" + "errors" + "fmt" + "sort" + "strings" + + "github.com/beeper/platform-imessage/pkg/imessage" + "github.com/beeper/platform-imessage/pkg/imessageid" + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/commands" + "maunium.net/go/mautrix/bridgev2/simplevent" +) + +var cmdCreateIMessageChat = &commands.FullHandler{ + Func: fnCreateIMessageChat, + Name: "create-imessage-chat", + Help: commands.HelpMeta{ + Section: commands.HelpSectionChats, + Description: "Create an iMessage chat with the required initial message.", + Args: "... -- ", + }, + RequiresLogin: true, +} + +func fnCreateIMessageChat(ce *commands.Event) { + client, ok := clientForCommand(ce, false) + if !ok { + return + } + recipients, initialMessage := parseCreateChatCommand(ce) + if len(recipients) == 0 || initialMessage == "" { + ce.Reply("Usage: `$cmdprefix create-imessage-chat ... -- `") + return + } + thread, _, err := client.IM.CreateChat(recipients, initialMessage, "") + if err != nil { + ce.Log.Err(err).Msg("Failed to create iMessage chat") + ce.Reply("Failed to create iMessage chat: %v", err) + return + } + + threadID := "" + if thread != nil { + threadID = thread.ID + client.queueCreatedThread(ce.Ctx, *thread) + if thread.PartialLastMessage != nil { + thread.PartialLastMessage.ThreadID = thread.ID + client.queueCreatedMessage(*thread.PartialLastMessage) + } + } else { + messages, err := client.findRecentlyCreatedMessage("", initialMessage) + if err != nil { + ce.Log.Err(err).Msg("Failed to reconcile created iMessage chat") + ce.Reply("Created iMessage chat, but couldn't reconcile the final thread ID: %v", err) + return + } + if len(messages) > 0 { + threadID = messages[0].ThreadID + client.queueCreatedMessage(messages[0]) + client.queueThreadResync(ce.Ctx, threadID) + } + } + if threadID == "" { + ce.Reply("Created iMessage chat, but the platform didn't return a thread ID.") + return + } + ce.Reply("Created iMessage chat `%s`. The portal should be created momentarily.", threadID) +} + +var cmdNotifyAnyway = &commands.FullHandler{ + Func: fnNotifyAnyway, + Name: "notify-anyway", + Help: commands.HelpMeta{ + Section: commands.HelpSectionChats, + Description: "Press iMessage Notify Anyway for the current portal.", + }, + RequiresLogin: true, + RequiresPortal: true, +} + +func fnNotifyAnyway(ce *commands.Event) { + client, ok := clientForCommand(ce, true) + if !ok { + return + } + if err := client.IM.NotifyAnyway(string(ce.Portal.ID)); err != nil { + ce.Log.Err(err).Msg("Failed to notify anyway") + ce.Reply("Failed to notify anyway: %v", err) + return + } + ce.Reply("Notify Anyway sent.") +} + +var cmdActivityStatus = &commands.FullHandler{ + Func: fnActivityStatus, + Name: "activity-status", + Help: commands.HelpMeta{ + Section: commands.HelpSectionChats, + Description: "Show current iMessage typing and Focus status for the current portal.", + }, + RequiresLogin: true, + RequiresPortal: true, +} + +func fnActivityStatus(ce *commands.Event) { + client, ok := clientForCommand(ce, true) + if !ok { + return + } + status, err := client.IM.ActivityStatus(string(ce.Portal.ID)) + if err != nil { + ce.Log.Err(err).Msg("Failed to get iMessage activity status") + ce.Reply("Failed to get iMessage activity status: %v", err) + return + } + ce.Reply(formatActivityStatus(status)) +} + +var cmdSelectChat = &commands.FullHandler{ + Func: fnSelectChat, + Name: "select-chat", + Help: commands.HelpMeta{ + Section: commands.HelpSectionChats, + Description: "Select this iMessage chat for local activity watching.", + }, + RequiresLogin: true, + RequiresPortal: true, +} + +func fnSelectChat(ce *commands.Event) { + client, ok := clientForCommand(ce, true) + if !ok { + return + } + if err := client.IM.WatchChat(string(ce.Portal.ID)); err != nil { + ce.Log.Err(err).Msg("Failed to select iMessage chat") + ce.Reply("Failed to select iMessage chat: %v", err) + return + } + ce.Reply("Selected iMessage chat for activity watching.") +} + +var cmdSearchMessages = &commands.FullHandler{ + Func: fnSearchMessages, + Name: "search-messages", + Help: commands.HelpMeta{ + Section: commands.HelpSectionChats, + Description: "Search iMessage messages globally or in the current portal.", + Args: "", + }, + RequiresLogin: true, +} + +func fnSearchMessages(ce *commands.Event) { + query := strings.TrimSpace(ce.RawArgs) + if query == "" { + ce.Reply("Usage: `$cmdprefix search-messages `") + return + } + client, ok := clientForCommand(ce, ce.Portal != nil) + if !ok { + return + } + threadID := "" + if ce.Portal != nil { + threadID = string(ce.Portal.ID) + } + page, err := client.IM.SearchMessages(query, threadID, nil, 10) + if err != nil { + ce.Log.Err(err).Msg("Failed to search iMessage messages") + ce.Reply("Failed to search iMessage messages: %v", err) + return + } + if len(page.Items) == 0 { + ce.Reply("No iMessage messages found.") + return + } + ce.Reply(formatSearchResults(page.Items, threadID == "")) +} + +func formatActivityStatus(status *imessage.ActivityStatus) string { + if status == nil { + return "iMessage activity status is unknown." + } + activity := status.ActivityType + if activity == "" { + activity = "none" + } + presence := status.PresenceStatus + if presence == "" && !status.DidObservePresence { + presence = "unknown" + } else if presence == "" { + presence = "none" + } + return fmt.Sprintf("Activity: `%s`\nPresence: `%s`", activity, presence) +} + +func clientForCommand(ce *commands.Event, preferPortal bool) (*Client, bool) { + var login *bridgev2.UserLogin + if preferPortal && ce.Portal != nil { + var err error + login, _, err = ce.Portal.FindPreferredLogin(ce.Ctx, ce.User, false) + if errors.Is(err, bridgev2.ErrNotLoggedIn) { + ce.Reply("You're not logged in in this portal.") + return nil, false + } else if err != nil { + ce.Log.Err(err).Msg("Failed to find preferred login for portal") + ce.Reply("Failed to find preferred login for portal.") + return nil, false + } + } + if login == nil { + login = ce.User.GetDefaultLogin() + } + if login == nil { + ce.Reply("Login not found.") + return nil, false + } + client, ok := login.Client.(*Client) + if !ok || client == nil { + ce.Reply("Login is not an iMessage login.") + return nil, false + } + return client, true +} + +func parseCreateChatCommand(ce *commands.Event) ([]string, string) { + raw := strings.TrimSpace(ce.RawArgs) + if before, after, ok := strings.Cut(raw, " -- "); ok { + return sortedNonEmptyFields(before), strings.TrimSpace(after) + } + for i, arg := range ce.Args { + if arg == "--message" || arg == "-m" { + return sortedNonEmptyFields(strings.Join(ce.Args[:i], " ")), strings.TrimSpace(strings.Join(ce.Args[i+1:], " ")) + } + } + return nil, "" +} + +func sortedNonEmptyFields(input string) []string { + fields := strings.Fields(input) + out := fields[:0] + for _, field := range fields { + if field != "" { + out = append(out, field) + } + } + sort.Strings(out) + return out +} + +func (c *Client) queueCreatedThread(ctx context.Context, thread imessage.Thread) { + c.reIDKnownSyntheticPortals(ctx, thread) + c.queueThreadReadState(thread) + c.UserLogin.QueueRemoteEvent(&simplevent.ChatResync{ + EventMeta: c.baseEventMeta(thread.ID).WithType(bridgev2.RemoteEventChatResync), + ChatInfo: c.chatInfoFromThread(thread), + LatestMessageTS: threadLatestMessageTimestamp(thread), + }) +} + +func (c *Client) queueThreadResync(ctx context.Context, threadID string) { + if threadID == "" { + return + } + thread, err := c.IM.Chat(threadID) + if err != nil { + c.UserLogin.Log.Warn().Err(err).Str("thread_id", threadID).Msg("Failed to load created iMessage chat") + return + } + if thread != nil { + c.queueCreatedThread(ctx, *thread) + } +} + +func (c *Client) queueCreatedMessage(message imessage.Message) { + if message.ThreadID == "" { + return + } + c.UserLogin.QueueRemoteEvent(&simplevent.Message[imessage.Message]{ + EventMeta: c.eventMeta(message.ThreadID, message), + ID: imessageid.MakeMessageID(message.ID), + Data: message, + ConvertMessageFunc: func(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI, data imessage.Message) (*bridgev2.ConvertedMessage, error) { + return c.convertMessageFromIMessage(ctx, portal, intent, data) + }, + }) +} + +func formatSearchResults(messages []imessage.Message, includeThread bool) string { + var builder strings.Builder + limit := len(messages) + if limit > 10 { + limit = 10 + } + fmt.Fprintf(&builder, "Found %d iMessage message%s:", len(messages), pluralSuffix(len(messages))) + for i := 0; i < limit; i++ { + msg := messages[i] + text := strings.Join(strings.Fields(msg.Text), " ") + if text == "" && len(msg.Attachments) > 0 { + text = msg.Attachments[0].FileName + } + if text == "" { + text = "Unsupported iMessage event" + } + text = truncate(text, 180) + if includeThread { + fmt.Fprintf(&builder, "\n%d. `%s` `%s` %s", i+1, msg.ThreadID, msg.ID, text) + } else { + fmt.Fprintf(&builder, "\n%d. `%s` %s", i+1, msg.ID, text) + } + } + return builder.String() +} + +func pluralSuffix(count int) string { + if count == 1 { + return "" + } + return "s" +} + +func truncate(input string, maxLen int) string { + runes := []rune(input) + if len(runes) <= maxLen { + return input + } + if maxLen <= 3 { + return string(runes[:maxLen]) + } + return string(runes[:maxLen-3]) + "..." +} diff --git a/pkg/connector/config.go b/pkg/connector/config.go new file mode 100644 index 00000000..9b90dd2b --- /dev/null +++ b/pkg/connector/config.go @@ -0,0 +1,36 @@ +package connector + +import ( + _ "embed" + + up "go.mau.fi/util/configupgrade" +) + +//go:embed example-config.yaml +var ExampleConfig string + +type Config struct { + DataDir string `yaml:"data_dir"` + Verbose bool `yaml:"verbose"` + UseSecondaryInstance bool `yaml:"use_secondary_instance"` + CoordinateWindow bool `yaml:"coordinate_window"` + EventPollTimeoutMS int `yaml:"event_poll_timeout_ms"` + SkipPermissionValidation *bool `yaml:"skip_permission_validation"` +} + +func upgradeConfig(helper up.Helper) { + helper.Copy(up.Str, "data_dir") + helper.Copy(up.Bool, "verbose") + helper.Copy(up.Bool, "use_secondary_instance") + helper.Copy(up.Bool, "coordinate_window") + helper.Copy(up.Int, "event_poll_timeout_ms") + helper.Copy(up.Bool, "skip_permission_validation") +} + +func (c *Connector) GetConfig() (string, any, up.Upgrader) { + return ExampleConfig, &c.Config, up.SimpleUpgrader(upgradeConfig) +} + +func (c *Config) ShouldSkipPermissionValidation() bool { + return c.SkipPermissionValidation == nil || *c.SkipPermissionValidation +} diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go new file mode 100644 index 00000000..36f2204c --- /dev/null +++ b/pkg/connector/connector.go @@ -0,0 +1,96 @@ +package connector + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "github.com/beeper/platform-imessage/pkg/imessage" + "github.com/beeper/platform-imessage/pkg/imessageid" + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/commands" + "maunium.net/go/mautrix/bridgev2/database" + "maunium.net/go/mautrix/bridgev2/networkid" +) + +type Connector struct { + Bridge *bridgev2.Bridge + Config Config +} + +var _ bridgev2.NetworkConnector = (*Connector)(nil) +var _ bridgev2.StoppableNetwork = (*Connector)(nil) + +func (c *Connector) Init(br *bridgev2.Bridge) { + c.Bridge = br + br.Commands.(*commands.Processor).AddHandlers( + cmdCreateIMessageChat, + cmdNotifyAnyway, + cmdActivityStatus, + cmdSelectChat, + cmdSearchMessages, + ) +} + +func (c *Connector) Start(ctx context.Context) error { + dataDir := c.Config.DataDir + if dataDir == "" { + dataDir = "imessage-data" + } + if !filepath.IsAbs(dataDir) { + abs, err := filepath.Abs(dataDir) + if err != nil { + return fmt.Errorf("failed to resolve iMessage data dir: %w", err) + } + dataDir = abs + } + if err := os.MkdirAll(dataDir, 0o700); err != nil { + return fmt.Errorf("failed to create iMessage data dir: %w", err) + } + return imessage.Init(dataDir, c.Config.Verbose, c.Config.UseSecondaryInstance, c.Config.CoordinateWindow) +} + +func (c *Connector) Stop() { + if err := imessage.Dispose(); err != nil && c.Bridge != nil { + c.Bridge.Log.Warn().Err(err).Msg("Failed to dispose iMessage bridge runtime") + } +} + +func (c *Connector) GetName() bridgev2.BridgeName { + return bridgev2.BridgeName{ + DisplayName: "iMessage", + NetworkURL: "https://www.apple.com/ios/messages/", + NetworkID: "imessage", + BeeperBridgeType: "imessage", + DefaultPort: 29340, + DefaultCommandPrefix: "!imessage", + } +} + +func (c *Connector) GetDBMetaTypes() database.MetaTypes { + return database.MetaTypes{ + UserLogin: func() any { return &imessageid.UserLoginMetadata{} }, + Portal: func() any { return &imessageid.PortalMetadata{} }, + Ghost: func() any { return &imessageid.GhostMetadata{} }, + Message: func() any { return &imessageid.MessageMetadata{} }, + Reaction: func() any { return &imessageid.ReactionMetadata{} }, + } +} + +func (c *Connector) LoadUserLogin(ctx context.Context, login *bridgev2.UserLogin) error { + login.Client = &Client{ + Main: c, + UserLogin: login, + IM: imessage.NewClient(), + stopEventLoop: make(chan struct{}), + } + return nil +} + +func portalKey(threadID string, receiver networkid.UserLoginID) networkid.PortalKey { + return networkid.PortalKey{ + ID: imessageid.MakePortalID(threadID), + Receiver: receiver, + } +} diff --git a/pkg/connector/example-config.yaml b/pkg/connector/example-config.yaml new file mode 100644 index 00000000..89439b24 --- /dev/null +++ b/pkg/connector/example-config.yaml @@ -0,0 +1,6 @@ +data_dir: ./imessage-data +verbose: false +use_secondary_instance: false +coordinate_window: false +event_poll_timeout_ms: 30000 +skip_permission_validation: true diff --git a/pkg/connector/handleimessage.go b/pkg/connector/handleimessage.go new file mode 100644 index 00000000..091f0c1c --- /dev/null +++ b/pkg/connector/handleimessage.go @@ -0,0 +1,501 @@ +package connector + +import ( + "context" + "encoding/json" + "sort" + "strings" + + "github.com/beeper/platform-imessage/pkg/imessage" + "github.com/beeper/platform-imessage/pkg/imessageid" + "github.com/rs/zerolog" + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/database" + "maunium.net/go/mautrix/bridgev2/networkid" + "maunium.net/go/mautrix/bridgev2/simplevent" + "maunium.net/go/mautrix/event" +) + +func (c *Client) handleMessageStateSync(evt imessage.StateSyncEvent) error { + switch evt.MutationType { + case "upsert": + var messages []imessage.Message + if err := json.Unmarshal(evt.Entries, &messages); err != nil { + return err + } + for _, message := range messages { + if message.ThreadID == "" { + message.ThreadID = evt.ObjectIDs.ThreadID + } + message := message + c.queueMessageActionChange(context.Background(), message) + if isReactionActionMessage(message) { + continue + } + c.UserLogin.QueueRemoteEvent(&simplevent.Message[imessage.Message]{ + EventMeta: c.eventMeta(message.ThreadID, message), + ID: imessageid.MakeMessageID(message.ID), + Data: message, + ConvertMessageFunc: func(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI, data imessage.Message) (*bridgev2.ConvertedMessage, error) { + return c.convertMessageFromIMessage(ctx, portal, intent, data) + }, + HandleExistingFunc: func(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI, existing []*database.Message, data imessage.Message) (bridgev2.UpsertResult, error) { + return bridgev2.UpsertResult{}, nil + }, + }) + } + case "update": + var messages []imessage.Message + if err := json.Unmarshal(evt.Entries, &messages); err != nil { + return err + } + for _, message := range messages { + if message.ThreadID == "" { + message.ThreadID = evt.ObjectIDs.ThreadID + } + c.UserLogin.QueueRemoteEvent(&simplevent.Message[imessage.Message]{ + EventMeta: c.eventMeta(message.ThreadID, message).WithType(bridgev2.RemoteEventEdit), + ID: imessageid.MakeMessageID(message.ID), + TargetMessage: imessageid.MakeMessageID(message.ID), + Data: message, + ConvertEditFunc: func(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI, existing []*database.Message, data imessage.Message) (*bridgev2.ConvertedEdit, error) { + if len(existing) == 0 { + return nil, bridgev2.ErrIgnoringRemoteEvent + } + converted, err := c.convertMessageFromIMessage(ctx, portal, intent, data) + if err != nil { + return nil, err + } + return editFromUpdatedMessage(existing, converted) + }, + }) + } + case "delete": + var ids []string + if err := json.Unmarshal(evt.Entries, &ids); err != nil { + return err + } + threadID := evt.ObjectIDs.ThreadID + for _, id := range ids { + c.UserLogin.QueueRemoteEvent(&simplevent.MessageRemove{ + EventMeta: c.eventMeta(threadID, imessage.Message{ID: id, ThreadID: threadID}), + TargetMessage: imessageid.MakeMessageID(id), + }) + } + } + return nil +} + +func isReactionActionMessage(message imessage.Message) bool { + if message.Action == nil { + return false + } + return message.Action.Type == "message_reaction_created" || + message.Action.Type == "message_reaction_deleted" +} + +func editFromUpdatedMessage(existing []*database.Message, converted *bridgev2.ConvertedMessage) (*bridgev2.ConvertedEdit, error) { + if len(existing) == 0 || converted == nil || len(converted.Parts) == 0 { + return nil, bridgev2.ErrIgnoringRemoteEvent + } + edit := &bridgev2.ConvertedEdit{} + modifiedCount := len(converted.Parts) + if len(existing) < modifiedCount { + modifiedCount = len(existing) + } + for i := 0; i < modifiedCount; i++ { + editPart := converted.Parts[i].ToEditPart(existing[i]) + if editPart != nil { + edit.ModifiedParts = append(edit.ModifiedParts, editPart) + } + } + if len(converted.Parts) > len(existing) { + edit.AddedParts = &bridgev2.ConvertedMessage{ + ReplyTo: converted.ReplyTo, + Disappear: converted.Disappear, + ThreadRoot: converted.ThreadRoot, + Parts: converted.Parts[len(existing):], + } + } + if len(existing) > len(converted.Parts) { + edit.DeletedParts = existing[len(converted.Parts):] + } + return edit, nil +} + +func (c *Client) handleReactionStateSync(evt imessage.StateSyncEvent) error { + targetMessageID := imessageid.MakeMessageID(evt.ObjectIDs.MessageID) + switch evt.MutationType { + case "upsert": + var reactions []imessage.Reaction + if err := json.Unmarshal(evt.Entries, &reactions); err != nil { + return err + } + for _, reaction := range reactions { + c.UserLogin.QueueRemoteEvent(&simplevent.Reaction{ + EventMeta: c.eventMeta(evt.ObjectIDs.ThreadID, imessage.Message{ThreadID: evt.ObjectIDs.ThreadID, SenderID: reaction.ParticipantID}).WithType(bridgev2.RemoteEventReaction), + TargetMessage: targetMessageID, + EmojiID: networkid.EmojiID(reaction.ID), + Emoji: bridgeReactionKeyFromPlatform(reaction.ReactionKey), + ReactionDBMeta: &imessageid.ReactionMetadata{ + ReactionID: reaction.ID, + ReactionKey: reaction.ReactionKey, + }, + }) + } + case "delete": + var ids []string + if err := json.Unmarshal(evt.Entries, &ids); err != nil { + return err + } + for _, id := range ids { + reactionID := string(id) + senderID, reactionKey := c.reactionSenderAndKey(context.Background(), targetMessageID, reactionID) + c.UserLogin.QueueRemoteEvent(&simplevent.Reaction{ + EventMeta: c.eventMeta(evt.ObjectIDs.ThreadID, imessage.Message{ThreadID: evt.ObjectIDs.ThreadID, SenderID: string(senderID)}).WithType(bridgev2.RemoteEventReactionRemove), + TargetMessage: targetMessageID, + EmojiID: networkid.EmojiID(id), + Emoji: reactionKey, + ReactionDBMeta: &imessageid.ReactionMetadata{ + ReactionID: reactionID, + ReactionKey: reactionKey, + }, + }) + } + } + return nil +} + +var standardIMessageReactionKeys = []string{ + "emphasize", + "question", + "dislike", + "sticker", + "heart", + "laugh", + "like", +} + +var bridgeReactionToPlatformReaction = map[string]string{ + "❤": "heart", + "❤️": "heart", + "👍": "like", + "👍️": "like", + "👎": "dislike", + "👎️": "dislike", + "HAHA": "laugh", + "‼️": "emphasize", + "‼": "emphasize", + "❓": "question", + "❓️": "question", +} + +var platformReactionToBridgeReaction = map[string]string{ + "heart": "❤️", + "like": "👍", + "dislike": "👎", + "laugh": "HAHA", + "emphasize": "‼️", + "question": "❓", +} + +func bridgeReactionKeyFromPlatform(reactionKey string) string { + if bridgeKey, ok := platformReactionToBridgeReaction[reactionKey]; ok { + return bridgeKey + } + return reactionKey +} + +func platformReactionKeyFromBridge(reactionKey string) (string, bool) { + reactionKey = normalizeBridgeReactionKey(reactionKey) + if platformKey, ok := bridgeReactionToPlatformReaction[reactionKey]; ok { + return platformKey, true + } + if _, ok := platformReactionToBridgeReaction[reactionKey]; ok { + return reactionKey, true + } + return "", false +} + +func normalizeBridgeReactionKey(reactionKey string) string { + switch strings.TrimSpace(reactionKey) { + case "❤", "❤️": + return "❤️" + case "👍", "👍️": + return "👍" + case "👎", "👎️": + return "👎" + case "‼", "‼️": + return "‼️" + case "❓", "❓️": + return "❓" + default: + return strings.TrimSpace(reactionKey) + } +} + +func backfillReactionFromIMessage(reaction imessage.Reaction) *bridgev2.BackfillReaction { + return &bridgev2.BackfillReaction{ + Sender: bridgev2.EventSender{Sender: imessageid.MakeUserID(reaction.ParticipantID)}, + EmojiID: networkid.EmojiID(reaction.ID), + Emoji: bridgeReactionKeyFromPlatform(reaction.ReactionKey), + DBMetadata: reactionDBMetadata(reaction.ID, reaction.ReactionKey), + } +} + +func reactionDBMetadata(reactionID, reactionKey string) *imessageid.ReactionMetadata { + return &imessageid.ReactionMetadata{ + ReactionID: reactionID, + ReactionKey: reactionKey, + } +} + +func splitStandardIMessageReactionID(reactionID string) (senderID networkid.UserID, reactionKey string) { + for _, key := range standardIMessageReactionKeys { + if strings.HasSuffix(reactionID, key) && len(reactionID) > len(key) { + return imessageid.MakeUserID(strings.TrimSuffix(reactionID, key)), key + } + } + return "", "" +} + +func (c *Client) reactionSenderAndKey(ctx context.Context, targetMessageID networkid.MessageID, reactionID string) (networkid.UserID, string) { + if c.Main != nil && c.Main.Bridge != nil && c.Main.Bridge.DB != nil && c.Main.Bridge.DB.Reaction != nil { + reactions, err := c.Main.Bridge.DB.Reaction.GetAllToMessage(ctx, receiver(c.UserLogin), targetMessageID) + if err == nil { + for _, reaction := range reactions { + if reaction.EmojiID != networkid.EmojiID(reactionID) { + continue + } + reactionKey := reaction.Emoji + if meta, ok := reaction.Metadata.(*imessageid.ReactionMetadata); ok && meta.ReactionKey != "" { + reactionKey = meta.ReactionKey + } + return reaction.SenderID, bridgeReactionKeyFromPlatform(reactionKey) + } + } + } + senderID, reactionKey := splitStandardIMessageReactionID(reactionID) + return senderID, bridgeReactionKeyFromPlatform(reactionKey) +} + +func (c *Client) handleThreadStateSync(evt imessage.StateSyncEvent) error { + if evt.MutationType == "delete" { + var ids []string + if err := json.Unmarshal(evt.Entries, &ids); err != nil { + return err + } + for _, threadID := range ids { + c.UserLogin.QueueRemoteEvent(&simplevent.ChatDelete{ + EventMeta: c.baseEventMeta(threadID).WithType(bridgev2.RemoteEventChatDelete), + OnlyForMe: true, + }) + } + return nil + } + + var threads []imessage.Thread + if err := json.Unmarshal(evt.Entries, &threads); err != nil { + return err + } + for _, thread := range threads { + thread := thread + c.reIDKnownSyntheticPortals(context.Background(), thread) + c.queueThreadReadState(thread) + c.UserLogin.QueueRemoteEvent(&simplevent.ChatResync{ + EventMeta: c.baseEventMeta(thread.ID).WithType(bridgev2.RemoteEventChatResync), + ChatInfo: c.chatInfoFromThread(thread), + LatestMessageTS: threadLatestMessageTimestamp(thread), + }) + } + return nil +} + +func (c *Client) queueThreadReadState(thread imessage.Thread) { + unread := thread.IsUnread + if thread.IsMarkedUnread != nil { + unread = *thread.IsMarkedUnread + } + c.UserLogin.QueueRemoteEvent(&simplevent.MarkUnread{ + EventMeta: c.baseEventMeta(thread.ID).WithType(bridgev2.RemoteEventMarkUnread), + Unread: unread, + }) + if thread.LastReadMessageID != "" && !unread { + readUpTo := messageTimestamp(imessage.Message{Timestamp: thread.Timestamp}) + if thread.LastReadMessageSortKey > 0 { + readUpTo = messageTimestamp(imessage.Message{Timestamp: thread.LastReadMessageSortKey}) + } + c.UserLogin.QueueRemoteEvent(&simplevent.Receipt{ + EventMeta: c.baseEventMeta(thread.ID).WithType(bridgev2.RemoteEventReadReceipt), + LastTarget: imessageid.MakeMessageID(thread.LastReadMessageID), + Targets: []networkid.MessageID{imessageid.MakeMessageID(thread.LastReadMessageID)}, + ReadUpTo: readUpTo, + }) + } +} + +func (c *Client) queueMessageActionChange(ctx context.Context, message imessage.Message) { + if message.Action == nil || message.ThreadID == "" { + return + } + if message.Action.Type == "thread_img_changed" { + c.queueThreadResync(ctx, message.ThreadID) + return + } + change := c.chatInfoChangeFromAction(*message.Action) + if change == nil { + return + } + c.UserLogin.QueueRemoteEvent(&simplevent.ChatInfoChange{ + EventMeta: c.eventMeta(message.ThreadID, message).WithType(bridgev2.RemoteEventChatInfoChange), + ChatInfoChange: change, + }) +} + +func (c *Client) chatInfoChangeFromAction(action imessage.MessageAction) *bridgev2.ChatInfoChange { + switch action.Type { + case "thread_title_updated", "group_thread_created": + return &bridgev2.ChatInfoChange{ + ChatInfo: &bridgev2.ChatInfo{Name: &action.Title}, + } + case "thread_participants_added", "thread_participants_removed": + membership := event.MembershipJoin + prevMembership := event.MembershipLeave + if action.Type == "thread_participants_removed" { + membership = event.MembershipLeave + prevMembership = event.MembershipJoin + } + members := bridgev2.ChatMemberMap{} + participants := map[string]imessage.User{} + for _, participant := range action.Participants { + participants[participant.ID] = participant + } + for _, participantID := range action.ParticipantIDs { + if participantID == "" { + continue + } + user := participants[participantID] + if user.ID == "" { + user = imessage.User{ID: participantID, Username: participantID} + } + userID := imessageid.MakeUserID(participantID) + members.Set(bridgev2.ChatMember{ + EventSender: bridgev2.EventSender{Sender: userID}, + Membership: membership, + PrevMembership: prevMembership, + UserInfo: c.userInfoFromUser(user), + MemberSender: bridgev2.EventSender{Sender: imessageid.MakeUserID(action.ActorParticipantID)}, + }) + } + if len(members) == 0 { + return nil + } + return &bridgev2.ChatInfoChange{ + MemberChanges: &bridgev2.ChatMemberList{MemberMap: members}, + } + default: + return nil + } +} + +func (c *Client) reIDKnownSyntheticPortals(ctx context.Context, thread imessage.Thread) { + for _, sourceID := range syntheticPortalIDsForThread(thread, c.currentUserIdentifiers()) { + c.reIDPortal(ctx, sourceID, thread.ID) + } +} + +func (c *Client) reIDPortal(ctx context.Context, sourceID, targetID string) { + if sourceID == "" || targetID == "" || sourceID == targetID { + return + } + _, _, err := c.Main.Bridge.ReIDPortal(ctx, portalKey(sourceID, c.UserLogin.ID), portalKey(targetID, c.UserLogin.ID)) + if err != nil { + c.UserLogin.Log.Warn(). + Err(err). + Str("source_thread_id", sourceID). + Str("target_thread_id", targetID). + Msg("Failed to re-ID provisional iMessage portal") + } +} + +func syntheticPortalIDsForThread(thread imessage.Thread, selfIdentifierMaps ...map[string]bool) []string { + selfIdentifiers := map[string]bool{} + if len(selfIdentifierMaps) > 0 && selfIdentifierMaps[0] != nil { + selfIdentifiers = selfIdentifierMaps[0] + } + participants := make([]string, 0, len(thread.Participants.Items)) + for _, participant := range thread.Participants.Items { + participantID := normalizeIMessageIdentifier(participant.ID) + canonicalParticipantID := canonicalIMessageIdentifier(participantID) + if participantID != "" && !isSelfParticipant(participant) && + !selfIdentifiers[participantID] && !selfIdentifiers[canonicalParticipantID] { + participants = append(participants, canonicalPortalParticipantID(participant.ID)) + } + } + if len(participants) == 0 { + return nil + } + sort.Strings(participants) + joined := strings.Join(participants, ",") + if len(participants) == 1 { + return []string{ + "any;-;" + joined, + "iMessage;-;" + joined, + "SMS;-;" + joined, + } + } + return []string{"group;-;" + joined} +} + +func (c *Client) eventMeta(threadID string, message imessage.Message) simplevent.EventMeta { + meta := c.baseEventMeta(threadID) + meta.Type = bridgev2.RemoteEventMessage + meta.Timestamp = messageTimestamp(message) + meta.Sender = c.sender(message) + meta.LogContext = func(zctx zerolog.Context) zerolog.Context { + return zctx.Str("thread_id", threadID).Str("message_id", message.ID) + } + return meta +} + +func (c *Client) baseEventMeta(threadID string) simplevent.EventMeta { + return simplevent.EventMeta{ + PortalKey: portalKey(threadID, c.UserLogin.ID), + CreatePortal: true, + Sender: bridgev2.EventSender{ + IsFromMe: true, + SenderLogin: c.UserLogin.ID, + Sender: c.GetUserID(), + }, + LogContext: func(zctx zerolog.Context) zerolog.Context { + return zctx.Str("thread_id", threadID) + }, + } +} + +func (c *Client) sender(message imessage.Message) bridgev2.EventSender { + if message.IsSender != nil && *message.IsSender { + return bridgev2.EventSender{ + IsFromMe: true, + SenderLogin: c.UserLogin.ID, + Sender: c.GetUserID(), + } + } + if message.SenderID != "" { + return bridgev2.EventSender{Sender: imessageid.MakeUserID(message.SenderID)} + } + return bridgev2.EventSender{ + IsFromMe: true, + SenderLogin: c.UserLogin.ID, + Sender: c.GetUserID(), + } +} + +func replyTarget(message imessage.Message) *networkid.MessageOptionalPartID { + if message.LinkedMessageID == "" { + return nil + } + return &networkid.MessageOptionalPartID{ + MessageID: imessageid.MakeMessageID(message.LinkedMessageID), + } +} diff --git a/pkg/connector/handlematrix.go b/pkg/connector/handlematrix.go new file mode 100644 index 00000000..22425f5e --- /dev/null +++ b/pkg/connector/handlematrix.go @@ -0,0 +1,543 @@ +package connector + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/beeper/platform-imessage/pkg/imessage" + "github.com/beeper/platform-imessage/pkg/imessageid" + "github.com/rs/zerolog" + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/database" + "maunium.net/go/mautrix/bridgev2/networkid" + "maunium.net/go/mautrix/event" +) + +var ( + _ bridgev2.EditHandlingNetworkAPI = (*Client)(nil) + _ bridgev2.ReactionHandlingNetworkAPI = (*Client)(nil) + _ bridgev2.RedactionHandlingNetworkAPI = (*Client)(nil) + _ bridgev2.ReadReceiptHandlingNetworkAPI = (*Client)(nil) + _ bridgev2.ChatViewingNetworkAPI = (*Client)(nil) + _ bridgev2.TypingHandlingNetworkAPI = (*Client)(nil) + _ bridgev2.MarkedUnreadHandlingNetworkAPI = (*Client)(nil) + _ bridgev2.MuteHandlingNetworkAPI = (*Client)(nil) + _ bridgev2.DeleteChatHandlingNetworkAPI = (*Client)(nil) +) + +func (c *Client) HandleMatrixMessage(ctx context.Context, msg *bridgev2.MatrixMessage) (*bridgev2.MatrixMessageResponse, error) { + var ( + sent []imessage.Message + err error + ) + log := zerolog.Ctx(ctx).With(). + Str("imessage_portal_id", string(msg.Portal.ID)). + Str("imessage_msgtype", string(msg.Content.MsgType)). + Logger() + + if err := c.ensureAccessibilityForSending(); err != nil { + return nil, err + } + log.Debug().Msg("iMessage outgoing permission preflight passed") + + quotedMessageID := "" + if msg.ReplyTo != nil { + quotedMessageID = platformMessageID(msg.ReplyTo) + } + + if msg.Content.MsgType.IsText() { + if shouldCreateChatForOutgoingText(string(msg.Portal.ID), quotedMessageID) { + log.Debug(). + Strs("imessage_recipients", recipientsFromThreadID(string(msg.Portal.ID))). + Msg("Starting synthetic iMessage chat from outgoing text") + sent, err = c.createChatFromSyntheticPortal(ctx, string(msg.Portal.ID), msg.Content.Body, msg.Portal.Name) + } else { + log.Debug().Str("quoted_message_id", quotedMessageID).Msg("Sending iMessage text") + sent, err = c.IM.SendText(string(msg.Portal.ID), msg.Content.Body, quotedMessageID) + } + } else if msg.Content.MsgType.IsMedia() { + if quotedMessageID == "" && len(recipientsFromThreadID(string(msg.Portal.ID))) > 0 { + return nil, matrixUnsupported("new iMessage chats must be started with a text message before sending attachments") + } + var tempPath string + tempPath, err = c.downloadMatrixMedia(ctx, msg) + if err == nil { + defer os.Remove(tempPath) + sent, err = c.IM.SendFile(string(msg.Portal.ID), tempPath, quotedMessageID) + } + } else { + return nil, matrixUnsupported("unsupported iMessage Matrix message type") + } + if err != nil { + return nil, c.decorateAutomationError(err) + } + log.Debug().Int("sent_count", len(sent)).Msg("iMessage outgoing send returned") + + first, err := firstSentMessage(sent) + if err != nil { + return nil, err + } + + return &bridgev2.MatrixMessageResponse{ + DB: &database.Message{ + ID: imessageid.MakeMessageID(first.ID), + SenderID: c.GetUserID(), + Timestamp: messageTimestamp(*first), + Metadata: &imessageid.MessageMetadata{ + ThreadID: responseThreadID(string(msg.Portal.ID), *first), + }, + }, + }, nil +} + +func (c *Client) ensureAccessibilityForSending() error { + if c.Main.Config.ShouldSkipPermissionValidation() { + return nil + } + + authStatus, err := c.IM.AuthorizationStatus() + if err != nil { + return err + } + for _, permission := range authStatus.Permissions { + if permission.ID != "accessibility" || permission.Authorized { + continue + } + if _, requestErr := c.IM.RequestAuthorization("accessibility"); requestErr != nil { + return requestErr + } + return errors.New("Accessibility permission is required to send iMessages. Enable this bridge in System Settings > Privacy & Security > Accessibility, then retry sending") + } + return nil +} + +func shouldCreateChatForOutgoingText(portalID, quotedMessageID string) bool { + return quotedMessageID == "" && len(recipientsFromThreadID(portalID)) > 0 +} + +func (c *Client) createChatFromSyntheticPortal(ctx context.Context, threadID, text, title string) ([]imessage.Message, error) { + recipients := recipientsFromThreadID(threadID) + if len(recipients) == 0 { + return nil, errors.New("portal ID does not contain synthetic iMessage recipients") + } + thread, _, err := c.IM.CreateChat(recipients, text, title) + if err != nil { + return nil, err + } + zerolog.Ctx(ctx).Debug(). + Strs("imessage_recipients", recipients). + Bool("has_thread", thread != nil). + Msg("iMessage CreateChat returned") + if thread != nil && sentPartialLastMessageMatches(thread.PartialLastMessage, text) { + if thread.ID != "" && thread.ID != threadID { + c.reIDPortal(ctx, threadID, thread.ID) + } + thread.PartialLastMessage.ThreadID = thread.ID + return []imessage.Message{*thread.PartialLastMessage}, nil + } + return c.findRecentlyCreatedMessage(threadID, text) +} + +func sentPartialLastMessageMatches(message *imessage.Message, text string) bool { + if message == nil || message.Text != text { + return false + } + return message.IsSender == nil || *message.IsSender +} + +func responseThreadID(portalID string, msg imessage.Message) string { + if msg.ThreadID != "" { + return msg.ThreadID + } + return portalID +} + +func (c *Client) findRecentlyCreatedMessage(oldThreadID, text string) ([]imessage.Message, error) { + startedAt := time.Now().Add(-5 * time.Second) + expectedParticipants := canonicalParticipantSet(recipientsFromThreadID(oldThreadID)) + selfIdentifiers := c.currentUserIdentifiers() + for attempt := 0; attempt < 20; attempt++ { + page, err := c.IM.SearchMessages(text, "", nil, 20) + if err != nil { + return nil, err + } + for _, msg := range page.Items { + if msg.Text != text || msg.ThreadID == "" || messageTimestamp(msg).Before(startedAt) { + continue + } + if msg.IsSender != nil && !*msg.IsSender { + continue + } + if len(expectedParticipants) > 0 { + thread, err := c.IM.Chat(msg.ThreadID) + if err != nil || thread == nil || !participantSetMatchesThread(expectedParticipants, *thread, selfIdentifiers) { + continue + } + } + c.reIDPortal(context.Background(), oldThreadID, msg.ThreadID) + return []imessage.Message{msg}, nil + } + time.Sleep(250 * time.Millisecond) + } + return nil, errors.New("created iMessage chat, but could not find the sent message") +} + +func recipientsFromThreadID(threadID string) []string { + if !strings.HasPrefix(threadID, "any;-;") && !strings.HasPrefix(threadID, "group;-;") { + return nil + } + parts := strings.Split(threadID, ";-;") + if len(parts) < 2 { + return nil + } + raw := parts[len(parts)-1] + var recipients []string + for _, participant := range strings.Split(raw, ",") { + participant = strings.TrimSpace(participant) + if participant != "" { + recipients = append(recipients, participant) + } + } + return recipients +} + +func (c *Client) downloadMatrixMedia(ctx context.Context, msg *bridgev2.MatrixMessage) (string, error) { + data, err := c.Main.Bridge.Bot.DownloadMedia(ctx, msg.Content.URL, msg.Content.File) + if err != nil { + return "", err + } + + ext := filepath.Ext(msg.Content.FileName) + file, err := os.CreateTemp("", "mautrix-imessage-*"+ext) + if err != nil { + return "", err + } + path := file.Name() + if _, err = file.Write(data); err != nil { + file.Close() + os.Remove(path) + return "", err + } + if err = file.Close(); err != nil { + os.Remove(path) + return "", err + } + return path, nil +} + +func (c *Client) HandleMatrixEdit(ctx context.Context, msg *bridgev2.MatrixEdit) error { + return c.IM.Edit(string(msg.Portal.ID), platformMessageID(msg.EditTarget), msg.Content.Body) +} + +func (c *Client) PreHandleMatrixReaction(ctx context.Context, msg *bridgev2.MatrixReaction) (bridgev2.MatrixReactionPreResponse, error) { + emoji := matrixReactionKey(msg) + platformKey, ok := platformReactionKeyFromBridge(emoji) + if !ok { + return bridgev2.MatrixReactionPreResponse{}, unsupportedIMessageReaction(emoji) + } + emojiID := c.makeOwnReactionID(platformKey) + return bridgev2.MatrixReactionPreResponse{ + SenderID: c.GetUserID(), + EmojiID: emojiID, + Emoji: emoji, + MaxReactions: 1, + }, nil +} + +func (c *Client) HandleMatrixReaction(ctx context.Context, msg *bridgev2.MatrixReaction) (*database.Reaction, error) { + emoji := matrixReactionKey(msg) + platformKey, ok := platformReactionKeyFromBridge(emoji) + if !ok { + return nil, unsupportedIMessageReaction(emoji) + } + emojiID := c.makeOwnReactionID(platformKey) + if err := c.removeSupersededIMessageReactions(ctx, msg, emojiID); err != nil { + return nil, err + } + err := c.IM.React(string(msg.Portal.ID), platformMessageID(msg.TargetMessage), platformKey, true) + if err != nil { + return nil, err + } + return &database.Reaction{ + SenderID: c.GetUserID(), + EmojiID: emojiID, + Emoji: emoji, + Timestamp: time.Now(), + Metadata: &imessageid.ReactionMetadata{ + ReactionID: string(emojiID), + ReactionKey: platformKey, + }, + }, nil +} + +func matrixReactionKey(msg *bridgev2.MatrixReaction) string { + if msg == nil { + return "" + } + if msg.Content != nil { + if key := strings.TrimSpace(msg.Content.RelatesTo.Key); key != "" { + return normalizeBridgeReactionKey(key) + } + } + if msg.Event == nil { + return "" + } + var raw struct { + RelatesTo struct { + Key string `json:"key"` + } `json:"m.relates_to"` + } + if len(msg.Event.Content.VeryRaw) > 0 && json.Unmarshal(msg.Event.Content.VeryRaw, &raw) == nil { + if key := strings.TrimSpace(raw.RelatesTo.Key); key != "" { + return normalizeBridgeReactionKey(key) + } + } + if relatesTo, ok := msg.Event.Content.Raw["m.relates_to"].(map[string]any); ok { + if key, ok := relatesTo["key"].(string); ok { + return normalizeBridgeReactionKey(key) + } + } + return "" +} + +func unsupportedIMessageReaction(reactionKey string) error { + return matrixUnsupported(fmt.Sprintf("unsupported iMessage reaction %q (%x)", reactionKey, []byte(reactionKey))) +} + +func (c *Client) removeSupersededIMessageReactions(ctx context.Context, msg *bridgev2.MatrixReaction, newEmojiID networkid.EmojiID) error { + if c.Main == nil || c.Main.Bridge == nil || c.Main.Bridge.DB == nil || c.Main.Bridge.DB.Reaction == nil || msg == nil || msg.TargetMessage == nil { + return nil + } + oldReactions, err := c.Main.Bridge.DB.Reaction.GetAllToMessageBySender(ctx, receiver(c.UserLogin), msg.TargetMessage.ID, c.GetUserID()) + if err != nil { + return err + } + for _, oldReaction := range oldReactions { + if !shouldRemoveSupersededReaction(oldReaction, msg.ExistingReactionsToKeep, newEmojiID) { + continue + } + reactionKey := reactionKeyFromDBReaction(oldReaction) + if reactionKey == "" { + continue + } + if err := c.IM.React(string(msg.Portal.ID), platformReactionMessageID(oldReaction), reactionKey, false); err != nil { + return err + } + } + return nil +} + +func shouldRemoveSupersededReaction(reaction *database.Reaction, keep []*database.Reaction, newEmojiID networkid.EmojiID) bool { + if reaction == nil || reaction.EmojiID == newEmojiID { + return false + } + for _, kept := range keep { + if kept == nil { + continue + } + if kept.MessageID == reaction.MessageID && + kept.MessagePartID == reaction.MessagePartID && + kept.SenderID == reaction.SenderID && + kept.EmojiID == reaction.EmojiID { + return false + } + } + return true +} + +func (c *Client) HandleMatrixReactionRemove(ctx context.Context, msg *bridgev2.MatrixReactionRemove) error { + return c.IM.React( + string(msg.Portal.ID), + platformReactionMessageID(msg.TargetReaction), + reactionKeyFromDBReaction(msg.TargetReaction), + false, + ) +} + +func (c *Client) HandleMatrixMessageRemove(ctx context.Context, msg *bridgev2.MatrixMessageRemove) error { + return c.IM.DeleteMessage(string(msg.Portal.ID), platformMessageID(msg.TargetMessage)) +} + +func (c *Client) HandleMatrixReadReceipt(ctx context.Context, msg *bridgev2.MatrixReadReceipt) error { + if msg.Portal == nil { + return nil + } + err := c.IM.MarkRead(string(msg.Portal.ID)) + if shouldIgnoreBestEffortAutomationError(err) { + zerolog.Ctx(ctx).Warn().Err(err).Str("imessage_portal_id", string(msg.Portal.ID)).Msg("Ignoring best-effort iMessage read receipt failure") + return nil + } + return err +} + +func (c *Client) HandleMatrixViewingChat(ctx context.Context, msg *bridgev2.MatrixViewingChat) error { + if msg.Portal == nil { + return nil + } + if err := c.IM.MarkRead(string(msg.Portal.ID)); err != nil { + if shouldIgnoreBestEffortAutomationError(err) { + zerolog.Ctx(ctx).Warn().Err(err).Str("imessage_portal_id", string(msg.Portal.ID)).Msg("Ignoring best-effort iMessage viewing-chat read failure") + return c.IM.WatchChat(string(msg.Portal.ID)) + } + return err + } + return c.IM.WatchChat(string(msg.Portal.ID)) +} + +func (c *Client) HandleMatrixTyping(ctx context.Context, msg *bridgev2.MatrixTyping) error { + return c.IM.Typing(string(msg.Portal.ID), msg.IsTyping) +} + +func (c *Client) HandleMarkedUnread(ctx context.Context, msg *bridgev2.MatrixMarkedUnread) error { + if msg.Content.Unread { + return c.IM.MarkUnread(string(msg.Portal.ID)) + } + return c.IM.MarkRead(string(msg.Portal.ID)) +} + +func (c *Client) HandleMute(ctx context.Context, msg *bridgev2.MatrixMute) error { + return c.IM.Mute(string(msg.Portal.ID), msg.Content.IsMuted()) +} + +func (c *Client) HandleMatrixDeleteChat(ctx context.Context, msg *bridgev2.MatrixDeleteChat) error { + if msg.Content.DeleteForEveryone { + return matrixUnsupported("iMessage cannot delete chats for everyone") + } + return c.IM.DeleteChat(string(msg.Portal.ID)) +} + +func shouldIgnoreBestEffortAutomationError(err error) bool { + if err == nil { + return false + } + errText := err.Error() + return strings.Contains(errText, "Could not get main Messages window") || + strings.Contains(errText, "Initialized MessagesController in an invalid state") +} + +func (c *Client) decorateAutomationError(err error) error { + if err == nil || !shouldIgnoreBestEffortAutomationError(err) { + return err + } + authStatus, statusErr := c.IM.AuthorizationStatus() + if statusErr != nil || authStatus == nil || authStatus.Automation.Message == "" { + return err + } + if state := bridgeStateForAutomationStatus(authStatus.Automation); state != nil { + c.UserLogin.BridgeState.Send(*state) + } + return fmt.Errorf("%s: %w", authStatus.Automation.Message, err) +} + +func (c *Client) makeOwnReactionID(reactionKey string) networkid.EmojiID { + return networkid.EmojiID(string(c.GetUserID()) + reactionKey) +} + +func reactionKeyFromDBReaction(reaction *database.Reaction) string { + if reaction == nil { + return "" + } + if meta, ok := reaction.Metadata.(*imessageid.ReactionMetadata); ok && meta.ReactionKey != "" { + return meta.ReactionKey + } + if reaction.Emoji != "" { + if platformKey, ok := platformReactionKeyFromBridge(reaction.Emoji); ok { + return platformKey + } + return reaction.Emoji + } + _, reactionKey := splitStandardIMessageReactionID(string(reaction.EmojiID)) + return reactionKey +} + +func platformMessageID(message *database.Message) string { + if message == nil { + return "" + } + return platformMessageIDParts(message.ID, message.PartID) +} + +func platformReactionMessageID(reaction *database.Reaction) string { + if reaction == nil { + return "" + } + return platformMessageIDParts(reaction.MessageID, reaction.MessagePartID) +} + +func platformMessageIDParts(messageID networkid.MessageID, partID networkid.PartID) string { + if messageID == "" { + return "" + } + if platformMessageIDHasPart(string(messageID)) { + return string(messageID) + } + if partID == "" { + return string(messageID) + } + return string(messageID) + "_" + string(partID) +} + +func platformMessageIDHasPart(messageID string) bool { + _, part, ok := strings.Cut(messageID, "_") + if !ok || part == "" { + return false + } + for _, char := range part { + if char < '0' || char > '9' { + return false + } + } + return true +} + +func messageTextContentFromIMessage(msg imessage.Message) *event.MessageEventContent { + body := msg.Text + if body == "" && len(msg.Attachments) > 0 { + body = msg.Attachments[0].FileName + } + if body == "" { + body = "Unsupported iMessage event" + } + return &event.MessageEventContent{ + MsgType: event.MsgText, + Body: body, + } +} + +func (c *Client) messageTextContentFromIMessage(ctx context.Context, msg imessage.Message) *event.MessageEventContent { + content := messageTextContentFromIMessage(msg) + content.Mentions = c.mentionsFromIMessage(ctx, msg) + content.BeeperLinkPreviews = linkPreviewsFromIMessage(msg) + return content +} + +func linkPreviewsFromIMessage(msg imessage.Message) []*event.BeeperLinkPreview { + if len(msg.Links) == 0 { + return nil + } + previews := make([]*event.BeeperLinkPreview, 0, len(msg.Links)) + for _, link := range msg.Links { + if link.URL == "" && link.OriginalURL == "" { + continue + } + matched := link.OriginalURL + if matched == "" { + matched = link.URL + } + previews = append(previews, &event.BeeperLinkPreview{ + MatchedURL: matched, + LinkPreview: event.LinkPreview{ + CanonicalURL: link.URL, + Title: link.Title, + Description: link.Summary, + }, + }) + } + return previews +} diff --git a/pkg/connector/integration_logic_test.go b/pkg/connector/integration_logic_test.go new file mode 100644 index 00000000..a9a25989 --- /dev/null +++ b/pkg/connector/integration_logic_test.go @@ -0,0 +1,1091 @@ +package connector + +import ( + "encoding/base64" + "encoding/json" + "errors" + "reflect" + "strings" + "testing" + "time" + + "github.com/beeper/platform-imessage/pkg/imessage" + "github.com/beeper/platform-imessage/pkg/imessageid" + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/commands" + "maunium.net/go/mautrix/bridgev2/database" + "maunium.net/go/mautrix/bridgev2/networkid" + "maunium.net/go/mautrix/bridgev2/status" + "maunium.net/go/mautrix/event" +) + +func testClient() *Client { + return &Client{ + UserLogin: &bridgev2.UserLogin{ + UserLogin: &database.UserLogin{ID: networkid.UserLoginID("self")}, + }, + } +} + +func TestParseCreateChatCommand(t *testing.T) { + recipients, message := parseCreateChatCommand(&commands.Event{ + RawArgs: "+15557654321 +15551234567 -- hello from Matrix", + Args: []string{"+15557654321", "+15551234567", "--", "hello", "from", "Matrix"}, + }) + if !reflect.DeepEqual(recipients, []string{"+15551234567", "+15557654321"}) { + t.Fatalf("unexpected recipients: %#v", recipients) + } + if message != "hello from Matrix" { + t.Fatalf("unexpected initial message: %q", message) + } + + recipients, message = parseCreateChatCommand(&commands.Event{ + RawArgs: "alice@example.com -m hello again", + Args: []string{"alice@example.com", "-m", "hello", "again"}, + }) + if !reflect.DeepEqual(recipients, []string{"alice@example.com"}) { + t.Fatalf("unexpected -m recipients: %#v", recipients) + } + if message != "hello again" { + t.Fatalf("unexpected -m message: %q", message) + } +} + +func TestSyntheticPortalIDsForThread(t *testing.T) { + isSelf := true + dmIDs := syntheticPortalIDsForThread(imessage.Thread{ + Participants: imessage.Page[imessage.User]{Items: []imessage.User{ + {ID: "+15551234567"}, + {ID: "me@example.com", IsSelf: &isSelf}, + }}, + }) + if !reflect.DeepEqual(dmIDs, []string{ + "any;-;+15551234567", + "iMessage;-;+15551234567", + "SMS;-;+15551234567", + }) { + t.Fatalf("unexpected DM synthetic IDs: %#v", dmIDs) + } + + groupIDs := syntheticPortalIDsForThread(imessage.Thread{ + Participants: imessage.Page[imessage.User]{Items: []imessage.User{ + {ID: "b@example.com"}, + {ID: "me@example.com", IsSelf: &isSelf}, + {ID: "a@example.com"}, + }}, + }) + if !reflect.DeepEqual(groupIDs, []string{"group;-;a@example.com,b@example.com"}) { + t.Fatalf("unexpected group synthetic IDs: %#v", groupIDs) + } + + groupIDs = syntheticPortalIDsForThread(imessage.Thread{ + Participants: imessage.Page[imessage.User]{Items: []imessage.User{ + {ID: "b@example.com"}, + {ID: "me@example.com"}, + {ID: "a@example.com"}, + }}, + }, map[string]bool{"me@example.com": true}) + if !reflect.DeepEqual(groupIDs, []string{"group;-;a@example.com,b@example.com"}) { + t.Fatalf("unmarked self identifier should not be part of synthetic group ID: %#v", groupIDs) + } + + groupIDs = syntheticPortalIDsForThread(imessage.Thread{ + Participants: imessage.Page[imessage.User]{Items: []imessage.User{ + {ID: "Bob@Example.com"}, + {ID: "Me@Example.com"}, + {ID: "alice@example.com"}, + }}, + }, map[string]bool{"me@example.com": true}) + if !reflect.DeepEqual(groupIDs, []string{"group;-;alice@example.com,bob@example.com"}) { + t.Fatalf("synthetic group IDs should be canonicalized: %#v", groupIDs) + } +} + +func TestParticipantSetMatchesExistingGroup(t *testing.T) { + isSelf := true + thread := imessage.Thread{ + ID: "any;+;chat123", + Type: imessage.ThreadTypeGroup, + Participants: imessage.Page[imessage.User]{Items: []imessage.User{ + {ID: "Alice@Example.com"}, + {ID: "+15551234567"}, + {ID: "me@example.com", IsSelf: &isSelf}, + }}, + } + expected := canonicalParticipantSet([]string{"+15551234567", "alice@example.com"}) + if !participantSetMatchesThread(expected, thread, map[string]bool{"me@example.com": true}) { + t.Fatal("expected existing group participants to match case-insensitively while ignoring self") + } + + missing := canonicalParticipantSet([]string{"+15551234567", "bob@example.com"}) + if participantSetMatchesThread(missing, thread, map[string]bool{"me@example.com": true}) { + t.Fatal("group with different participants must not match") + } +} + +func TestReactionActionMessagesAreNotBridgedAsText(t *testing.T) { + reactionCreated := imessage.Message{ + ID: "reaction-action", + Action: &imessage.MessageAction{ + Type: "message_reaction_created", + }, + } + if !isReactionActionMessage(reactionCreated) { + t.Fatal("reaction-created action messages should be suppressed as text") + } + + reactionDeleted := imessage.Message{ + ID: "reaction-delete-action", + Action: &imessage.MessageAction{ + Type: "message_reaction_deleted", + }, + } + if !isReactionActionMessage(reactionDeleted) { + t.Fatal("reaction-deleted action messages should be suppressed as text") + } + + titleChange := imessage.Message{ + ID: "title-action", + Action: &imessage.MessageAction{ + Type: "thread_title_updated", + Title: "New title", + }, + } + if isReactionActionMessage(titleChange) { + t.Fatal("non-reaction action messages must not be suppressed by the reaction filter") + } +} + +func TestIMessageIdentifierValidationAndGhostDM(t *testing.T) { + conn := &Connector{} + validIDs := []networkid.UserID{ + networkid.UserID("+15551234567"), + networkid.UserID("alice@example.com"), + } + for _, id := range validIDs { + if !conn.ValidateUserID(id) { + t.Fatalf("expected %q to be a valid iMessage user ID", id) + } + } + invalidIDs := []networkid.UserID{ + networkid.UserID(""), + networkid.UserID("group;-;alice@example.com,bob@example.com"), + networkid.UserID("alice@example.com,bob@example.com"), + networkid.UserID("alice\n@example.com"), + } + for _, id := range invalidIDs { + if conn.ValidateUserID(id) { + t.Fatalf("expected %q to be rejected as an iMessage user ID", id) + } + } + + client := testClient() + resp, err := client.CreateChatWithGhost(t.Context(), &bridgev2.Ghost{Ghost: &database.Ghost{ID: networkid.UserID("alice@example.com")}}) + if err != nil { + t.Fatal(err) + } + if resp == nil || resp.PortalKey.ID != "any;-;alice@example.com" { + t.Fatalf("unexpected ghost DM response: %#v", resp) + } + + resolved, err := client.ResolveIdentifier(t.Context(), "alice@example.com", true) + if err != nil { + t.Fatal(err) + } + if resolved == nil || resolved.UserID != "alice@example.com" || resolved.Chat == nil || resolved.Chat.PortalKey.ID != "any;-;alice@example.com" { + t.Fatalf("unexpected email resolve response: %#v", resolved) + } +} + +func TestConvertedMessagePartIDsAreStable(t *testing.T) { + parts := []*bridgev2.ConvertedMessagePart{ + {Content: &event.MessageEventContent{Body: "text"}}, + {Content: &event.MessageEventContent{Body: "image.jpg"}}, + {Content: &event.MessageEventContent{Body: "file.pdf"}}, + } + setConvertedMessagePartIDs(parts) + if parts[0].ID != "" || parts[1].ID != "1" || parts[2].ID != "2" { + t.Fatalf("unexpected part IDs: %q %q %q", parts[0].ID, parts[1].ID, parts[2].ID) + } +} + +func TestFileInfoForAttachmentIncludesImageDimensions(t *testing.T) { + const png1x2Base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAACCAYAAACZgbYnAAAADUlEQVR4nGNgYGD4DwABBAEAghnFoQAAAABJRU5ErkJggg==" + data, err := base64.StdEncoding.DecodeString(png1x2Base64) + if err != nil { + t.Fatal(err) + } + + info := fileInfoForAttachment(data, imessage.Attachment{}, "image/png") + if info.Size != len(data) { + t.Fatalf("unexpected file size: %d", info.Size) + } + if info.Width != 1 || info.Height != 2 { + t.Fatalf("expected image dimensions in Matrix file info, got %dx%d", info.Width, info.Height) + } +} + +func TestFileInfoForAttachmentSkipsDimensionsForGenericFiles(t *testing.T) { + info := fileInfoForAttachment([]byte("plain text"), imessage.Attachment{}, "text/plain") + if info.Width != 0 || info.Height != 0 { + t.Fatalf("did not expect generic file dimensions, got %dx%d", info.Width, info.Height) + } +} + +func TestResponseThreadIDPrefersRealThread(t *testing.T) { + if got := responseThreadID("group;-;a,b", imessage.Message{ThreadID: "iMessage;-;real-chat"}); got != "iMessage;-;real-chat" { + t.Fatalf("expected real thread ID, got %q", got) + } + if got := responseThreadID("group;-;a,b", imessage.Message{}); got != "group;-;a,b" { + t.Fatalf("expected portal fallback, got %q", got) + } +} + +func TestRecipientsFromThreadIDOnlyAcceptsSyntheticCreationPortals(t *testing.T) { + if got := recipientsFromThreadID("any;-;alice@example.com"); !reflect.DeepEqual(got, []string{"alice@example.com"}) { + t.Fatalf("unexpected any recipients: %#v", got) + } + if got := recipientsFromThreadID("group;-;alice@example.com,bob@example.com"); !reflect.DeepEqual(got, []string{"alice@example.com", "bob@example.com"}) { + t.Fatalf("unexpected group recipients: %#v", got) + } + if got := recipientsFromThreadID("iMessage;-;alice@example.com"); got != nil { + t.Fatalf("real iMessage portal must not be treated as synthetic creation portal: %#v", got) + } + if got := recipientsFromThreadID("SMS;-;+15551234567"); got != nil { + t.Fatalf("real SMS portal must not be treated as synthetic creation portal: %#v", got) + } +} + +func TestChatInfoFromThreadIncludesMembersAndAvatar(t *testing.T) { + client := testClient() + self := true + thread := imessage.Thread{ + ID: "iMessage;-;chat", + Title: "Project", + Type: imessage.ThreadTypeGroup, + ImgURL: "asset://account/group-avatar/1.heic", + Participants: imessage.Page[imessage.User]{Items: []imessage.User{ + {ID: "alice@example.com", FullName: "Alice", ImgURL: "asset://account/alice/1.heic"}, + {ID: "bob@example.com", FullName: "Bob"}, + {ID: "self@example.com", IsSelf: &self}, + }}, + } + + info := client.chatInfoFromThread(thread) + if info.Name == nil || *info.Name != "Project" { + t.Fatalf("unexpected room name: %#v", info.Name) + } + if info.Type == nil || *info.Type != database.RoomTypeDefault { + t.Fatalf("unexpected room type: %#v", info.Type) + } + if info.Avatar == nil || info.Avatar.ID != networkid.AvatarID(thread.ImgURL) { + t.Fatalf("unexpected room avatar: %#v", info.Avatar) + } + if !info.CanBackfill { + t.Fatal("real iMessage thread should advertise bridgev2 backfill support") + } + if info.Members == nil || !info.Members.IsFull || info.Members.TotalMemberCount != 3 { + t.Fatalf("unexpected member list metadata: %#v", info.Members) + } + alice := info.Members.MemberMap[imessageid.MakeUserID("alice@example.com")] + if alice.UserInfo == nil || alice.UserInfo.Name == nil || *alice.UserInfo.Name != "Alice" { + t.Fatalf("unexpected Alice info: %#v", alice.UserInfo) + } + if alice.UserInfo.Avatar == nil || alice.UserInfo.Avatar.ID != "asset://account/alice/1.heic" { + t.Fatalf("unexpected Alice avatar: %#v", alice.UserInfo.Avatar) + } + if _, ok := info.Members.MemberMap[imessageid.MakeUserID("self@example.com")]; ok { + t.Fatal("self participant should not be added as a remote ghost") + } +} + +func TestParticipantsForChatInfoSkipsUnmarkedSelfIdentifier(t *testing.T) { + thread := imessage.Thread{ + ID: "any;+;group", + Type: imessage.ThreadTypeGroup, + Participants: imessage.Page[imessage.User]{Items: []imessage.User{ + {ID: "alice@example.com"}, + {ID: "me@example.com"}, + {ID: "bob@example.com"}, + }}, + } + + participants := participantsForChatInfo(thread, database.RoomTypeDefault, map[string]bool{"me@example.com": true}) + if len(participants) != 2 || participants[0].ID != "alice@example.com" || participants[1].ID != "bob@example.com" { + t.Fatalf("unmarked self identifier should be excluded from chat info participants: %#v", participants) + } +} + +func TestChatInfoFromThreadTreatsGroupThreadIDAsGroupEvenIfTypeIsSingle(t *testing.T) { + client := testClient() + self := true + thread := imessage.Thread{ + ID: "any;+;untitled-group", + Type: imessage.ThreadTypeSingle, + Participants: imessage.Page[imessage.User]{Items: []imessage.User{ + {ID: "alice@example.com"}, + {ID: "bob@example.com"}, + {ID: "self@example.com", IsSelf: &self}, + }}, + } + + info := client.chatInfoFromThread(thread) + if info.Type == nil || *info.Type != database.RoomTypeDefault { + t.Fatalf("multi-remote iMessage thread should be a group, got %#v", info.Type) + } + if info.Members == nil || info.Members.TotalMemberCount != 3 { + t.Fatalf("expected two remote members plus bridge user, got %#v", info.Members) + } +} + +func TestChatInfoFromThreadKeepsMultiHandleSingleAsDM(t *testing.T) { + client := testClient() + thread := imessage.Thread{ + ID: "any;-;alice@example.com", + Type: imessage.ThreadTypeSingle, + Participants: imessage.Page[imessage.User]{Items: []imessage.User{ + {ID: "alice@example.com"}, + {ID: "+15551234567"}, + }}, + } + + info := client.chatInfoFromThread(thread) + if info.Type == nil || *info.Type != database.RoomTypeDM { + t.Fatalf("multi-handle single-recipient thread should stay a DM, got %#v", info.Type) + } + if info.Members == nil || info.Members.TotalMemberCount != 2 { + t.Fatalf("expected one remote member plus bridge user, got %#v", info.Members) + } + if _, ok := info.Members.MemberMap[imessageid.MakeUserID("+15551234567")]; ok { + t.Fatal("alternate DM handle should not be exposed as a second remote member") + } +} + +func TestSyntheticChatResponseDoesNotAdvertiseBackfill(t *testing.T) { + client := testClient() + resp := client.syntheticChatResponse([]string{"alice@example.com"}, "") + if resp.PortalInfo == nil { + t.Fatal("expected portal info") + } + if resp.PortalInfo.CanBackfill { + t.Fatal("synthetic pre-send portal should not advertise backfill until reconciled to a real thread") + } +} + +func TestThreadLatestMessageTimestamp(t *testing.T) { + thread := imessage.Thread{ + Timestamp: 1700000000000, + PartialLastMessage: &imessage.Message{ + Timestamp: 1700000001234, + }, + } + if got, want := threadLatestMessageTimestamp(thread), time.UnixMilli(1700000001234); !got.Equal(want) { + t.Fatalf("expected partial last message timestamp %s, got %s", want, got) + } + + thread.PartialLastMessage = nil + if got, want := threadLatestMessageTimestamp(thread), time.UnixMilli(1700000000000); !got.Equal(want) { + t.Fatalf("expected thread timestamp %s, got %s", want, got) + } + + if got := threadLatestMessageTimestamp(imessage.Thread{}); !got.IsZero() { + t.Fatalf("expected zero timestamp for empty thread, got %s", got) + } +} + +func TestChatInfoChangeFromAction(t *testing.T) { + client := testClient() + + titleChange := client.chatInfoChangeFromAction(imessage.MessageAction{ + Type: "thread_title_updated", + Title: "New title", + }) + if titleChange == nil || titleChange.ChatInfo == nil || titleChange.ChatInfo.Name == nil || *titleChange.ChatInfo.Name != "New title" { + t.Fatalf("unexpected title change: %#v", titleChange) + } + + addChange := client.chatInfoChangeFromAction(imessage.MessageAction{ + Type: "thread_participants_added", + ActorParticipantID: "admin@example.com", + ParticipantIDs: []string{"new@example.com"}, + Participants: []imessage.User{{ID: "new@example.com", FullName: "New User"}}, + }) + added := addChange.MemberChanges.MemberMap[imessageid.MakeUserID("new@example.com")] + if added.Membership != event.MembershipJoin || added.PrevMembership != event.MembershipLeave { + t.Fatalf("unexpected add membership: %#v", added) + } + if added.UserInfo == nil || added.UserInfo.Name == nil || *added.UserInfo.Name != "New User" { + t.Fatalf("unexpected added user info: %#v", added.UserInfo) + } + + removeChange := client.chatInfoChangeFromAction(imessage.MessageAction{ + Type: "thread_participants_removed", + ParticipantIDs: []string{"old@example.com"}, + }) + removed := removeChange.MemberChanges.MemberMap[imessageid.MakeUserID("old@example.com")] + if removed.Membership != event.MembershipLeave || removed.PrevMembership != event.MembershipJoin { + t.Fatalf("unexpected remove membership: %#v", removed) + } +} + +func TestBackfillPagination(t *testing.T) { + cursorPage := backfillPagination(bridgev2.FetchMessagesParams{ + Cursor: networkid.PaginationCursor("cursor-1"), + Forward: true, + Count: 37, + }) + if cursorPage == nil || cursorPage.Cursor != "cursor-1" || cursorPage.Direction != "after" || cursorPage.Limit != 37 { + t.Fatalf("unexpected cursor pagination: %#v", cursorPage) + } + + anchorPage := backfillPagination(bridgev2.FetchMessagesParams{ + AnchorMessage: &database.Message{ + ID: networkid.MessageID("message-id"), + Metadata: &imessageid.MessageMetadata{Cursor: "message-cursor"}, + }, + }) + if anchorPage == nil || anchorPage.Cursor != "message-cursor" || anchorPage.Direction != "before" { + t.Fatalf("unexpected anchor pagination: %#v", anchorPage) + } + + fallbackPage := backfillPagination(bridgev2.FetchMessagesParams{ + AnchorMessage: &database.Message{ID: networkid.MessageID("message-id")}, + }) + if fallbackPage == nil || fallbackPage.Cursor != "message-id" || fallbackPage.Direction != "before" { + t.Fatalf("unexpected fallback pagination: %#v", fallbackPage) + } + + repairedPage := backfillPagination(bridgev2.FetchMessagesParams{ + Cursor: networkid.PaginationCursor("02F90C51-B1F7-4C30-8F5D-55CB535130F6"), + AnchorMessage: &database.Message{ + ID: networkid.MessageID("message-id"), + Metadata: &imessageid.MessageMetadata{Cursor: "1696075189006000000"}, + }, + }) + if repairedPage == nil || repairedPage.Cursor != "1696075189006000000" { + t.Fatalf("unexpected repaired pagination cursor: %#v", repairedPage) + } + if !isIMessagePaginationCursor("1696075189006000000") || isIMessagePaginationCursor("message-id") { + t.Fatal("iMessage pagination cursor validation should only accept numeric cursors") + } + + page := &imessage.Page[imessage.Message]{ + OldestCursor: "oldest", + NewestCursor: "newest", + } + if got := nextBackfillCursor(page, false); got != "oldest" { + t.Fatalf("backward backfill should use oldest cursor, got %q", got) + } + if got := nextBackfillCursor(page, true); got != "newest" { + t.Fatalf("forward backfill should use newest cursor, got %q", got) + } + + imessagePage := &imessage.Page[imessage.Message]{ + Items: []imessage.Message{ + {ID: "old-id", Cursor: "old-cursor"}, + {ID: "new-id", Cursor: "new-cursor"}, + }, + } + if got := nextBackfillCursor(imessagePage, false); got != "old-cursor" { + t.Fatalf("backward cursor fallback should use oldest returned message cursor, got %q", got) + } + if got := nextBackfillCursor(imessagePage, true); got != "new-cursor" { + t.Fatalf("forward cursor fallback should use newest returned message cursor, got %q", got) + } + + idOnlyPage := &imessage.Page[imessage.Message]{ + Items: []imessage.Message{{ID: "old-id"}, {ID: "new-id"}}, + } + if got := nextBackfillCursor(idOnlyPage, false); got != "old-id" { + t.Fatalf("messages without platform cursors should fall back to IDs, got %q", got) + } +} + +func TestIMessageReactionMetadataAndDeleteSender(t *testing.T) { + senderID, reactionKey := splitStandardIMessageReactionID("alice@example.comlike") + if senderID != "alice@example.com" || reactionKey != "like" { + t.Fatalf("unexpected like reaction split: %q %q", senderID, reactionKey) + } + + senderID, reactionKey = splitStandardIMessageReactionID("+15551234567emphasize") + if senderID != "+15551234567" || reactionKey != "emphasize" { + t.Fatalf("unexpected emphasize reaction split: %q %q", senderID, reactionKey) + } + + senderID, reactionKey = splitStandardIMessageReactionID("alice@example.comcustom") + if senderID != "" || reactionKey != "" { + t.Fatalf("unexpected custom reaction split without DB metadata: %q %q", senderID, reactionKey) + } + + client := testClient() + senderID, reactionKey = client.reactionSenderAndKey(t.Context(), networkid.MessageID("message-id"), "bob@example.comheart") + if senderID != "bob@example.com" || reactionKey != "❤️" { + t.Fatalf("unexpected fallback reaction sender/key: %q %q", senderID, reactionKey) + } + + backfillReaction := backfillReactionFromIMessage(imessage.Reaction{ + ID: "alice@example.comlike", + ReactionKey: "like", + ParticipantID: "alice@example.com", + }) + if backfillReaction.Sender.Sender != "alice@example.com" || backfillReaction.EmojiID != "alice@example.comlike" || backfillReaction.Emoji != "👍" { + t.Fatalf("unexpected backfill reaction: %#v", backfillReaction) + } + meta, ok := backfillReaction.DBMetadata.(*imessageid.ReactionMetadata) + if !ok || meta.ReactionID != "alice@example.comlike" || meta.ReactionKey != "like" { + t.Fatalf("unexpected backfill reaction metadata: %#v", backfillReaction.DBMetadata) + } +} + +func TestIMessageReactionKeyMapping(t *testing.T) { + for bridgeKey, platformKey := range map[string]string{ + "❤️": "heart", + "❤": "heart", + "👍": "like", + "👍️": "like", + "👎": "dislike", + "👎️": "dislike", + "HAHA": "laugh", + "‼️": "emphasize", + "‼": "emphasize", + "❓": "question", + "❓️": "question", + } { + got, ok := platformReactionKeyFromBridge(bridgeKey) + if !ok || got != platformKey { + t.Fatalf("expected %q to map to %q, got %q ok=%v", bridgeKey, platformKey, got, ok) + } + } + + for platformKey, bridgeKey := range platformReactionToBridgeReaction { + if got := bridgeReactionKeyFromPlatform(platformKey); got != bridgeKey { + t.Fatalf("expected %q to map back to %q, got %q", platformKey, bridgeKey, got) + } + } + + if _, ok := platformReactionKeyFromBridge("😂"); ok { + t.Fatal("custom reactions must not be sent to platform-imessage when capabilities advertise fixed tapbacks only") + } +} + +func TestMatrixReactionKeyReadsParsedAndRawContent(t *testing.T) { + parsed := matrixReactionKey(&bridgev2.MatrixReaction{ + MatrixEventBase: bridgev2.MatrixEventBase[*event.ReactionEventContent]{ + Content: &event.ReactionEventContent{RelatesTo: event.RelatesTo{Key: "👍"}}, + }, + }) + if parsed != "👍" { + t.Fatalf("expected parsed reaction key, got %q", parsed) + } + + variant := matrixReactionKey(&bridgev2.MatrixReaction{ + MatrixEventBase: bridgev2.MatrixEventBase[*event.ReactionEventContent]{ + Content: &event.ReactionEventContent{RelatesTo: event.RelatesTo{Key: "👍️"}}, + }, + }) + if variant != "👍" { + t.Fatalf("expected variation selector reaction key to normalize, got %q", variant) + } + + raw := matrixReactionKey(&bridgev2.MatrixReaction{ + MatrixEventBase: bridgev2.MatrixEventBase[*event.ReactionEventContent]{ + Event: &event.Event{Content: event.Content{VeryRaw: []byte(`{"m.relates_to":{"key":"HAHA"}}`)}}, + }, + }) + if raw != "HAHA" { + t.Fatalf("expected raw reaction key, got %q", raw) + } +} + +func TestReactionKeyFromDBReaction(t *testing.T) { + if got := reactionKeyFromDBReaction(&database.Reaction{ + Emoji: "👍", + Metadata: &imessageid.ReactionMetadata{ + ReactionKey: "like", + }, + }); got != "like" { + t.Fatalf("expected metadata reaction key, got %q", got) + } + + if got := reactionKeyFromDBReaction(&database.Reaction{Emoji: "👍"}); got != "like" { + t.Fatalf("expected visible reaction fallback to become platform key, got %q", got) + } + + if got := reactionKeyFromDBReaction(&database.Reaction{EmojiID: networkid.EmojiID("alice@example.comheart")}); got != "heart" { + t.Fatalf("expected standard reaction ID fallback, got %q", got) + } +} + +func TestShouldRemoveSupersededReaction(t *testing.T) { + old := &database.Reaction{ + MessageID: networkid.MessageID("message-guid"), + MessagePartID: networkid.PartID("1"), + SenderID: networkid.UserID("self"), + EmojiID: networkid.EmojiID("selflike"), + } + if !shouldRemoveSupersededReaction(old, nil, networkid.EmojiID("selfheart")) { + t.Fatal("expected old reaction to be removed when adding a different reaction") + } + if shouldRemoveSupersededReaction(old, nil, networkid.EmojiID("selflike")) { + t.Fatal("new reaction should not remove itself") + } + if shouldRemoveSupersededReaction(old, []*database.Reaction{{ + MessageID: old.MessageID, + MessagePartID: old.MessagePartID, + SenderID: old.SenderID, + EmojiID: old.EmojiID, + }}, networkid.EmojiID("selfheart")) { + t.Fatal("kept reactions must not be removed") + } +} + +func TestPlatformMessageIDPreservesBridgeV2PartID(t *testing.T) { + msg := &database.Message{ + ID: networkid.MessageID("message-guid"), + PartID: networkid.PartID("2"), + } + if got := platformMessageID(msg); got != "message-guid_2" { + t.Fatalf("expected platform part message ID, got %q", got) + } + + msg.PartID = "" + if got := platformMessageID(msg); got != "message-guid" { + t.Fatalf("expected first-part message ID, got %q", got) + } + + reaction := &database.Reaction{ + MessageID: networkid.MessageID("message-guid"), + MessagePartID: networkid.PartID("3"), + } + if got := platformReactionMessageID(reaction); got != "message-guid_3" { + t.Fatalf("expected platform reaction target ID, got %q", got) + } + + if got := platformMessageIDParts(networkid.MessageID("message-guid_1"), networkid.PartID("2")); got != "message-guid_1" { + t.Fatalf("must not double-encode platform message part IDs, got %q", got) + } +} + +func TestEditFromUpdatedMessageUpdatesAllParts(t *testing.T) { + existing := []*database.Message{ + {ID: networkid.MessageID("message-id"), PartID: networkid.PartID("0")}, + {ID: networkid.MessageID("message-id"), PartID: networkid.PartID("1")}, + } + converted := &bridgev2.ConvertedMessage{ + Parts: []*bridgev2.ConvertedMessagePart{ + { + Type: event.EventMessage, + Content: &event.MessageEventContent{ + MsgType: event.MsgText, + Body: "updated text", + }, + }, + { + Type: event.EventMessage, + Content: &event.MessageEventContent{ + MsgType: event.MsgImage, + Body: "loaded.jpg", + }, + }, + { + Type: event.EventMessage, + Content: &event.MessageEventContent{ + MsgType: event.MsgFile, + Body: "added.pdf", + }, + }, + }, + } + + edit, err := editFromUpdatedMessage(existing, converted) + if err != nil { + t.Fatal(err) + } + if len(edit.ModifiedParts) != 2 { + t.Fatalf("expected existing parts to be modified, got %d", len(edit.ModifiedParts)) + } + if edit.ModifiedParts[0].Part != existing[0] || edit.ModifiedParts[0].Content.Body != "updated text" { + t.Fatalf("unexpected first modified part: %#v", edit.ModifiedParts[0]) + } + if edit.ModifiedParts[1].Part != existing[1] || edit.ModifiedParts[1].Content.MsgType != event.MsgImage { + t.Fatalf("unexpected second modified part: %#v", edit.ModifiedParts[1]) + } + if edit.AddedParts == nil || len(edit.AddedParts.Parts) != 1 || edit.AddedParts.Parts[0].Content.Body != "added.pdf" { + t.Fatalf("expected extra converted part to be added, got %#v", edit.AddedParts) + } + if len(edit.DeletedParts) != 0 { + t.Fatalf("did not expect deleted parts: %#v", edit.DeletedParts) + } +} + +func TestEditFromUpdatedMessageDeletesSurplusParts(t *testing.T) { + existing := []*database.Message{ + {ID: networkid.MessageID("message-id"), PartID: networkid.PartID("0")}, + {ID: networkid.MessageID("message-id"), PartID: networkid.PartID("1")}, + } + converted := &bridgev2.ConvertedMessage{ + Parts: []*bridgev2.ConvertedMessagePart{{ + Type: event.EventMessage, + Content: &event.MessageEventContent{ + MsgType: event.MsgText, + Body: "only remaining part", + }, + }}, + } + + edit, err := editFromUpdatedMessage(existing, converted) + if err != nil { + t.Fatal(err) + } + if len(edit.ModifiedParts) != 1 || edit.ModifiedParts[0].Part != existing[0] { + t.Fatalf("unexpected modified parts: %#v", edit.ModifiedParts) + } + if len(edit.DeletedParts) != 1 || edit.DeletedParts[0] != existing[1] { + t.Fatalf("expected surplus part to be deleted, got %#v", edit.DeletedParts) + } + if edit.AddedParts != nil { + t.Fatalf("did not expect added parts: %#v", edit.AddedParts) + } +} + +func TestCapabilitiesAdvertiseSyntheticGroupCreation(t *testing.T) { + conn := &Connector{} + general := conn.GetCapabilities() + if general.ImplicitReadReceipts { + t.Fatal("implicit read receipts should stay disabled because mark-read uses Messages.app automation") + } + if !general.Provisioning.ResolveIdentifier.CreateDM { + t.Fatal("expected CreateDM to be advertised") + } + if !general.Provisioning.ResolveIdentifier.LookupEmail || !general.Provisioning.ResolveIdentifier.AnyPhone { + t.Fatalf("expected email lookup and any-phone capabilities for iMessage identifiers: %#v", general.Provisioning.ResolveIdentifier) + } + if !general.Provisioning.ResolveIdentifier.ContactList || !general.Provisioning.ResolveIdentifier.Search { + t.Fatalf("expected contact list and search capabilities: %#v", general.Provisioning.ResolveIdentifier) + } + groupCaps, ok := general.Provisioning.GroupCreation["group"] + if !ok { + t.Fatalf("expected iMessage synthetic group creation to be advertised: %#v", general.Provisioning.GroupCreation) + } + if !groupCaps.Participants.Allowed || !groupCaps.Participants.Required || groupCaps.Participants.MinLength != 2 { + t.Fatalf("unexpected iMessage group participant capability: %#v", groupCaps.Participants) + } + if !groupCaps.Name.Allowed { + t.Fatalf("expected iMessage group names to be allowed: %#v", groupCaps.Name) + } + + client := testClient() + first := client.GetCapabilities(t.Context(), nil) + second := client.GetCapabilities(t.Context(), nil) + first.DeleteChat = false + if !second.DeleteChat { + t.Fatal("room capabilities must be cloned per call") + } + if first.Formatting[event.FmtBold] != event.CapLevelDropped { + t.Fatalf("formatting should be declared dropped, got %v", first.Formatting[event.FmtBold]) + } + if !reflect.DeepEqual(first.AllowedReactions, supportedIMessageReactions) || first.CustomEmojiReactions { + t.Fatalf("unexpected reaction capability declaration: allowed=%#v custom=%v", first.AllowedReactions, first.CustomEmojiReactions) + } + if first.File[event.MsgImage].MimeTypes["image/jpeg"] != event.CapLevelFullySupported || + first.File[event.MsgImage].MimeTypes["*/*"] != event.CapabilitySupportLevel(0) { + t.Fatalf("image capability should match platform-imessage supported MIME types: %#v", first.File[event.MsgImage].MimeTypes) + } + if first.File[event.MsgFile].MimeTypes["*/*"] != event.CapLevelFullySupported { + t.Fatalf("generic file capability should allow all MIME types: %#v", first.File[event.MsgFile].MimeTypes) + } + if !first.ReadReceipts { + t.Fatal("explicit read receipts should remain advertised") + } + + syntheticPortal := &bridgev2.Portal{Portal: &database.Portal{PortalKey: networkid.PortalKey{ID: "group;-;alice@example.com,bob@example.com"}}} + syntheticCaps := client.GetCapabilities(t.Context(), syntheticPortal) + if syntheticCaps.File != nil { + t.Fatalf("synthetic new-chat portals must not advertise file sending before initial text reconciliation: %#v", syntheticCaps.File) + } + existingPortal := &bridgev2.Portal{Portal: &database.Portal{PortalKey: networkid.PortalKey{ID: "iMessage;-;real-chat"}}} + existingCaps := client.GetCapabilities(t.Context(), existingPortal) + if existingCaps.File == nil { + t.Fatal("existing iMessage portals should advertise file sending") + } +} + +func TestCreateGroupReturnsSyntheticPortal(t *testing.T) { + client := testClient() + resp, err := client.CreateGroup(t.Context(), &bridgev2.GroupCreateParams{ + Participants: []networkid.UserID{"bob@example.com", "alice@example.com"}, + Name: &event.RoomNameEventContent{Name: "Project"}, + }) + if err != nil { + t.Fatal(err) + } + if resp == nil || resp.PortalKey.ID != "group;-;alice@example.com,bob@example.com" { + t.Fatalf("unexpected group create response: %#v", resp) + } + if resp.PortalInfo == nil || resp.PortalInfo.Name == nil || *resp.PortalInfo.Name != "Project" { + t.Fatalf("expected group name in portal info: %#v", resp.PortalInfo) + } + if resp.PortalInfo.Type == nil || *resp.PortalInfo.Type != database.RoomTypeDefault { + t.Fatalf("expected group room type, got %#v", resp.PortalInfo.Type) + } + if resp.PortalInfo.Members == nil || !resp.PortalInfo.Members.IsFull || resp.PortalInfo.Members.TotalMemberCount != 3 { + t.Fatalf("expected two remote members plus bridge user in synthetic group: %#v", resp.PortalInfo.Members) + } + self := resp.PortalInfo.Members.MemberMap[client.GetUserID()] + if !self.IsFromMe || self.SenderLogin != client.UserLogin.ID || self.Membership != event.MembershipJoin { + t.Fatalf("expected synthetic group to include bridge user membership: %#v", self) + } +} + +func TestSyntheticDMIncludesBridgeUser(t *testing.T) { + client := testClient() + resp := client.syntheticChatResponse([]string{"alice@example.com"}, "") + if resp == nil || resp.PortalInfo == nil || resp.PortalInfo.Members == nil { + t.Fatalf("unexpected synthetic DM response: %#v", resp) + } + if resp.PortalInfo.Members.TotalMemberCount != 2 { + t.Fatalf("expected remote member plus bridge user in synthetic DM: %#v", resp.PortalInfo.Members) + } + self := resp.PortalInfo.Members.MemberMap[client.GetUserID()] + if !self.IsFromMe || self.SenderLogin != client.UserLogin.ID || self.Membership != event.MembershipJoin { + t.Fatalf("expected synthetic DM to include bridge user membership: %#v", self) + } +} + +func TestPermissionInstructions(t *testing.T) { + instructions := permissionInstructions(&imessage.AuthorizationStatus{ + Permissions: []imessage.PermissionStatus{{ + Title: "Accessibility", + Authorized: true, + Required: true, + Detail: "The bridge can control Messages.app.", + }, { + Title: "Messages Data", + Required: true, + Detail: "Allow access to ~/Library/Messages.", + }, { + Title: "Automation", + Detail: "Optional prompt.", + }}, + }) + if !strings.Contains(instructions, "[ok] Accessibility - The bridge can control Messages.app.") { + t.Fatalf("missing authorized permission line:\n%s", instructions) + } + if !strings.Contains(instructions, "[ ] Messages Data - Allow access to ~/Library/Messages.") { + t.Fatalf("missing missing-permission line:\n%s", instructions) + } + if strings.Contains(instructions, "Automation") { + t.Fatalf("optional permission should not be shown as blocking:\n%s", instructions) + } +} + +func TestMissingPermissionMessage(t *testing.T) { + message := missingPermissionMessage(&imessage.AuthorizationStatus{ + Permissions: []imessage.PermissionStatus{{ + Title: "Accessibility", + Required: true, + }, { + Title: "Contacts", + Required: true, + Authorized: true, + }, { + Title: "Automation", + }}, + }) + if message != "Missing local iMessage permissions: Accessibility" { + t.Fatalf("unexpected missing permission message: %q", message) + } +} + +func TestAuthorizationStatusDecodesAutomationHealth(t *testing.T) { + var authStatus imessage.AuthorizationStatus + if err := json.Unmarshal([]byte(`{ + "authorized": true, + "automation": { + "status": "unavailable", + "available": false, + "reason": "LOGINWINDOW_FRONTMOST", + "message": "Unlock the desktop session.", + "frontmostBundleID": "com.apple.loginwindow", + "frontmostName": "loginwindow", + "messagesRunning": true, + "messagesActive": false, + "messagesHidden": false, + "messagesWindowCount": 1 + } + }`), &authStatus); err != nil { + t.Fatal(err) + } + if authStatus.Automation.Available || authStatus.Automation.Reason != "LOGINWINDOW_FRONTMOST" || authStatus.Automation.FrontmostBundleID != "com.apple.loginwindow" { + t.Fatalf("unexpected automation status: %#v", authStatus.Automation) + } +} + +func TestBridgeStateForUnavailableAutomation(t *testing.T) { + state := bridgeStateForAutomationStatus(imessage.AutomationStatus{ + Status: "unavailable", + Available: false, + Reason: "LOGINWINDOW_FRONTMOST", + Message: "Unlock the desktop session.", + FrontmostBundleID: "com.apple.loginwindow", + FrontmostName: "loginwindow", + MessagesRunning: true, + MessagesWindowCount: 1, + }) + if state == nil { + t.Fatal("expected transient bridge state for unavailable automation") + } + if state.StateEvent != status.StateConnected || state.Error != "IMESSAGE_AUTOMATION_UNAVAILABLE" { + t.Fatalf("unexpected bridge state: %#v", state) + } + if state.Reason != "LOGINWINDOW_FRONTMOST" || state.Info["frontmost_bundle_id"] != "com.apple.loginwindow" { + t.Fatalf("missing automation details: %#v", state.Info) + } + + if available := bridgeStateForAutomationStatus(imessage.AutomationStatus{Status: "available", Available: true}); available != nil { + t.Fatalf("available automation should not produce bridge state: %#v", available) + } +} + +func TestSkipPermissionValidationDefaultsOn(t *testing.T) { + var config Config + if !config.ShouldSkipPermissionValidation() { + t.Fatal("omitted skip_permission_validation should default to true") + } + + explicitFalse := false + config.SkipPermissionValidation = &explicitFalse + if config.ShouldSkipPermissionValidation() { + t.Fatal("explicit false skip_permission_validation should be honored") + } +} + +func TestSendPermissionCheckHonorsSkipValidationDefault(t *testing.T) { + client := &Client{Main: &Connector{}} + if err := client.ensureAccessibilityForSending(); err != nil { + t.Fatalf("default skip_permission_validation should skip send-time permission prompts: %v", err) + } +} + +func TestSyntheticTextMessagesUseCreateChat(t *testing.T) { + if !shouldCreateChatForOutgoingText("any;-;cigdem.cabuker@icloud.com", "") { + t.Fatal("new synthetic DM text should use CreateChat instead of SendText") + } + if !shouldCreateChatForOutgoingText("group;-;alice@example.com,bob@example.com", "") { + t.Fatal("new synthetic group text should use CreateChat instead of SendText") + } + if shouldCreateChatForOutgoingText("any;-;cigdem.cabuker@icloud.com", "reply-id") { + t.Fatal("replies should not use CreateChat") + } + if shouldCreateChatForOutgoingText("iMessage;-;real-chat", "") { + t.Fatal("real iMessage thread IDs should use SendText") + } +} + +func TestCreateChatIgnoresStalePartialLastMessage(t *testing.T) { + isSender := true + if sentPartialLastMessageMatches(&imessage.Message{Text: "old", IsSender: &isSender}, "new") { + t.Fatal("stale partial last message must not be used as the new send response") + } + if !sentPartialLastMessageMatches(&imessage.Message{Text: "new", IsSender: &isSender}, "new") { + t.Fatal("matching own partial last message should be usable") + } + isNotSender := false + if sentPartialLastMessageMatches(&imessage.Message{Text: "new", IsSender: &isNotSender}, "new") { + t.Fatal("incoming partial last message must not be used as the own send response") + } +} + +func TestBestEffortAutomationErrorClassifier(t *testing.T) { + if !shouldIgnoreBestEffortAutomationError(errors.New("Initialized MessagesController in an invalid state:\nmwFrameValid=failure(Could not get main Messages window)")) { + t.Fatal("expected missing Messages main window errors to be best-effort for implicit read receipts") + } + if shouldIgnoreBestEffortAutomationError(errors.New("operation timed out after 45s")) { + t.Fatal("generic send timeouts must not be ignored") + } + if shouldIgnoreBestEffortAutomationError(nil) { + t.Fatal("nil errors must not be classified as ignored") + } +} + +func TestClientLoginStateAndEventLoopReset(t *testing.T) { + client := &Client{} + if client.IsLoggedIn() { + t.Fatal("new client should not report logged in") + } + + first := client.resetEventLoop() + client.loggedIn.Store(true) + client.Disconnect() + if client.IsLoggedIn() { + t.Fatal("disconnect should clear logged-in state") + } + select { + case <-first: + default: + t.Fatal("disconnect should close the active event loop channel") + } + + second := client.resetEventLoop() + select { + case <-second: + t.Fatal("reset should create a fresh open event loop channel") + default: + } + client.Disconnect() + select { + case <-second: + default: + t.Fatal("disconnect should close the reset event loop channel") + } +} + +func TestMessageMetadataAndSearchFormatting(t *testing.T) { + meta := messageDBMetadata(imessage.Message{ + ThreadID: "thread", + Cursor: "cursor", + Attachments: []imessage.Attachment{{ + ID: "att-1", + SrcURL: "file:///tmp/photo.jpg", + }}, + }) + if !meta.Attachment || !reflect.DeepEqual(meta.AttachmentIDs, []string{"att-1"}) || !reflect.DeepEqual(meta.AttachmentURLs, []string{"file:///tmp/photo.jpg"}) { + t.Fatalf("unexpected message metadata: %#v", meta) + } + + result := formatSearchResults([]imessage.Message{{ + ID: "msg-1", + ThreadID: "thread-1", + Text: "hello world", + }, { + ID: "msg-2", + ThreadID: "thread-2", + Attachments: []imessage.Attachment{{ + FileName: "image.png", + }}, + }}, true) + if !strings.Contains(result, "`thread-1` `msg-1` hello world") || !strings.Contains(result, "`thread-2` `msg-2` image.png") { + t.Fatalf("unexpected search result:\n%s", result) + } + + truncated := truncate("ååååå", 4) + if truncated != "å..." { + t.Fatalf("truncate should be rune-safe, got %q", truncated) + } +} + +func TestActivityStatusFormatting(t *testing.T) { + status := formatActivityStatus(&imessage.ActivityStatus{ + ActivityType: "typing", + PresenceStatus: "dnd_can_notify", + DidObservePresence: true, + }) + if !strings.Contains(status, "Activity: `typing`") || !strings.Contains(status, "Presence: `dnd_can_notify`") { + t.Fatalf("unexpected activity status:\n%s", status) + } + + unknown := formatActivityStatus(&imessage.ActivityStatus{}) + if !strings.Contains(unknown, "Activity: `none`") || !strings.Contains(unknown, "Presence: `unknown`") { + t.Fatalf("unexpected unknown activity status:\n%s", unknown) + } +} diff --git a/pkg/connector/login.go b/pkg/connector/login.go new file mode 100644 index 00000000..7c8f67ca --- /dev/null +++ b/pkg/connector/login.go @@ -0,0 +1,244 @@ +package connector + +import ( + "context" + "fmt" + "strings" + + "github.com/beeper/platform-imessage/pkg/imessage" + "github.com/beeper/platform-imessage/pkg/imessageid" + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/database" + "maunium.net/go/mautrix/bridgev2/networkid" + "maunium.net/go/mautrix/bridgev2/status" +) + +const ( + loginFlowLocal = "local" + loginStepPermissions = "com.beeper.imessage.login.permissions" + loginStepDone = "com.beeper.imessage.login.complete" + loginFieldConfirm = "permissions_ready" +) + +func (c *Connector) GetLoginFlows() []bridgev2.LoginFlow { + return []bridgev2.LoginFlow{{ + Name: "Local Messages.app", + Description: "Use the Apple ID currently signed in to Messages.app on this Mac.", + ID: loginFlowLocal, + }} +} + +func (c *Connector) CreateLogin(ctx context.Context, user *bridgev2.User, flowID string) (bridgev2.LoginProcess, error) { + if flowID != loginFlowLocal { + return nil, fmt.Errorf("invalid login flow ID %q", flowID) + } + return &LocalLogin{ + Main: c, + User: user, + IM: imessage.NewClient(), + }, nil +} + +type LocalLogin struct { + Main *Connector + User *bridgev2.User + IM *imessage.Client +} + +var _ bridgev2.LoginProcess = (*LocalLogin)(nil) +var _ bridgev2.LoginProcessUserInput = (*LocalLogin)(nil) + +func (l *LocalLogin) Start(ctx context.Context) (*bridgev2.LoginStep, error) { + if err := l.ensureOnlyLocalLoginOwner(ctx); err != nil { + return nil, err + } + + if l.Main.Config.ShouldSkipPermissionValidation() { + return l.complete(ctx) + } + + authStatus, err := l.IM.AuthorizationStatus() + if err != nil { + return nil, fmt.Errorf("failed to check local iMessage permissions: %w", err) + } + if authStatus.Authorized { + return l.complete(ctx) + } + return l.permissionStep(authStatus), nil +} + +func (l *LocalLogin) SubmitUserInput(ctx context.Context, _ map[string]string) (*bridgev2.LoginStep, error) { + if err := l.ensureOnlyLocalLoginOwner(ctx); err != nil { + return nil, err + } + + if l.Main.Config.ShouldSkipPermissionValidation() { + return l.complete(ctx) + } + + authStatus, err := l.IM.AuthorizationStatus() + if err != nil { + return nil, fmt.Errorf("failed to check local iMessage permissions: %w", err) + } + if authStatus.Authorized { + return l.complete(ctx) + } + + authStatus, err = l.IM.RequestAuthorization("all") + if err != nil { + return nil, fmt.Errorf("failed to request local iMessage permissions: %w", err) + } + if authStatus.Authorized { + return l.complete(ctx) + } + + return l.permissionStep(authStatus), nil +} + +func (l *LocalLogin) complete(ctx context.Context) (*bridgev2.LoginStep, error) { + currentUser, err := l.IM.CurrentUser() + if err != nil { + return nil, fmt.Errorf("failed to get local iMessage user: %w", err) + } + + loginID := l.localUserLoginID(currentUser.ID) + existingLogins := l.loginsForUser() + if len(existingLogins) > 1 { + return nil, fmt.Errorf("%s already has multiple local iMessage logins; remove the extra logins before reconnecting", l.User.MXID) + } + if len(existingLogins) == 1 && existingLogins[0].ID == loginID { + existing := existingLogins[0] + loginID = existing.ID + } + remoteName := currentUser.DisplayText + if remoteName == "" { + remoteName = currentUser.Email + } + if remoteName == "" { + remoteName = currentUser.PhoneNumber + } + if remoteName == "" { + remoteName = currentUser.ID + } + + ul, err := l.User.NewLogin(ctx, &database.UserLogin{ + ID: loginID, + RemoteName: remoteName, + RemoteProfile: status.RemoteProfile{ + Name: currentUser.DisplayText, + Email: currentUser.Email, + Phone: currentUser.PhoneNumber, + }, + Metadata: &imessageid.UserLoginMetadata{ + DisplayText: currentUser.DisplayText, + Email: currentUser.Email, + PhoneNumber: currentUser.PhoneNumber, + }, + }, &bridgev2.NewLoginParams{ + DeleteOnConflict: false, + }) + if err != nil { + return nil, fmt.Errorf("failed to create iMessage login: %w", err) + } + + return &bridgev2.LoginStep{ + Type: bridgev2.LoginStepTypeComplete, + StepID: loginStepDone, + Instructions: fmt.Sprintf("Logged in to local iMessage account %s", remoteName), + CompleteParams: &bridgev2.LoginCompleteParams{ + UserLoginID: ul.ID, + UserLogin: ul, + }, + }, nil +} + +func (l *LocalLogin) localUserLoginID(currentUserID string) networkid.UserLoginID { + if l.Main != nil && l.Main.Bridge != nil && l.Main.Bridge.Bot != nil { + localpart, _, ok := strings.Cut(strings.TrimPrefix(string(l.Main.Bridge.Bot.GetMXID()), "@"), ":") + if ok { + localpart = strings.TrimSuffix(localpart, "bot") + if localpart != "" { + return networkid.UserLoginID(localpart) + } + } + } + return imessageid.MakeUserLoginID(currentUserID) +} + +func (l *LocalLogin) permissionStep(status *imessage.AuthorizationStatus) *bridgev2.LoginStep { + return &bridgev2.LoginStep{ + Type: bridgev2.LoginStepTypeUserInput, + StepID: loginStepPermissions, + Instructions: permissionInstructions(status), + UserInputParams: &bridgev2.LoginUserInputParams{ + Fields: []bridgev2.LoginInputDataField{{ + Type: bridgev2.LoginInputFieldTypeSelect, + ID: loginFieldConfirm, + Name: "Permissions", + Description: "Grant the required macOS permissions, then continue.", + Options: []string{"Permissions granted"}, + }}, + }, + } +} + +func permissionInstructions(status *imessage.AuthorizationStatus) string { + var lines []string + lines = append(lines, + "Grant the missing local iMessage permissions on this Mac, then choose Permissions granted and submit.", + "", + ) + for _, permission := range status.Permissions { + if !permission.Required { + continue + } + marker := "[ ]" + if permission.Authorized { + marker = "[ok]" + } + line := fmt.Sprintf("%s %s", marker, permission.Title) + if permission.Detail != "" { + line += " - " + permission.Detail + } + lines = append(lines, line) + } + lines = append(lines, "", "If a settings window opened, enable this bridge there and return to Beeper.") + return strings.Join(lines, "\n") +} + +func (l *LocalLogin) loginsForUser() []*bridgev2.UserLogin { + var logins []*bridgev2.UserLogin + for _, login := range l.Main.Bridge.GetAllCachedUserLogins() { + if login.UserMXID == l.User.MXID { + logins = append(logins, login) + } + } + return logins +} + +func (l *LocalLogin) ensureOnlyLocalLoginOwner(ctx context.Context) error { + for _, login := range l.Main.Bridge.GetAllCachedUserLogins() { + if login.UserMXID != l.User.MXID { + return fmt.Errorf("local iMessage is already connected by %s; this bridge can only have one local iMessage login", login.UserMXID) + } + } + userIDs, err := l.Main.Bridge.DB.UserLogin.GetAllUserIDsWithLogins(ctx) + if err != nil { + return fmt.Errorf("failed to check existing iMessage logins: %w", err) + } + for _, userID := range userIDs { + if userID != l.User.MXID { + return fmt.Errorf("local iMessage is already connected by %s; this bridge can only have one local iMessage login", userID) + } + } + existingForUser, err := l.Main.Bridge.DB.UserLogin.GetAllForUser(ctx, l.User.MXID) + if err != nil { + return fmt.Errorf("failed to check existing iMessage logins for %s: %w", l.User.MXID, err) + } + if len(existingForUser) > 1 { + return fmt.Errorf("%s already has multiple local iMessage logins; this bridge can only have one local iMessage login", l.User.MXID) + } + return nil +} + +func (l *LocalLogin) Cancel() {} diff --git a/pkg/connector/messageconv.go b/pkg/connector/messageconv.go new file mode 100644 index 00000000..3cf45fcb --- /dev/null +++ b/pkg/connector/messageconv.go @@ -0,0 +1,288 @@ +package connector + +import ( + "bytes" + "context" + "encoding/base64" + "fmt" + "image" + _ "image/gif" + _ "image/jpeg" + _ "image/png" + "mime" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + + "github.com/beeper/platform-imessage/pkg/imessage" + "github.com/beeper/platform-imessage/pkg/imessageid" + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/event" +) + +func (c *Client) convertMessageFromIMessage(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI, msg imessage.Message) (*bridgev2.ConvertedMessage, error) { + converted := &bridgev2.ConvertedMessage{ + ReplyTo: replyTarget(msg), + } + + if strings.TrimSpace(msg.Text) != "" || len(msg.Attachments) == 0 { + content := c.messageTextContentFromIMessage(ctx, msg) + converted.Parts = append(converted.Parts, &bridgev2.ConvertedMessagePart{ + Type: event.EventMessage, + Content: content, + DBMetadata: messageDBMetadata(msg), + }) + } + + for _, attachment := range msg.Attachments { + part, err := c.convertAttachmentFromIMessage(ctx, portal, intent, msg, attachment) + if err != nil { + part = unavailableAttachmentPart(msg, attachment, err) + } + converted.Parts = append(converted.Parts, part) + } + + if len(converted.Parts) == 0 { + content := c.messageTextContentFromIMessage(ctx, msg) + converted.Parts = append(converted.Parts, &bridgev2.ConvertedMessagePart{ + Type: event.EventMessage, + Content: content, + DBMetadata: messageDBMetadata(msg), + }) + } + setConvertedMessagePartIDs(converted.Parts) + return converted, nil +} + +func setConvertedMessagePartIDs(parts []*bridgev2.ConvertedMessagePart) { + for i, part := range parts { + if part != nil { + part.ID = imessageid.MakePartID(i) + } + } +} + +func (c *Client) convertAttachmentFromIMessage(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI, msg imessage.Message, attachment imessage.Attachment) (*bridgev2.ConvertedMessagePart, error) { + data, fileName, mimeType, err := c.attachmentBytes(ctx, msg, attachment) + if err != nil { + return nil, err + } + mxc, file, err := intent.UploadMedia(ctx, portal.MXID, data, fileName, mimeType) + if err != nil { + return nil, err + } + info := fileInfoForAttachment(data, attachment, mimeType) + return &bridgev2.ConvertedMessagePart{ + Type: event.EventMessage, + Content: &event.MessageEventContent{ + MsgType: msgTypeForAttachment(attachment, mimeType), + Body: fileName, + FileName: fileName, + URL: mxc, + File: file, + Info: info, + }, + DBMetadata: messageDBMetadata(msg), + }, nil +} + +func fileInfoForAttachment(data []byte, attachment imessage.Attachment, mimeType string) *event.FileInfo { + info := &event.FileInfo{ + MimeType: mimeType, + Size: len(data), + MauGIF: attachment.IsGif, + } + if width, height := imageDimensions(data, mimeType); width > 0 && height > 0 { + info.Width = width + info.Height = height + } + return info +} + +func imageDimensions(data []byte, mimeType string) (width, height int) { + if !strings.HasPrefix(mimeType, "image/") || len(data) == 0 { + return 0, 0 + } + config, _, err := image.DecodeConfig(bytes.NewReader(data)) + if err != nil { + return 0, 0 + } + return config.Width, config.Height +} + +func (c *Client) attachmentBytes(ctx context.Context, msg imessage.Message, attachment imessage.Attachment) ([]byte, string, string, error) { + if attachment.SrcURL == "" { + if err := c.IM.LoadAttachment(msg.ID); err != nil { + return nil, "", "", err + } + reloaded, err := c.IM.Chat(msg.ThreadID) + if err != nil { + return nil, "", "", err + } + if reloaded != nil && reloaded.PartialLastMessage != nil && reloaded.PartialLastMessage.ID == msg.ID { + for _, loadedAttachment := range reloaded.PartialLastMessage.Attachments { + if loadedAttachment.ID == attachment.ID && loadedAttachment.SrcURL != "" { + attachment = loadedAttachment + break + } + } + } + if attachment.SrcURL == "" { + page, err := c.IM.Messages(msg.ThreadID, nil) + if err != nil { + return nil, "", "", err + } + for _, loadedMessage := range page.Items { + if loadedMessage.ID != msg.ID { + continue + } + for _, loadedAttachment := range loadedMessage.Attachments { + if loadedAttachment.ID == attachment.ID && loadedAttachment.SrcURL != "" { + attachment = loadedAttachment + break + } + } + } + } + if attachment.SrcURL == "" { + return nil, "", "", fmt.Errorf("attachment %s has no source URL after load request", attachment.ID) + } + } + data, path, err := c.readAttachmentURL(ctx, attachment.SrcURL, 0) + if err != nil { + return nil, "", "", err + } + + fileName := attachment.FileName + if fileName == "" { + fileName = filepath.Base(path) + } + if fileName == "" || fileName == "." || fileName == "/" { + fileName = attachment.ID + } + mimeType := attachment.MimeType + if mimeType == "" { + mimeType = mime.TypeByExtension(filepath.Ext(fileName)) + } + if mimeType == "" { + mimeType = http.DetectContentType(data) + } + return data, fileName, mimeType, nil +} + +func (c *Client) readAttachmentURL(ctx context.Context, rawURL string, depth int) ([]byte, string, error) { + if depth > 4 { + return nil, "", fmt.Errorf("too many nested asset redirects") + } + parsed, err := url.Parse(rawURL) + if err != nil { + return nil, "", err + } + switch parsed.Scheme { + case "file": + return readLocalAttachmentFile(parsed.Path) + case "asset": + pathHex, methodName := splitAssetPath(strings.TrimPrefix(parsed.Path, "/")) + asset, err := c.IM.GetAsset(pathHex, methodName) + if err != nil { + return nil, "", err + } + if asset.DataBase64 != "" { + data, err := base64.StdEncoding.DecodeString(asset.DataBase64) + return data, methodName, err + } + if asset.URL != "" { + return c.readAttachmentURL(ctx, asset.URL, depth+1) + } + return nil, "", fmt.Errorf("asset %s returned no URL or data", rawURL) + case "": + return readLocalAttachmentFile(rawURL) + default: + return nil, "", fmt.Errorf("unsupported attachment URL scheme %q", parsed.Scheme) + } +} + +func readLocalAttachmentFile(path string) ([]byte, string, error) { + data, err := os.ReadFile(path) + return data, path, err +} + +func splitAssetPath(path string) (pathHex, methodName string) { + parts := strings.SplitN(path, "/", 2) + pathHex = parts[0] + if len(parts) > 1 { + methodName = parts[1] + } + return +} + +func unavailableAttachmentPart(msg imessage.Message, attachment imessage.Attachment, err error) *bridgev2.ConvertedMessagePart { + fileName := attachment.FileName + if fileName == "" { + fileName = attachment.ID + } + return &bridgev2.ConvertedMessagePart{ + Type: event.EventMessage, + Content: &event.MessageEventContent{ + MsgType: event.MsgNotice, + Body: fmt.Sprintf("Attachment unavailable: %s (%v)", fileName, err), + }, + DBMetadata: messageDBMetadata(msg), + } +} + +func msgTypeForAttachment(attachment imessage.Attachment, mimeType string) event.MessageType { + if attachment.IsSticker { + return event.MsgImage + } + switch { + case strings.HasPrefix(mimeType, "image/"): + return event.MsgImage + case strings.HasPrefix(mimeType, "video/"): + return event.MsgVideo + case strings.HasPrefix(mimeType, "audio/"): + return event.MsgAudio + default: + return event.MsgFile + } +} + +func messageDBMetadata(msg imessage.Message) *imessageid.MessageMetadata { + meta := &imessageid.MessageMetadata{ + ThreadID: msg.ThreadID, + Cursor: msg.Cursor, + Attachment: len(msg.Attachments) > 0, + } + for _, attachment := range msg.Attachments { + if attachment.ID != "" { + meta.AttachmentIDs = append(meta.AttachmentIDs, attachment.ID) + } + if attachment.SrcURL != "" { + meta.AttachmentURLs = append(meta.AttachmentURLs, attachment.SrcURL) + } + } + return meta +} + +func (c *Client) mentionsFromIMessage(ctx context.Context, msg imessage.Message) *event.Mentions { + if msg.TextAttributes == nil { + return nil + } + mentions := &event.Mentions{} + for _, entity := range msg.TextAttributes.Entities { + if entity.MentionedUser == nil || entity.MentionedUser.ID == "" { + continue + } + ghost, err := c.Main.Bridge.GetGhostByID(ctx, imessageid.MakeUserID(entity.MentionedUser.ID)) + if err != nil || ghost == nil { + continue + } + mentions.Add(ghost.Intent.GetMXID()) + } + if len(mentions.UserIDs) == 0 && !mentions.Room { + return nil + } + return mentions +} diff --git a/pkg/imessage/client.go b/pkg/imessage/client.go new file mode 100644 index 00000000..772ba1ce --- /dev/null +++ b/pkg/imessage/client.go @@ -0,0 +1,282 @@ +package imessage + +import ( + "encoding/json" + "fmt" + + "github.com/beeper/platform-imessage/pkg/imessage/lib" +) + +type Client struct{} + +func Init(dataDir string, verbose, useSecondaryInstance, coordinateWindow bool) error { + _, err := lib.Init(dataDir, verbose, useSecondaryInstance, coordinateWindow) + return err +} + +func Dispose() error { + _, err := lib.Dispose() + return err +} + +func NewClient() *Client { + return &Client{} +} + +func decode[T any](raw json.RawMessage) (out T, err error) { + if len(raw) == 0 || string(raw) == "null" { + return out, nil + } + err = json.Unmarshal(raw, &out) + return +} + +func (c *Client) CurrentUser() (*CurrentUser, error) { + raw, err := lib.CurrentUser() + if err != nil { + return nil, err + } + user, err := decode[CurrentUser](raw) + if err != nil { + return nil, fmt.Errorf("failed to decode current user: %w", err) + } + return &user, nil +} + +func (c *Client) AuthorizationStatus() (*AuthorizationStatus, error) { + raw, err := lib.AuthorizationStatus() + if err != nil { + return nil, err + } + status, err := decode[AuthorizationStatus](raw) + if err != nil { + return nil, fmt.Errorf("failed to decode authorization status: %w", err) + } + return &status, nil +} + +func (c *Client) RequestAuthorization(target string) (*AuthorizationStatus, error) { + raw, err := lib.RequestAuthorization(target) + if err != nil { + return nil, err + } + status, err := decode[AuthorizationStatus](raw) + if err != nil { + return nil, fmt.Errorf("failed to decode authorization status: %w", err) + } + return &status, nil +} + +func (c *Client) Chats(pagination *Pagination) (*Page[Thread], error) { + paginationJSON, err := pagination.JSON() + if err != nil { + return nil, err + } + raw, err := lib.Chats(paginationJSON) + if err != nil { + return nil, err + } + page, err := decode[Page[Thread]](raw) + if err != nil { + return nil, fmt.Errorf("failed to decode chats: %w", err) + } + return &page, nil +} + +func (c *Client) Chat(threadID string) (*Thread, error) { + raw, err := lib.Chat(threadID) + if err != nil { + return nil, err + } + if string(raw) == "null" { + return nil, nil + } + thread, err := decode[Thread](raw) + if err != nil { + return nil, fmt.Errorf("failed to decode chat: %w", err) + } + return &thread, nil +} + +func (c *Client) Messages(threadID string, pagination *Pagination) (*Page[Message], error) { + paginationJSON, err := pagination.JSON() + if err != nil { + return nil, err + } + raw, err := lib.Messages(threadID, paginationJSON) + if err != nil { + return nil, err + } + page, err := decode[Page[Message]](raw) + if err != nil { + return nil, fmt.Errorf("failed to decode messages: %w", err) + } + return &page, nil +} + +func (c *Client) SendText(threadID, text, quotedMessageID string) ([]Message, error) { + raw, err := lib.SendText(threadID, text, quotedMessageID) + if err != nil { + return nil, err + } + return decodeSendResult(raw) +} + +func (c *Client) SendFile(threadID, filePath, quotedMessageID string) ([]Message, error) { + raw, err := lib.SendFile(threadID, filePath, quotedMessageID) + if err != nil { + return nil, err + } + return decodeSendResult(raw) +} + +func (c *Client) CreateChat(recipients []string, messageText, title string) (*Thread, bool, error) { + recipientsJSON, err := json.Marshal(recipients) + if err != nil { + return nil, false, err + } + raw, err := lib.CreateChat(string(recipientsJSON), messageText, title) + if err != nil { + return nil, false, err + } + if len(raw) == 0 || string(raw) == "true" { + return nil, true, nil + } + if string(raw) == "false" || string(raw) == "null" { + return nil, false, nil + } + thread, err := decode[Thread](raw) + if err != nil { + return nil, false, fmt.Errorf("failed to decode create chat response: %w", err) + } + return &thread, true, nil +} + +func (c *Client) Edit(threadID, messageID, text string) error { + _, err := lib.Edit(threadID, messageID, text) + return err +} + +func (c *Client) DeleteMessage(threadID, messageID string) error { + _, err := lib.DeleteMessage(threadID, messageID) + return err +} + +func (c *Client) React(threadID, messageID, reactionKey string, enabled bool) error { + _, err := lib.React(threadID, messageID, reactionKey, enabled) + return err +} + +func (c *Client) MarkRead(threadID string) error { + _, err := lib.MarkRead(threadID) + return err +} + +func (c *Client) MarkUnread(threadID string) error { + _, err := lib.MarkUnread(threadID) + return err +} + +func (c *Client) Mute(threadID string, muted bool) error { + _, err := lib.Mute(threadID, muted) + return err +} + +func (c *Client) DeleteChat(threadID string) error { + _, err := lib.DeleteChat(threadID) + return err +} + +func (c *Client) NotifyAnyway(threadID string) error { + _, err := lib.NotifyAnyway(threadID) + return err +} + +func (c *Client) ActivityStatus(threadID string) (*ActivityStatus, error) { + raw, err := lib.ActivityStatus(threadID) + if err != nil { + return nil, err + } + status, err := decode[ActivityStatus](raw) + if err != nil { + return nil, fmt.Errorf("failed to decode activity status: %w", err) + } + return &status, nil +} + +func (c *Client) Typing(threadID string, enabled bool) error { + _, err := lib.Typing(threadID, enabled) + return err +} + +func (c *Client) WatchChat(threadID string) error { + _, err := lib.WatchChat(threadID) + return err +} + +func (c *Client) SearchMessages(query, threadID string, pagination *Pagination, limit int) (*Page[Message], error) { + paginationJSON, err := pagination.JSON() + if err != nil { + return nil, err + } + raw, err := lib.SearchMessages(query, threadID, paginationJSON, limit) + if err != nil { + return nil, err + } + page, err := decode[Page[Message]](raw) + if err != nil { + return nil, fmt.Errorf("failed to decode message search: %w", err) + } + return &page, nil +} + +func (c *Client) GetAsset(pathHex, methodName string) (*Asset, error) { + raw, err := lib.GetAsset(pathHex, methodName) + if err != nil { + return nil, err + } + asset, err := decode[Asset](raw) + if err != nil { + return nil, fmt.Errorf("failed to decode asset: %w", err) + } + return &asset, nil +} + +func (c *Client) LoadAttachment(messageID string) error { + _, err := lib.LoadAttachment(messageID) + return err +} + +func (c *Client) StartEvents() error { + _, err := lib.StartEvents() + return err +} + +func (c *Client) NextEvents(timeoutMilliseconds int) ([]StateSyncEvent, error) { + raw, err := lib.NextEvents(timeoutMilliseconds) + if err != nil { + return nil, err + } + if len(raw) == 0 || string(raw) == "null" { + return nil, nil + } + var events []StateSyncEvent + if err := json.Unmarshal(raw, &events); err != nil { + return nil, fmt.Errorf("failed to decode events: %w", err) + } + return events, nil +} + +func decodeSendResult(raw json.RawMessage) ([]Message, error) { + if len(raw) == 0 || string(raw) == "true" || string(raw) == "null" { + return nil, nil + } + if string(raw) == "false" { + return nil, fmt.Errorf("swift send returned false") + } + messages, err := decode[[]Message](raw) + if err != nil { + return nil, fmt.Errorf("failed to decode send response: %w", err) + } + return messages, nil +} diff --git a/pkg/imessage/lib/lib_darwin.go b/pkg/imessage/lib/lib_darwin.go new file mode 100644 index 00000000..680baa1c --- /dev/null +++ b/pkg/imessage/lib/lib_darwin.go @@ -0,0 +1,278 @@ +//go:build darwin && cgo + +package lib + +/* +#cgo darwin LDFLAGS: -lIMessageBridgeKit +#include +#include + +char *imessage_bridge_init(const char *data_dir, int32_t verbose, int32_t use_secondary_instance, int32_t coordinate_window); +char *imessage_bridge_dispose(void); +char *imessage_bridge_current_user(void); +char *imessage_bridge_authorization_status(void); +char *imessage_bridge_request_authorization(const char *target); +char *imessage_bridge_chats(const char *pagination_json); +char *imessage_bridge_chat(const char *thread_id); +char *imessage_bridge_messages(const char *thread_id, const char *pagination_json); +char *imessage_bridge_send_text(const char *thread_id, const char *text, const char *quoted_message_id); +char *imessage_bridge_send_file(const char *thread_id, const char *file_path, const char *quoted_message_id); +char *imessage_bridge_create_chat(const char *recipients_json, const char *message_text, const char *title); +char *imessage_bridge_edit(const char *thread_id, const char *message_id, const char *text); +char *imessage_bridge_delete_message(const char *thread_id, const char *message_id); +char *imessage_bridge_react(const char *thread_id, const char *message_id, const char *reaction_key, int32_t enabled); +char *imessage_bridge_mark_read(const char *thread_id); +char *imessage_bridge_mark_unread(const char *thread_id); +char *imessage_bridge_mute(const char *thread_id, int32_t muted); +char *imessage_bridge_delete_chat(const char *thread_id); +char *imessage_bridge_notify_anyway(const char *thread_id); +char *imessage_bridge_activity_status(const char *thread_id); +char *imessage_bridge_typing(const char *thread_id, int32_t enabled); +char *imessage_bridge_watch_chat(const char *thread_id); +char *imessage_bridge_search_messages(const char *query, const char *thread_id, const char *pagination_json, int32_t limit); +char *imessage_bridge_get_asset(const char *path_hex, const char *method_name); +char *imessage_bridge_load_attachment(const char *message_id); +char *imessage_bridge_start_events(void); +char *imessage_bridge_next_events(int32_t timeout_milliseconds); +void imessage_bridge_free(char *pointer); +*/ +import "C" + +import ( + "encoding/json" + "fmt" + "unsafe" +) + +type Response struct { + OK bool `json:"ok"` + Error string `json:"error,omitempty"` + Payload json.RawMessage `json:"payload"` +} + +func call(fn func() *C.char) (json.RawMessage, error) { + ptr := fn() + if ptr == nil { + return nil, fmt.Errorf("swift bridge returned nil") + } + defer C.imessage_bridge_free(ptr) + + var resp Response + if err := json.Unmarshal([]byte(C.GoString(ptr)), &resp); err != nil { + return nil, fmt.Errorf("failed to decode swift bridge response: %w", err) + } + if !resp.OK { + if resp.Error == "" { + resp.Error = "unknown swift bridge error" + } + return nil, fmt.Errorf("%s", resp.Error) + } + return resp.Payload, nil +} + +func cstr(input string) (*C.char, func()) { + value := C.CString(input) + return value, func() { C.free(unsafe.Pointer(value)) } +} + +func optionalCStr(input string) (*C.char, func()) { + if input == "" { + return nil, func() {} + } + return cstr(input) +} + +func Init(dataDir string, verbose, useSecondaryInstance, coordinateWindow bool) (json.RawMessage, error) { + dataDirC, freeDataDir := cstr(dataDir) + defer freeDataDir() + return call(func() *C.char { + return C.imessage_bridge_init(dataDirC, boolToCInt(verbose), boolToCInt(useSecondaryInstance), boolToCInt(coordinateWindow)) + }) +} + +func Dispose() (json.RawMessage, error) { + return call(func() *C.char { return C.imessage_bridge_dispose() }) +} + +func CurrentUser() (json.RawMessage, error) { + return call(func() *C.char { return C.imessage_bridge_current_user() }) +} + +func AuthorizationStatus() (json.RawMessage, error) { + return call(func() *C.char { return C.imessage_bridge_authorization_status() }) +} + +func RequestAuthorization(target string) (json.RawMessage, error) { + targetC, freeTarget := optionalCStr(target) + defer freeTarget() + return call(func() *C.char { return C.imessage_bridge_request_authorization(targetC) }) +} + +func Chats(paginationJSON string) (json.RawMessage, error) { + paginationC, freePagination := optionalCStr(paginationJSON) + defer freePagination() + return call(func() *C.char { return C.imessage_bridge_chats(paginationC) }) +} + +func Chat(threadID string) (json.RawMessage, error) { + threadIDC, freeThreadID := cstr(threadID) + defer freeThreadID() + return call(func() *C.char { return C.imessage_bridge_chat(threadIDC) }) +} + +func Messages(threadID, paginationJSON string) (json.RawMessage, error) { + threadIDC, freeThreadID := cstr(threadID) + defer freeThreadID() + paginationC, freePagination := optionalCStr(paginationJSON) + defer freePagination() + return call(func() *C.char { return C.imessage_bridge_messages(threadIDC, paginationC) }) +} + +func SendText(threadID, text, quotedMessageID string) (json.RawMessage, error) { + threadIDC, freeThreadID := cstr(threadID) + defer freeThreadID() + textC, freeText := cstr(text) + defer freeText() + quotedC, freeQuoted := optionalCStr(quotedMessageID) + defer freeQuoted() + return call(func() *C.char { return C.imessage_bridge_send_text(threadIDC, textC, quotedC) }) +} + +func SendFile(threadID, filePath, quotedMessageID string) (json.RawMessage, error) { + threadIDC, freeThreadID := cstr(threadID) + defer freeThreadID() + filePathC, freeFilePath := cstr(filePath) + defer freeFilePath() + quotedC, freeQuoted := optionalCStr(quotedMessageID) + defer freeQuoted() + return call(func() *C.char { return C.imessage_bridge_send_file(threadIDC, filePathC, quotedC) }) +} + +func CreateChat(recipientsJSON, messageText, title string) (json.RawMessage, error) { + recipientsC, freeRecipients := cstr(recipientsJSON) + defer freeRecipients() + messageC, freeMessage := cstr(messageText) + defer freeMessage() + titleC, freeTitle := optionalCStr(title) + defer freeTitle() + return call(func() *C.char { return C.imessage_bridge_create_chat(recipientsC, messageC, titleC) }) +} + +func Edit(threadID, messageID, text string) (json.RawMessage, error) { + threadIDC, freeThreadID := cstr(threadID) + defer freeThreadID() + messageIDC, freeMessageID := cstr(messageID) + defer freeMessageID() + textC, freeText := cstr(text) + defer freeText() + return call(func() *C.char { return C.imessage_bridge_edit(threadIDC, messageIDC, textC) }) +} + +func DeleteMessage(threadID, messageID string) (json.RawMessage, error) { + threadIDC, freeThreadID := cstr(threadID) + defer freeThreadID() + messageIDC, freeMessageID := cstr(messageID) + defer freeMessageID() + return call(func() *C.char { return C.imessage_bridge_delete_message(threadIDC, messageIDC) }) +} + +func React(threadID, messageID, reactionKey string, enabled bool) (json.RawMessage, error) { + threadIDC, freeThreadID := cstr(threadID) + defer freeThreadID() + messageIDC, freeMessageID := cstr(messageID) + defer freeMessageID() + reactionC, freeReaction := cstr(reactionKey) + defer freeReaction() + return call(func() *C.char { + return C.imessage_bridge_react(threadIDC, messageIDC, reactionC, boolToCInt(enabled)) + }) +} + +func MarkRead(threadID string) (json.RawMessage, error) { + threadIDC, freeThreadID := cstr(threadID) + defer freeThreadID() + return call(func() *C.char { return C.imessage_bridge_mark_read(threadIDC) }) +} + +func MarkUnread(threadID string) (json.RawMessage, error) { + threadIDC, freeThreadID := cstr(threadID) + defer freeThreadID() + return call(func() *C.char { return C.imessage_bridge_mark_unread(threadIDC) }) +} + +func Mute(threadID string, muted bool) (json.RawMessage, error) { + threadIDC, freeThreadID := cstr(threadID) + defer freeThreadID() + return call(func() *C.char { return C.imessage_bridge_mute(threadIDC, boolToCInt(muted)) }) +} + +func DeleteChat(threadID string) (json.RawMessage, error) { + threadIDC, freeThreadID := cstr(threadID) + defer freeThreadID() + return call(func() *C.char { return C.imessage_bridge_delete_chat(threadIDC) }) +} + +func NotifyAnyway(threadID string) (json.RawMessage, error) { + threadIDC, freeThreadID := cstr(threadID) + defer freeThreadID() + return call(func() *C.char { return C.imessage_bridge_notify_anyway(threadIDC) }) +} + +func ActivityStatus(threadID string) (json.RawMessage, error) { + threadIDC, freeThreadID := cstr(threadID) + defer freeThreadID() + return call(func() *C.char { return C.imessage_bridge_activity_status(threadIDC) }) +} + +func Typing(threadID string, enabled bool) (json.RawMessage, error) { + threadIDC, freeThreadID := cstr(threadID) + defer freeThreadID() + return call(func() *C.char { return C.imessage_bridge_typing(threadIDC, boolToCInt(enabled)) }) +} + +func WatchChat(threadID string) (json.RawMessage, error) { + threadIDC, freeThreadID := cstr(threadID) + defer freeThreadID() + return call(func() *C.char { return C.imessage_bridge_watch_chat(threadIDC) }) +} + +func SearchMessages(query, threadID, paginationJSON string, limit int) (json.RawMessage, error) { + queryC, freeQuery := cstr(query) + defer freeQuery() + threadIDC, freeThreadID := optionalCStr(threadID) + defer freeThreadID() + paginationC, freePagination := optionalCStr(paginationJSON) + defer freePagination() + return call(func() *C.char { + return C.imessage_bridge_search_messages(queryC, threadIDC, paginationC, C.int32_t(limit)) + }) +} + +func GetAsset(pathHex, methodName string) (json.RawMessage, error) { + pathHexC, freePathHex := cstr(pathHex) + defer freePathHex() + methodC, freeMethod := optionalCStr(methodName) + defer freeMethod() + return call(func() *C.char { return C.imessage_bridge_get_asset(pathHexC, methodC) }) +} + +func LoadAttachment(messageID string) (json.RawMessage, error) { + messageIDC, freeMessageID := cstr(messageID) + defer freeMessageID() + return call(func() *C.char { return C.imessage_bridge_load_attachment(messageIDC) }) +} + +func StartEvents() (json.RawMessage, error) { + return call(func() *C.char { return C.imessage_bridge_start_events() }) +} + +func NextEvents(timeoutMilliseconds int) (json.RawMessage, error) { + return call(func() *C.char { return C.imessage_bridge_next_events(C.int32_t(timeoutMilliseconds)) }) +} + +func boolToCInt(value bool) C.int32_t { + if value { + return 1 + } + return 0 +} diff --git a/pkg/imessage/lib/lib_stub.go b/pkg/imessage/lib/lib_stub.go new file mode 100644 index 00000000..e2845a8c --- /dev/null +++ b/pkg/imessage/lib/lib_stub.go @@ -0,0 +1,120 @@ +//go:build !darwin || !cgo + +package lib + +import ( + "encoding/json" + "fmt" +) + +func unsupported() (json.RawMessage, error) { + return nil, fmt.Errorf("platform-imessage bridge is only supported on macOS with cgo") +} + +func Init(dataDir string, verbose, useSecondaryInstance, coordinateWindow bool) (json.RawMessage, error) { + return unsupported() +} + +func Dispose() (json.RawMessage, error) { + return unsupported() +} + +func CurrentUser() (json.RawMessage, error) { + return unsupported() +} + +func AuthorizationStatus() (json.RawMessage, error) { + return unsupported() +} + +func RequestAuthorization(target string) (json.RawMessage, error) { + return unsupported() +} + +func Chats(paginationJSON string) (json.RawMessage, error) { + return unsupported() +} + +func Chat(threadID string) (json.RawMessage, error) { + return unsupported() +} + +func Messages(threadID, paginationJSON string) (json.RawMessage, error) { + return unsupported() +} + +func SendText(threadID, text, quotedMessageID string) (json.RawMessage, error) { + return unsupported() +} + +func SendFile(threadID, filePath, quotedMessageID string) (json.RawMessage, error) { + return unsupported() +} + +func CreateChat(recipientsJSON, messageText, title string) (json.RawMessage, error) { + return unsupported() +} + +func Edit(threadID, messageID, text string) (json.RawMessage, error) { + return unsupported() +} + +func DeleteMessage(threadID, messageID string) (json.RawMessage, error) { + return unsupported() +} + +func React(threadID, messageID, reactionKey string, enabled bool) (json.RawMessage, error) { + return unsupported() +} + +func MarkRead(threadID string) (json.RawMessage, error) { + return unsupported() +} + +func MarkUnread(threadID string) (json.RawMessage, error) { + return unsupported() +} + +func Mute(threadID string, muted bool) (json.RawMessage, error) { + return unsupported() +} + +func DeleteChat(threadID string) (json.RawMessage, error) { + return unsupported() +} + +func NotifyAnyway(threadID string) (json.RawMessage, error) { + return unsupported() +} + +func ActivityStatus(threadID string) (json.RawMessage, error) { + return unsupported() +} + +func Typing(threadID string, enabled bool) (json.RawMessage, error) { + return unsupported() +} + +func WatchChat(threadID string) (json.RawMessage, error) { + return unsupported() +} + +func SearchMessages(query, threadID, paginationJSON string, limit int) (json.RawMessage, error) { + return unsupported() +} + +func GetAsset(pathHex, methodName string) (json.RawMessage, error) { + return unsupported() +} + +func LoadAttachment(messageID string) (json.RawMessage, error) { + return unsupported() +} + +func StartEvents() (json.RawMessage, error) { + return unsupported() +} + +func NextEvents(timeoutMilliseconds int) (json.RawMessage, error) { + return unsupported() +} diff --git a/pkg/imessage/types.go b/pkg/imessage/types.go new file mode 100644 index 00000000..7da5e3b1 --- /dev/null +++ b/pkg/imessage/types.go @@ -0,0 +1,209 @@ +package imessage + +import "encoding/json" + +type CurrentUser struct { + ID string `json:"id"` + DisplayText string `json:"displayText,omitempty"` + Email string `json:"email,omitempty"` + PhoneNumber string `json:"phoneNumber,omitempty"` +} + +type AuthorizationStatus struct { + Authorized bool `json:"authorized"` + Permissions []PermissionStatus `json:"permissions"` + Automation AutomationStatus `json:"automation,omitempty"` +} + +type PermissionStatus struct { + ID string `json:"id"` + Title string `json:"title"` + Status string `json:"status"` + Authorized bool `json:"authorized"` + Required bool `json:"required"` + Detail string `json:"detail,omitempty"` +} + +type AutomationStatus struct { + Status string `json:"status,omitempty"` + Available bool `json:"available"` + Reason string `json:"reason,omitempty"` + Message string `json:"message,omitempty"` + FrontmostBundleID string `json:"frontmostBundleID,omitempty"` + FrontmostName string `json:"frontmostName,omitempty"` + MessagesRunning bool `json:"messagesRunning"` + MessagesActive bool `json:"messagesActive"` + MessagesHidden bool `json:"messagesHidden"` + MessagesWindowCount int `json:"messagesWindowCount,omitempty"` +} + +type Page[T any] struct { + Items []T `json:"items"` + HasMore bool `json:"hasMore"` + OldestCursor string `json:"oldestCursor,omitempty"` + NewestCursor string `json:"newestCursor,omitempty"` +} + +type Pagination struct { + Cursor string `json:"cursor"` + Direction string `json:"direction"` + Limit int `json:"limit,omitempty"` +} + +type ActivityStatus struct { + ActivityType string `json:"activityType"` + PresenceStatus string `json:"presenceStatus,omitempty"` + DidObservePresence bool `json:"didObservePresence"` +} + +func (p *Pagination) JSON() (string, error) { + if p == nil || p.Cursor == "" || p.Direction == "" { + return "", nil + } + data, err := json.Marshal(p) + if err != nil { + return "", err + } + return string(data), nil +} + +type ThreadType string + +const ( + ThreadTypeSingle ThreadType = "single" + ThreadTypeGroup ThreadType = "group" +) + +type Thread struct { + ID string `json:"id"` + Title string `json:"title,omitempty"` + Type ThreadType `json:"type"` + Timestamp int64 `json:"timestamp,omitempty"` + ImgURL string `json:"imgURL,omitempty"` + IsUnread bool `json:"isUnread"` + IsReadOnly bool `json:"isReadOnly"` + MutedUntil any `json:"mutedUntil,omitempty"` + Participants Page[User] `json:"participants"` + PartialLastMessage *Message `json:"partialLastMessage,omitempty"` + MessageExpirySeconds *int `json:"messageExpirySeconds,omitempty"` + UnreadCount *int `json:"unreadCount,omitempty"` + IsMarkedUnread *bool `json:"isMarkedUnread,omitempty"` + LastReadMessageID string `json:"lastReadMessageID,omitempty"` + LastReadMessageSortKey int64 `json:"lastReadMessageSortKey,omitempty"` + Extra any `json:"extra,omitempty"` +} + +type User struct { + ID string `json:"id"` + Username string `json:"username,omitempty"` + PhoneNumber string `json:"phoneNumber,omitempty"` + Email string `json:"email,omitempty"` + FullName string `json:"fullName,omitempty"` + Nickname string `json:"nickname,omitempty"` + ImgURL string `json:"imgURL,omitempty"` + IsSelf *bool `json:"isSelf,omitempty"` +} + +type Message struct { + ID string `json:"id"` + Timestamp int64 `json:"timestamp"` + EditedTimestamp int64 `json:"editedTimestamp,omitempty"` + SenderID string `json:"senderID"` + Text string `json:"text,omitempty"` + TextAttributes *TextAttributes `json:"textAttributes,omitempty"` + Attachments []Attachment `json:"attachments,omitempty"` + Links []MessageLink `json:"links,omitempty"` + Reactions []Reaction `json:"reactions,omitempty"` + Seen any `json:"seen,omitempty"` + IsSender *bool `json:"isSender,omitempty"` + IsAction *bool `json:"isAction,omitempty"` + IsDeleted *bool `json:"isDeleted,omitempty"` + IsErrored *bool `json:"isErrored,omitempty"` + LinkedMessageThreadID string `json:"linkedMessageThreadID,omitempty"` + LinkedMessageID string `json:"linkedMessageID,omitempty"` + Action *MessageAction `json:"action,omitempty"` + ThreadID string `json:"threadID,omitempty"` + Cursor string `json:"cursor,omitempty"` + Extra any `json:"extra,omitempty"` +} + +type MessageAction struct { + Type string `json:"type"` + Title string `json:"title,omitempty"` + ActorParticipantID string `json:"actorParticipantID,omitempty"` + ParticipantIDs []string `json:"participantIDs,omitempty"` + Participants []User `json:"participants,omitempty"` + MessageID string `json:"messageID,omitempty"` + ID string `json:"id,omitempty"` + ReactionKey string `json:"reactionKey,omitempty"` + ImgURL string `json:"imgURL,omitempty"` + ParticipantID string `json:"participantID,omitempty"` + Emoji *bool `json:"emoji,omitempty"` +} + +type TextAttributes struct { + Entities []TextEntity `json:"entities,omitempty"` +} + +type TextEntity struct { + From int `json:"from"` + To int `json:"to"` + Bold *bool `json:"bold,omitempty"` + Italic *bool `json:"italic,omitempty"` + Underline *bool `json:"underline,omitempty"` + Strikethrough *bool `json:"strikethrough,omitempty"` + Link string `json:"link,omitempty"` + MentionedUser *MentionedUser `json:"mentionedUser,omitempty"` +} + +type MentionedUser struct { + ID string `json:"id,omitempty"` + Username string `json:"username,omitempty"` +} + +type MessageLink struct { + URL string `json:"url"` + OriginalURL string `json:"originalURL,omitempty"` + Title string `json:"title,omitempty"` + Summary string `json:"summary,omitempty"` +} + +type Attachment struct { + ID string `json:"id"` + Type string `json:"type"` + MimeType string `json:"mimeType,omitempty"` + FileName string `json:"fileName,omitempty"` + FileSize int64 `json:"fileSize,omitempty"` + SrcURL string `json:"srcURL,omitempty"` + IsGif bool `json:"isGif,omitempty"` + IsSticker bool `json:"isSticker,omitempty"` + IsVoiceNote bool `json:"isVoiceNote,omitempty"` +} + +type Asset struct { + URL string `json:"url,omitempty"` + DataBase64 string `json:"dataBase64,omitempty"` +} + +type Reaction struct { + ID string `json:"id"` + ReactionKey string `json:"reactionKey"` + ParticipantID string `json:"participantID"` +} + +type StateSyncEvent struct { + Type string `json:"type"` + ThreadID string `json:"threadID,omitempty"` + ParticipantID string `json:"participantID,omitempty"` + ActivityType string `json:"activityType,omitempty"` + DurationMS int `json:"durationMs,omitempty"` + ObjectName string `json:"objectName"` + MutationType string `json:"mutationType"` + ObjectIDs StateSyncIDs `json:"objectIDs"` + Entries json.RawMessage `json:"entries"` +} + +type StateSyncIDs struct { + ThreadID string `json:"threadID,omitempty"` + MessageID string `json:"messageID,omitempty"` +} diff --git a/pkg/imessageid/dbmeta.go b/pkg/imessageid/dbmeta.go new file mode 100644 index 00000000..bcc23756 --- /dev/null +++ b/pkg/imessageid/dbmeta.go @@ -0,0 +1,28 @@ +package imessageid + +type UserLoginMetadata struct { + DisplayText string `json:"display_text,omitempty"` + Email string `json:"email,omitempty"` + PhoneNumber string `json:"phone_number,omitempty"` +} + +type PortalMetadata struct { + ThreadType string `json:"thread_type,omitempty"` +} + +type MessageMetadata struct { + ThreadID string `json:"thread_id,omitempty"` + Cursor string `json:"cursor,omitempty"` + Attachment bool `json:"attachment,omitempty"` + AttachmentIDs []string `json:"attachment_ids,omitempty"` + AttachmentURLs []string `json:"attachment_urls,omitempty"` +} + +type ReactionMetadata struct { + ReactionID string `json:"reaction_id,omitempty"` + ReactionKey string `json:"reaction_key,omitempty"` +} + +type GhostMetadata struct { + ContactID string `json:"contact_id,omitempty"` +} diff --git a/pkg/imessageid/ids.go b/pkg/imessageid/ids.go new file mode 100644 index 00000000..d3bcdc3d --- /dev/null +++ b/pkg/imessageid/ids.go @@ -0,0 +1,44 @@ +package imessageid + +import ( + "fmt" + "strings" + + "maunium.net/go/mautrix/bridgev2/networkid" +) + +const MessagePartSeparator = "\x1f" + +func MakeUserLoginID(currentUserID string) networkid.UserLoginID { + if currentUserID == "" { + return networkid.UserLoginID("default") + } + return networkid.UserLoginID(currentUserID) +} + +func MakeUserID(userID string) networkid.UserID { + return networkid.UserID(userID) +} + +func MakePortalID(threadID string) networkid.PortalID { + return networkid.PortalID(threadID) +} + +func MakeMessageID(messageID string) networkid.MessageID { + return networkid.MessageID(messageID) +} + +func MakePartID(index int) networkid.PartID { + if index == 0 { + return "" + } + return networkid.PartID(fmt.Sprintf("%d", index)) +} + +func SplitMessagePartID(messageID networkid.MessageID) (networkid.MessageID, networkid.PartID) { + id, part, ok := strings.Cut(string(messageID), MessagePartSeparator) + if !ok { + return messageID, "" + } + return networkid.MessageID(id), networkid.PartID(part) +} diff --git a/src/IMessage/Sources/IMessage/Hashing/PlatformAPI+Hashing.swift b/src/IMessage/Sources/IMessage/Hashing/PlatformAPI+Hashing.swift index 6a33aa92..201db038 100644 --- a/src/IMessage/Sources/IMessage/Hashing/PlatformAPI+Hashing.swift +++ b/src/IMessage/Sources/IMessage/Hashing/PlatformAPI+Hashing.swift @@ -3,11 +3,12 @@ import IMessageCore import PlatformSDK private let platformAPIHasWarmedThreadHasher = Protected(false) +private let platformAPIHasWarmedParticipantHasher = Protected(false) extension PlatformAPI { - nonisolated static func originalThreadID(db: IMDatabase, _ threadID: String) throws -> String { - guard threadID.hasPrefix("imsg") else { - return threadID + nonisolated static func originalThreadID(db: IMDatabase, _ threadID: String) throws -> String { + guard threadID.hasPrefix("imsg") else { + return threadID } do { return try Hasher.thread.recoverOriginal(fromToken: threadID) @@ -22,13 +23,40 @@ extension PlatformAPI { _ = Hasher.thread.tokenizeHashRemembering(pii: guid) } } - return try Hasher.thread.recoverOriginal(fromToken: threadID) - } - } + return try Hasher.thread.recoverOriginal(fromToken: threadID) + } + } - nonisolated static func mapAndHashMessages( - messageRows: [MappedMessageRow], - attachmentRows: [MappedAttachmentRow], + nonisolated static func originalParticipantID(db: IMDatabase, _ participantID: String) throws -> String { + guard participantID.hasPrefix("imsg") else { + return participantID + } + do { + return try Hasher.participant.recoverOriginal(fromToken: participantID) + } catch { + let shouldWarm = platformAPIHasWarmedParticipantHasher.withLock { hasWarmed in + guard !hasWarmed else { return false } + hasWarmed = true + return true + } + if shouldWarm { + let chatRows = try db.mappedThreadRows(cursor: nil, direction: nil, limit: 10_000) + let handleRowsByChatRowID = try db.mappedThreadParticipantRows(chatRowIDs: chatRows.map(\.rowID)) + for handleRows in handleRowsByChatRowID.values { + for row in handleRows { + for id in [row.participantID, row.uncanonicalizedID].compactMap({ $0 }) where !id.isEmpty { + _ = Hasher.participant.tokenizeHashRemembering(pii: id) + } + } + } + } + return try Hasher.participant.recoverOriginal(fromToken: participantID) + } + } + + nonisolated static func mapAndHashMessages( + messageRows: [MappedMessageRow], + attachmentRows: [MappedAttachmentRow], reactionRows: [MappedReactionMessageRow], currentUserID: String, accountID: String diff --git a/src/IMessage/Sources/IMessage/IMessageHost.swift b/src/IMessage/Sources/IMessage/IMessageHost.swift index b15f9a1c..eb3850a0 100644 --- a/src/IMessage/Sources/IMessage/IMessageHost.swift +++ b/src/IMessage/Sources/IMessage/IMessageHost.swift @@ -86,13 +86,14 @@ public enum IMessageHost { } } - public static func bootstrapWithOptions(dataDirPath: String, verbose: Bool, useSecondaryInstance: Bool) { + public static func bootstrapWithOptions(dataDirPath: String, verbose: Bool, useSecondaryInstance: Bool, coordinateWindow: Bool = true) { Preferences.setLoggingDirectory(dataDirPath) Preferences.setUseSecondaryInstance(useSecondaryInstance) Preferences.configureHashing(defaultEnabled: false) Preferences.isLoggingEnabled = verbose Log.consoleOutputEnabled = verbose Defaults.registerDefaults() + Defaults.imessage.set(coordinateWindow, forKey: DefaultsKeys.windowCoordination) bootstrapLock.lock() defer { bootstrapLock.unlock() } diff --git a/src/IMessage/Sources/IMessage/KeyPresser.swift b/src/IMessage/Sources/IMessage/KeyPresser.swift index 2141b131..56aa2f03 100644 --- a/src/IMessage/Sources/IMessage/KeyPresser.swift +++ b/src/IMessage/Sources/IMessage/KeyPresser.swift @@ -74,6 +74,10 @@ class KeyPresser { try pressMappedKey("v", flags: .maskCommand, onMainThread: onMainThread) } + func commandN(onMainThread: Bool = true) throws { + try pressMappedKey("n", flags: .maskCommand, onMainThread: onMainThread) + } + /// marks as read/unread on ventura func commandShiftU(onMainThread: Bool = true) throws { try pressMappedKey("u", flags: [.maskCommand, .maskShift], onMainThread: onMainThread) diff --git a/src/IMessage/Sources/IMessage/MacPermissions.swift b/src/IMessage/Sources/IMessage/MacPermissions.swift index b9f284df..344532b4 100644 --- a/src/IMessage/Sources/IMessage/MacPermissions.swift +++ b/src/IMessage/Sources/IMessage/MacPermissions.swift @@ -63,11 +63,21 @@ public enum MacPermissions { } public static func askForMessagesDirAccess() async throws { + if (try? await canAccessMessagesDir()) == true { + return + } try await accessManager.requestAccess() } + public static func hasMessagesDirAccessGrant() -> Bool { + accessManager.hasAccessGrant + } + public static func canAccessMessagesDir() async throws -> Bool { - try await Task.detached(priority: .userInitiated) { + if accessManager.hasAccessGrant { + return true + } + return try await Task.detached(priority: .userInitiated) { _ = try IMDatabase() return true }.value diff --git a/src/IMessage/Sources/IMessage/Mappers/MessageMapper.swift b/src/IMessage/Sources/IMessage/Mappers/MessageMapper.swift index 629ed5c0..a2f403d5 100644 --- a/src/IMessage/Sources/IMessage/Mappers/MessageMapper.swift +++ b/src/IMessage/Sources/IMessage/Mappers/MessageMapper.swift @@ -18,7 +18,7 @@ struct Mapper { let attachments = attachmentRows.compactMap { attachment(from: $0) } let service = messageRow.service let isSMS = service == "SMS" || service == "RCS" - let isGroup = !(messageRow.roomName ?? "").isEmpty + let isGroup = threadIsGroup(threadID: messageRow.threadID, roomName: messageRow.roomName) let dates = MessageDates(row: messageRow) let summaryInfo = parseSummaryInfo() diff --git a/src/IMessage/Sources/IMessage/Mappers/ThreadMapper.swift b/src/IMessage/Sources/IMessage/Mappers/ThreadMapper.swift index 1b0edf8d..83fa2121 100644 --- a/src/IMessage/Sources/IMessage/Mappers/ThreadMapper.swift +++ b/src/IMessage/Sources/IMessage/Mappers/ThreadMapper.swift @@ -20,15 +20,18 @@ enum ThreadMapper { let selfID = chat.lastAddressedHandle.flatMap(\.nonEmpty) ?? chat.accountLogin.map(mapAccountLogin).flatMap(\.nonEmpty) ?? context.currentUser.id - let firstParticipantID = handleRows.first?.participantID + let localSelfIdentifiers = selfIdentifiers(selfID: selfID, currentUser: context.currentUser) let chatDisplayName = chat.displayName - var participants = handleRows.compactMap { mapParticipant($0, chatDisplayName: chatDisplayName) } - if context.currentUser.id != firstParticipantID { - participants.append(mapSelfParticipant(selfID: selfID, currentUserID: context.currentUser.id)) + var participants = handleRows.compactMap { row -> PlatformSDK.Participant? in + if isSelfHandle(row, selfIdentifiers: localSelfIdentifiers) { + return nil + } + return mapParticipant(row, chatDisplayName: chatDisplayName) } + participants.append(mapSelfParticipant(selfID: selfID, currentUserID: context.currentUser.id)) - let isGroup = chat.roomName?.isEmpty == false + let isGroup = threadIsGroup(threadID: guid, roomName: chat.roomName) let isReadOnly = chat.state == 0 && chat.properties != nil let props = propertyListDictionary(chat.properties) let unreadCount = context.unreadCounts[chat.rowID] ?? 0 @@ -87,6 +90,23 @@ enum ThreadMapper { )) } + private static func selfIdentifiers(selfID: String, currentUser: PlatformSDK.CurrentUser) -> Set { + Set([selfID, currentUser.id, currentUser.email, currentUser.phoneNumber].compactMap(normalizedIdentifier)) + } + + private static func isSelfHandle(_ row: MappedHandleRow, selfIdentifiers: Set) -> Bool { + [row.participantID, row.uncanonicalizedID].compactMap(normalizedIdentifier).contains { selfIdentifiers.contains($0) } + } + + private static func normalizedIdentifier(_ value: String?) -> String? { + guard let normalized = value?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(), + !normalized.isEmpty + else { + return nil + } + return normalized + } + private static func isPhoneLike(_ id: String) -> Bool { !id.hasPrefix("urn:") && !id.contains("@") && id.rangeOfCharacter(from: .decimalDigits) != nil } diff --git a/src/IMessage/Sources/IMessage/Messages/MessagesAccessManager.swift b/src/IMessage/Sources/IMessage/Messages/MessagesAccessManager.swift index bbf05712..03325962 100644 --- a/src/IMessage/Sources/IMessage/Messages/MessagesAccessManager.swift +++ b/src/IMessage/Sources/IMessage/Messages/MessagesAccessManager.swift @@ -11,16 +11,25 @@ final class MessagesAccessManager: NSObject, NSOpenSavePanelDelegate { } private static let messagesBookmarkKey = "TXTMessagesBookmark" + private static let sharedBookmarkSuiteNames = [ + "com.beeper.platform-imessage.bridge", + "imessage-cli", + ] private let expectedURL = MessagesPaths.messagesDirectory var url: URL? + var hasAccessGrant: Bool { url != nil } override init() { super.init() - if let bookmark = UserDefaults.standard.data(forKey: Self.messagesBookmarkKey) { + if let bookmark = Self.bookmarkData() { var isStale = false - url = try? URL(resolvingBookmarkData: bookmark, bookmarkDataIsStale: &isStale) + url = try? URL( + resolvingBookmarkData: bookmark, + options: [.withSecurityScope], + bookmarkDataIsStale: &isStale + ) if isStale || url?.startAccessingSecurityScopedResource() == false { url = nil } @@ -29,6 +38,21 @@ final class MessagesAccessManager: NSObject, NSOpenSavePanelDelegate { log.debug("do we have an initial url? \(url != nil)") } + private static func bookmarkStores() -> [UserDefaults] { + var stores = [UserDefaults.standard] + stores.append(contentsOf: sharedBookmarkSuiteNames.compactMap(UserDefaults.init(suiteName:))) + return stores + } + + private static func bookmarkData() -> Data? { + for store in bookmarkStores() { + if let bookmark = store.data(forKey: messagesBookmarkKey) { + return bookmark + } + } + return nil + } + private func isExpectedURL(_ url: URL) -> Bool { url.standardized.path == expectedURL?.standardized.path } @@ -84,8 +108,10 @@ final class MessagesAccessManager: NSObject, NSOpenSavePanelDelegate { guard url.startAccessingSecurityScopedResource() else { throw ErrorMessage("Could not authorize access to the Messages directory") } - let bookmark = try url.bookmarkData() - UserDefaults.standard.set(bookmark, forKey: Self.messagesBookmarkKey) + let bookmark = try url.bookmarkData(options: [.withSecurityScope]) + for store in Self.bookmarkStores() { + store.set(bookmark, forKey: Self.messagesBookmarkKey) + } self.url?.stopAccessingSecurityScopedResource() self.url = url } diff --git a/src/IMessage/Sources/IMessage/Messages/MessagesAppElements.swift b/src/IMessage/Sources/IMessage/Messages/MessagesAppElements.swift index 936ae9fc..fc13da28 100644 --- a/src/IMessage/Sources/IMessage/Messages/MessagesAppElements.swift +++ b/src/IMessage/Sources/IMessage/Messages/MessagesAppElements.swift @@ -275,18 +275,34 @@ final class MessagesAppElements { log.notice("mainWindow: using compose deep link to try to get main window") try self.openDeepLink(MessagesDeepLink.compose.url()) } else if attempt == 1 { + log.notice("mainWindow: activating Messages and reopening compose deep link to create main window") + self.runningApp.unhide() + self.runningApp.activate() + try MessagesController.openDeepLink( + MessagesDeepLink.compose.url(), + activating: true, + hiding: false, + targeting: self.runningApp + ) + } else if attempt == 2 { + log.notice("mainWindow: sending Command-N to create Messages main window") + self.runningApp.unhide() + self.runningApp.activate() + Thread.sleep(forTimeInterval: 0.2) + try KeyPresser(pid: self.runningApp.processIdentifier).commandN(onMainThread: false) + } else if attempt == 3 { if self.isPromptVisibleInMessagesApp() { log.notice("mainWindow: some prompts are visible, attempting to reset") Defaults.resetPrompts() } - } else if attempt == 2 { + } else if attempt == 4 { if self.isPromptVisibleInMessagesApp() { log.error("mainWindow: some prompts are still visible, force terminating") // regular terminate wont work since all window close buttons are disabled self.runningApp.forceTerminate() // this should invalidate the MessagesController } - } else if attempt > 3 { + } else if attempt > 5 { do { try self.dismissAnyPresentedSheet() } catch { diff --git a/src/IMessage/Sources/IMessage/Messages/MessagesController.swift b/src/IMessage/Sources/IMessage/Messages/MessagesController.swift index 89d404cf..fcbede1e 100644 --- a/src/IMessage/Sources/IMessage/Messages/MessagesController.swift +++ b/src/IMessage/Sources/IMessage/Messages/MessagesController.swift @@ -439,7 +439,6 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) } @inlinable func prepareForAutomation() throws { - log.info("prepareForAutomation") afterAutomationTask?.cancel() log.debug("prepareForAutomation: making the app automatable") @@ -453,7 +452,6 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) } @inlinable func finishedAutomation() { - log.info("finishedAutomation") activityLock.unlock() // this isn't propagated to make finishedAutomation callable inside of defer { … } if Defaults.shouldCoordinateWindow, let mainWindow = elements.getMainWindow() { @@ -1143,7 +1141,7 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) private func sendMessageInField(_ messageField: Accessibility.Element) throws { log.debug("\(#function): focusing field and pressing return") focusMessageField(messageField) // focus is partially redundant, hitting enter without focus works too unless another text field is focused - try keyPresser.return() // in some random cases hitting enter will not send the message (even without automation), until the message input is clicked/focused + try keyPresser.return(onMainThread: false) // embedded bridge calls can block the main queue while waiting for send completion log.debug("\(#function): completed initial attempt") do { @@ -1164,12 +1162,12 @@ isMessagesAppResponsive=\(isMessagesAppResponsive) log.debug("\(#function): focusing and pressing enter again") self.focusMessageField(messageField) - try? self.keyPresser.return() + try? self.keyPresser.return(onMainThread: false) } else if attempt == 6 { log.debug("\(#function): focusing and pressing enter again (alt. strategy)") try? messageField.press() - try? self.keyPresser.return() + try? self.keyPresser.return(onMainThread: false) } } diff --git a/src/IMessage/Sources/IMessage/PlatformAPI.swift b/src/IMessage/Sources/IMessage/PlatformAPI.swift index 0d6f170b..485e885e 100644 --- a/src/IMessage/Sources/IMessage/PlatformAPI.swift +++ b/src/IMessage/Sources/IMessage/PlatformAPI.swift @@ -207,14 +207,19 @@ public final class PlatformAPI { } } - public func getMessages(threadID: String, pagination: PlatformSDK.PaginationArg?) async throws -> PlatformSDK.Paginated { + public func getMessages( + threadID: String, + pagination: PlatformSDK.PaginationArg?, + limit: Int? = nil + ) async throws -> PlatformSDK.Paginated { try await runDBQuery { db, currentUser, accountID in try Self.getMessages( db: db, threadID: threadID, pagination: pagination, currentUserID: currentUser.id, - accountID: accountID + accountID: accountID, + limit: limit ) } } @@ -251,8 +256,12 @@ public final class PlatformAPI { throw ErrorMessage("no message") } - if userIDs.count == 1 { - let existingThreadID = "\(isTahoeOrUp ? "any" : "iMessage");-;\(userIDs[0])" + let resolvedUserIDs = try await runDBQuery { db, _, _ in + try userIDs.map { try Self.originalParticipantID(db: db, $0) } + } + + if resolvedUserIDs.count == 1 { + let existingThreadID = "\(isTahoeOrUp ? "any" : "iMessage");-;\(resolvedUserIDs[0])" let existingThread = try await runDBQuery { db, currentUser, accountID in try Self.getThread( db: db, @@ -263,29 +272,46 @@ public final class PlatformAPI { } if let existingThread { - try await withMessagesController { controller in - try controller.sendMessage( - threadID: existingThreadID, - addresses: nil, - text: messageText, - filePath: nil, - quotedMessage: nil - ) - } + _ = try await sendTextViaOSA(threadID: existingThreadID, text: messageText) return .thread(existingThread) } + } else if let existingGroup = try await existingGroupThread(userIDs: resolvedUserIDs) { + try await withMessagesController { controller in + try controller.sendMessage( + threadID: existingGroup.threadID, + addresses: nil, + text: messageText, + filePath: nil, + quotedMessage: nil + ) + } + return .thread(existingGroup.thread) } - try await withMessagesController { controller in + let lastRowID = try await performControllerOperation( + name: "createThread", + retries: 1, + prepareAttempt: { try await self.lastMessageRowID() } + ) { controller in try controller.sendMessage( threadID: nil, - addresses: userIDs, + addresses: resolvedUserIDs, text: messageText, filePath: nil, quotedMessage: nil ) } - return .boolean(true) + + let sentMessageIDs = try await waitForSentMessageIDs(since: lastRowID, text: messageText, timeout: messageSendTimeout) + let sentThreadIDs = try await waitForSentThreadIDs(messageRowIDs: sentMessageIDs.map(\.rowID)) + guard let threadID = sentThreadIDs.compactMap({ $0 }).first, + let thread = try await runDBQuery({ db, currentUser, accountID in + try Self.getThread(db: db, threadID: threadID, currentUser: currentUser, accountID: accountID) + }) + else { + return .boolean(true) + } + return .thread(thread) } public func updateThread(threadID publicThreadID: String, muted: Bool) async throws { @@ -305,6 +331,10 @@ public final class PlatformAPI { throw ErrorMessage("Cannot send message to email address over SMS") } + if quotedMessageID == nil, filePath == nil, let text, canSendTextViaOSA(threadID: threadID, text: text) { + return try await sendTextViaOSA(threadID: threadID, text: text) + } + let lastRowID = try await performControllerOperation( name: "sendMessage", retries: quotedMessageID == nil ? 1 : 2, @@ -732,17 +762,23 @@ public final class PlatformAPI { expectedLinkedMessageID: String?, text: String?, lastRowID: Int, - timeout: TimeInterval + timeout: TimeInterval, + validateThreadWithController: Bool = true ) async throws -> PlatformSDK.MessageSendResult { let sentMessageIDs = try await waitForSentMessageIDs(since: lastRowID, text: text, timeout: timeout) let sentThreadIDs = try await waitForSentThreadIDs(messageRowIDs: sentMessageIDs.map(\.rowID)) let address = threadIDToAddress(threadID) - let sentThreadIsValid = try await withMessagesController { controller in - sentThreadIDs.allSatisfy { sentThreadID in - if sentThreadID == threadID { return true } - guard let sentThreadID else { return false } - return controller.isSameContact(address, threadIDToAddress(sentThreadID)) + let sentThreadIsValid: Bool + if validateThreadWithController { + sentThreadIsValid = try await withMessagesController { controller in + sentThreadIDs.allSatisfy { sentThreadID in + if sentThreadID == threadID { return true } + guard let sentThreadID else { return false } + return controller.isSameContact(address, threadIDToAddress(sentThreadID)) + } } + } else { + sentThreadIsValid = sentThreadIDs.allSatisfy { $0 == threadID } } guard sentThreadIsValid else { @@ -755,6 +791,33 @@ public final class PlatformAPI { return .messages(messages) } + private func sendTextViaOSA(threadID: String, text: String) async throws -> PlatformSDK.MessageSendResult { + let lastRowID = try await lastMessageRowID() + try OSA.send(threadID: threadID, text: text) + return try await waitForMessageSend( + threadID: threadID, + expectedLinkedMessageID: nil, + text: text, + lastRowID: lastRowID, + timeout: messageSendTimeout, + validateThreadWithController: false + ) + } + + private func canSendTextViaOSA(threadID: String, text: String) -> Bool { + singleParticipantAddress(threadID) != nil && + !Defaults.disableOSAFastPath && + !Preferences.useSecondaryMessagesInstance && + !text.contains("@") && + !text.containsLink + } + + private func existingGroupThread(userIDs: [String]) async throws -> (threadID: String, thread: PlatformSDK.Thread)? { + try await runDBQuery { db, currentUser, accountID in + try Self.existingGroupThread(db: db, userIDs: userIDs, currentUser: currentUser, accountID: accountID) + } + } + private func waitForSentMessageIDs( since lastRowID: Int, text: String?, @@ -825,6 +888,11 @@ public final class PlatformAPI { try? errorMessageReporter?(message) } + nonisolated private static func normalizedChatIdentifier(_ value: String?) -> String? { + let normalized = value?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return normalized?.isEmpty == false ? normalized : nil + } + nonisolated static func getThreads( db: IMDatabase, folderName: String, @@ -861,6 +929,47 @@ public final class PlatformAPI { return try ThreadMapper.mapThread(chatRow, context: context) } + nonisolated static func existingGroupThread( + db: IMDatabase, + userIDs: [String], + currentUser: PlatformSDK.CurrentUser, + accountID: String + ) throws -> (threadID: String, thread: PlatformSDK.Thread)? { + let requestedParticipants = Set(userIDs.compactMap(normalizedChatIdentifier)) + guard requestedParticipants.count == userIDs.count, requestedParticipants.count > 1 else { + return nil + } + + let chatRows = try db.mappedThreadRows(cursor: nil, direction: nil, limit: 10_000) + .filter { threadIsGroup(threadID: $0.guid, roomName: $0.roomName) } + let handleRowsByChatRowID = try db.mappedThreadParticipantRows(chatRowIDs: chatRows.map(\.rowID)) + + for chatRow in chatRows { + let selfIdentifiers = Set([ + chatRow.lastAddressedHandle, + chatRow.accountLogin, + currentUser.id, + currentUser.email, + currentUser.phoneNumber, + ].compactMap(normalizedChatIdentifier)) + let participants = Set((handleRowsByChatRowID[chatRow.rowID] ?? []).compactMap { row -> String? in + let identifiers = [row.participantID, row.uncanonicalizedID].compactMap(normalizedChatIdentifier) + guard !identifiers.contains(where: { selfIdentifiers.contains($0) }) else { + return nil + } + return identifiers.first + }) + guard participants == requestedParticipants else { + continue + } + + let context = try threadMapperContext(db: db, chatRows: [chatRow], currentUser: currentUser, accountID: accountID) + return (chatRow.guid, try ThreadMapper.mapThread(chatRow, context: context)) + } + + return nil + } + nonisolated static func getMessages( db: IMDatabase, threadID publicThreadID: String, diff --git a/src/IMessage/Sources/IMessage/UI/SettingsView.swift b/src/IMessage/Sources/IMessage/UI/SettingsView.swift index 6106570d..d3ea1b11 100644 --- a/src/IMessage/Sources/IMessage/UI/SettingsView.swift +++ b/src/IMessage/Sources/IMessage/UI/SettingsView.swift @@ -278,7 +278,9 @@ struct SettingsView: View { } } +#if ENABLE_SWIFTUI_PREVIEWS @available(macOS 13, *) #Preview { SettingsView() } +#endif diff --git a/src/IMessage/Sources/IMessage/Utilities/ThreadID.swift b/src/IMessage/Sources/IMessage/Utilities/ThreadID.swift index 04da8c08..6d986b93 100644 --- a/src/IMessage/Sources/IMessage/Utilities/ThreadID.swift +++ b/src/IMessage/Sources/IMessage/Utilities/ThreadID.swift @@ -23,3 +23,10 @@ func singleParticipantAddress(_ threadID: String) -> String? { func threadIDIsForGroup(_ id: String) -> Bool { splitThreadID(id).map { $0.1 == MessagesDeepLink.groupThreadType } == true } + +func threadIsGroup(threadID: String?, roomName: String?) -> Bool { + if threadID.map(threadIDIsForGroup) == true { + return true + } + return roomName?.isEmpty == false +} diff --git a/src/IMessage/Sources/IMessage/WindowCoordination/WindowCoordinators/Eclipsing/EclipsingDebuggerView.swift b/src/IMessage/Sources/IMessage/WindowCoordination/WindowCoordinators/Eclipsing/EclipsingDebuggerView.swift index 9473d3e2..1229853c 100644 --- a/src/IMessage/Sources/IMessage/WindowCoordination/WindowCoordinators/Eclipsing/EclipsingDebuggerView.swift +++ b/src/IMessage/Sources/IMessage/WindowCoordination/WindowCoordinators/Eclipsing/EclipsingDebuggerView.swift @@ -204,6 +204,7 @@ struct EclipsingDebuggerView: View { } } +#if ENABLE_SWIFTUI_PREVIEWS @available(macOS 14, *) #Preview { @Previewable var state = EclipsingDebuggerState(points: [ @@ -216,3 +217,4 @@ struct EclipsingDebuggerView: View { EclipsingDebuggerView(state: state) .frame(width: 1920 / 4, height: 1080 / 4) } +#endif diff --git a/src/IMessage/Sources/IMessageBridgeKit/IMessageBridgeKit.swift b/src/IMessage/Sources/IMessageBridgeKit/IMessageBridgeKit.swift new file mode 100644 index 00000000..fdab4ced --- /dev/null +++ b/src/IMessage/Sources/IMessageBridgeKit/IMessageBridgeKit.swift @@ -0,0 +1,728 @@ +import Darwin +import AppKit +import Foundation +import IMessage +import IMessageCore +import PlatformSDK + +private let defaultAccountID = "default" +private let bridgeAutomationTimeout: TimeInterval = 45 + +private final class BridgeRuntime: @unchecked Sendable { + static let shared = BridgeRuntime() + + private let lock = NSLock() + private var api: PlatformAPI? + private var didBootstrap = false + + private let eventCondition = NSCondition() + private var eventBatches: [Any] = [] + private var eventsStarted = false + + func initialize(dataDirPath: String, verbose: Bool, useSecondaryInstance: Bool, coordinateWindow: Bool) throws { + lock.lock() + defer { lock.unlock() } + + if !didBootstrap { + IMessageHost.bootstrapWithOptions( + dataDirPath: dataDirPath, + verbose: verbose, + useSecondaryInstance: useSecondaryInstance, + coordinateWindow: coordinateWindow + ) + didBootstrap = true + } + + if api == nil { + api = try PlatformAPI(accountID: defaultAccountID) + } + } + + func platformAPI() throws -> PlatformAPI { + lock.lock() + defer { lock.unlock() } + + if let api { + return api + } + let api = try PlatformAPI(accountID: defaultAccountID) + self.api = api + return api + } + + func dispose() async throws { + let currentAPI = takeAPIForDispose() + try await currentAPI?.dispose() + resetEventQueue() + } + + private func takeAPIForDispose() -> PlatformAPI? { + lock.lock() + defer { lock.unlock() } + let currentAPI = api + api = nil + eventsStarted = false + return currentAPI + } + + private func resetEventQueue() { + eventCondition.lock() + eventBatches.removeAll() + eventCondition.broadcast() + eventCondition.unlock() + } + + func startEvents() async throws { + let api = try platformAPI() + + guard markEventsStarted() else { return } + + api.subscribeToEvents { [weak self] events in + let values = events.map { $0.jsonObject() } + self?.appendEventBatch(values) + } + do { + try await api.startEventWatchingFromCurrentState() + } catch { + clearEventsStarted() + throw error + } + } + + func watchChat(threadID: String) async throws { + let api = try platformAPI() + try await api.onThreadSelected(threadID: threadID) { [weak self] events in + let values = events.map { $0.jsonObject() } + self?.appendEventBatch(values) + } + } + + private func markEventsStarted() -> Bool { + lock.lock() + defer { lock.unlock() } + let shouldStart = !eventsStarted + eventsStarted = true + return shouldStart + } + + private func clearEventsStarted() { + lock.lock() + eventsStarted = false + lock.unlock() + } + + private func appendEventBatch(_ events: [Any]) { + eventCondition.lock() + eventBatches.append(events) + eventCondition.signal() + eventCondition.unlock() + } + + func nextEventBatch(timeoutMilliseconds: Int) -> Any? { + eventCondition.lock() + defer { eventCondition.unlock() } + + if eventBatches.isEmpty, timeoutMilliseconds > 0 { + let deadline = Date(timeIntervalSinceNow: Double(timeoutMilliseconds) / 1000.0) + while eventBatches.isEmpty, eventCondition.wait(until: deadline) {} + } + + guard !eventBatches.isEmpty else { + return nil + } + return eventBatches.removeFirst() + } +} + +private struct PaginationInput: Decodable { + let cursor: String + let direction: String + let limit: Int? +} + +private func parsePagination(_ raw: UnsafePointer?) throws -> PlatformSDK.PaginationArg? { + try parsePaginationInput(raw).pagination +} + +private func parsePaginationInput(_ raw: UnsafePointer?) throws -> (pagination: PlatformSDK.PaginationArg?, limit: Int?) { + guard let raw else { + return (nil, nil) + } + let string = String(cString: raw) + guard !string.isEmpty else { + return (nil, nil) + } + let data = Data(string.utf8) + let decoded = try JSONDecoder().decode(PaginationInput.self, from: data) + guard let direction = PlatformSDK.PaginationDirection(rawValue: decoded.direction) else { + throw ErrorMessage("invalid pagination direction \(decoded.direction)") + } + return (PlatformSDK.PaginationArg(cursor: decoded.cursor, direction: direction), decoded.limit) +} + +private func parseStringArray(_ raw: UnsafePointer) throws -> [String] { + let string = String(cString: raw) + let data = Data(string.utf8) + guard let values = try JSONSerialization.jsonObject(with: data) as? [String] else { + throw ErrorMessage("expected JSON string array") + } + return values +} + +private func assetResponse(_ result: PlatformAPI.AssetResult) -> Any { + switch result { + case let .url(url): + return ["url": url] + case let .data(data): + return ["dataBase64": data.base64EncodedString()] + } +} + +private func permissionStatus(id: String, title: String, status: MacPermissionAuthStatus, required: Bool, detail: String) -> [String: Any] { + [ + "id": id, + "title": title, + "status": status.rawValue, + "authorized": status == .authorized, + "required": required, + "detail": detail, + ] +} + +private func messagesWindowCount() -> Int { + guard let windows = CGWindowListCopyWindowInfo([.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID) as? [[String: Any]] else { + return 0 + } + return windows.filter { window in + guard let ownerName = window[kCGWindowOwnerName as String] as? String else { + return false + } + return ownerName == "Messages" + }.count +} + +private func automationStatus(accessibility: MacPermissionAuthStatus) -> [String: Any] { + let frontmost = NSWorkspace.shared.frontmostApplication + let frontmostBundleID = frontmost?.bundleIdentifier ?? "" + let frontmostName = frontmost?.localizedName ?? "" + let messagesApps = NSRunningApplication.runningApplications(withBundleIdentifier: "com.apple.MobileSMS") + let messagesApp = messagesApps.first + let windowCount = messagesWindowCount() + + var status = "available" + var reason = "" + var message = "Messages.app automation is available." + + if frontmostBundleID == "com.apple.loginwindow" { + status = "unavailable" + reason = "LOGINWINDOW_FRONTMOST" + message = "The macOS GUI session is at loginwindow, so Messages.app cannot expose an automatable main window. Unlock or focus the user desktop session, then retry." + } else if accessibility != .authorized { + status = "degraded" + reason = "ACCESSIBILITY_NOT_AUTHORIZED" + message = "Accessibility is not authorized, so sending and other Messages.app automation may fail." + } else if messagesApp != nil && windowCount == 0 { + status = "degraded" + reason = "MESSAGES_WINDOW_NOT_VISIBLE" + message = "Messages.app is running but no on-screen Messages window is visible yet." + } + + return [ + "status": status, + "available": status != "unavailable", + "reason": reason, + "message": message, + "frontmostBundleID": frontmostBundleID, + "frontmostName": frontmostName, + "messagesRunning": messagesApp != nil, + "messagesActive": messagesApp?.isActive ?? false, + "messagesHidden": messagesApp?.isHidden ?? false, + "messagesWindowCount": windowCount, + ] +} + +private func authorizationStatus() async -> [String: Any] { + let accessibility = MacPermissions.getAuthStatus(.accessibility) + let contacts = MacPermissions.getAuthStatus(.contacts) + let messagesDataOK = (try? await MacPermissions.canAccessMessagesDir()) == true + let messagesData: MacPermissionAuthStatus = messagesDataOK ? .authorized : .denied + + let permissions: [[String: Any]] = [ + permissionStatus( + id: "accessibility", + title: "Accessibility", + status: accessibility, + required: false, + detail: accessibility == .authorized + ? "The bridge can control Messages.app." + : "Enable this bridge in System Settings > Privacy & Security > Accessibility to send messages and automate Messages.app." + ), + permissionStatus( + id: "contacts", + title: "Contacts", + status: contacts, + required: true, + detail: contacts == .authorized + ? "The bridge can look up contact names and avatars." + : "Allow Contacts access so bridged chats can use local contact names and avatars." + ), + permissionStatus( + id: "messages-data", + title: "Messages Data", + status: messagesData, + required: true, + detail: messagesData == .authorized + ? "The bridge can read your local Messages database." + : "Allow access to ~/Library/Messages. If the folder picker does not grant access, enable Full Disk Access." + ), + [ + "id": "automation", + "title": "Automation", + "status": "requestable", + "authorized": true, + "required": false, + "detail": "The bridge asks for Apple Events access to Messages.app during setup when macOS requires it.", + ], + ] + + return [ + "authorized": contacts == .authorized && messagesData == .authorized, + "permissions": permissions, + "automation": automationStatus(accessibility: accessibility), + ] +} + +private func requestAuthorization(_ target: String) async throws -> [String: Any] { + let names: [String] = switch target { + case "", "all": + ["contacts", "messages-data", "automation"] + case "all-with-accessibility": + ["accessibility", "contacts", "messages-data", "automation"] + default: + [target] + } + + for name in names { + switch name { + case "accessibility": + if MacPermissions.getAuthStatus(.accessibility) != .authorized { + MacPermissions.askForAccessibilityAccess() + } + case "contacts": + if MacPermissions.getAuthStatus(.contacts) != .authorized { + Task.detached { + _ = try? await MacPermissions.askForContactsAccess() + } + } + case "messages-data": + if (try? await MacPermissions.canAccessMessagesDir()) != true { + Task { @MainActor in + do { + try await MacPermissions.askForMessagesDirAccess() + } catch { + MacPermissions.askForFullDiskAccess() + } + } + } + case "automation": + break + default: + throw ErrorMessage("unknown authorization target \(name)") + } + } + + return await authorizationStatus() +} + +private func jsonValue(_ raw: String) -> Any { + guard let data = raw.data(using: .utf8), + let value = try? JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed]) + else { + return raw + } + return value +} + +private func response(ok value: Any?) -> UnsafeMutablePointer? { + let object: [String: Any] = [ + "ok": true, + "payload": value ?? NSNull(), + ] + return cString(try? encodeJSON(object)) +} + +private func response(error: Error) -> UnsafeMutablePointer? { + let object: [String: Any] = [ + "ok": false, + "error": String(describing: error), + ] + return cString(try? encodeJSON(object)) +} + +private func cString(_ string: String?) -> UnsafeMutablePointer? { + strdup(string ?? #"{"ok":false,"error":"failed to encode response"}"#) +} + +private func runBlocking(timeout: TimeInterval? = nil, _ operation: @escaping () async throws -> Any?) -> UnsafeMutablePointer? { + let semaphore = DispatchSemaphore(value: 0) + let box = Protected?>(nil) + + let task = Task { + do { + let value = try await operation() + box.withLock { $0 = .success(value) } + } catch { + box.withLock { $0 = .failure(error) } + } + semaphore.signal() + } + + if let timeout { + let deadline = DispatchTime.now() + timeout + if semaphore.wait(timeout: deadline) == .timedOut { + task.cancel() + return response(error: ErrorMessage("operation timed out after \(Int(timeout))s")) + } + } else { + semaphore.wait() + } + switch box.read() { + case let .success(value): + return response(ok: value) + case let .failure(error): + return response(error: error) + case nil: + return response(error: ErrorMessage("operation ended without a result")) + } +} + +@_cdecl("imessage_bridge_free") +public func imessage_bridge_free(_ pointer: UnsafeMutablePointer?) { + free(pointer) +} + +@_cdecl("imessage_bridge_init") +public func imessage_bridge_init( + _ dataDir: UnsafePointer?, + _ verbose: Int32, + _ useSecondaryInstance: Int32, + _ coordinateWindow: Int32 +) -> UnsafeMutablePointer? { + do { + let dataDirPath = dataDir.map(String.init(cString:)) ?? NSTemporaryDirectory() + try BridgeRuntime.shared.initialize( + dataDirPath: dataDirPath, + verbose: verbose != 0, + useSecondaryInstance: useSecondaryInstance != 0, + coordinateWindow: coordinateWindow != 0 + ) + return response(ok: ["accountID": defaultAccountID]) + } catch { + return response(error: error) + } +} + +@_cdecl("imessage_bridge_dispose") +public func imessage_bridge_dispose() -> UnsafeMutablePointer? { + runBlocking { + try await BridgeRuntime.shared.dispose() + return true + } +} + +@_cdecl("imessage_bridge_current_user") +public func imessage_bridge_current_user() -> UnsafeMutablePointer? { + runBlocking { + try await BridgeRuntime.shared.platformAPI().getCurrentUser().jsonObject + } +} + +@_cdecl("imessage_bridge_authorization_status") +public func imessage_bridge_authorization_status() -> UnsafeMutablePointer? { + runBlocking { + await authorizationStatus() + } +} + +@_cdecl("imessage_bridge_request_authorization") +public func imessage_bridge_request_authorization(_ target: UnsafePointer?) -> UnsafeMutablePointer? { + runBlocking { + try await requestAuthorization(target.map(String.init(cString:)) ?? "all") + } +} + +@_cdecl("imessage_bridge_chats") +public func imessage_bridge_chats(_ paginationJSON: UnsafePointer?) -> UnsafeMutablePointer? { + runBlocking { + let pagination = try parsePagination(paginationJSON) + let page = try await BridgeRuntime.shared.platformAPI().getThreads(folderName: "normal", pagination: pagination) + return page.jsonObject + } +} + +@_cdecl("imessage_bridge_chat") +public func imessage_bridge_chat(_ threadID: UnsafePointer) -> UnsafeMutablePointer? { + runBlocking { + let threadID = String(cString: threadID) + return try await BridgeRuntime.shared.platformAPI().getThread(threadID: threadID)?.jsonObject + } +} + +@_cdecl("imessage_bridge_messages") +public func imessage_bridge_messages( + _ threadID: UnsafePointer, + _ paginationJSON: UnsafePointer? +) -> UnsafeMutablePointer? { + runBlocking { + let threadID = String(cString: threadID) + let paginationInput = try parsePaginationInput(paginationJSON) + return try await BridgeRuntime.shared.platformAPI().getMessages( + threadID: threadID, + pagination: paginationInput.pagination, + limit: paginationInput.limit + ).jsonObject + } +} + +@_cdecl("imessage_bridge_send_text") +public func imessage_bridge_send_text( + _ threadID: UnsafePointer, + _ text: UnsafePointer, + _ quotedMessageID: UnsafePointer? +) -> UnsafeMutablePointer? { + runBlocking(timeout: bridgeAutomationTimeout) { + let quoted = quotedMessageID.map(String.init(cString:)).flatMap { $0.isEmpty ? nil : $0 } + let result = try await BridgeRuntime.shared.platformAPI().sendMessage( + threadID: String(cString: threadID), + text: String(cString: text), + filePath: nil, + quotedMessageID: quoted + ) + return result.jsonValue + } +} + +@_cdecl("imessage_bridge_send_file") +public func imessage_bridge_send_file( + _ threadID: UnsafePointer, + _ filePath: UnsafePointer, + _ quotedMessageID: UnsafePointer? +) -> UnsafeMutablePointer? { + runBlocking(timeout: bridgeAutomationTimeout) { + let quoted = quotedMessageID.map(String.init(cString:)).flatMap { $0.isEmpty ? nil : $0 } + let result = try await BridgeRuntime.shared.platformAPI().sendMessage( + threadID: String(cString: threadID), + text: nil, + filePath: String(cString: filePath), + quotedMessageID: quoted + ) + return result.jsonValue + } +} + +@_cdecl("imessage_bridge_create_chat") +public func imessage_bridge_create_chat( + _ recipientsJSON: UnsafePointer, + _ messageText: UnsafePointer, + _ title: UnsafePointer? +) -> UnsafeMutablePointer? { + runBlocking(timeout: bridgeAutomationTimeout) { + let title = title.map(String.init(cString:)).flatMap { $0.isEmpty ? nil : $0 } + let result = try await BridgeRuntime.shared.platformAPI().createThread( + userIDs: try parseStringArray(recipientsJSON), + title: title, + messageText: String(cString: messageText) + ) + return result.jsonValue + } +} + +@_cdecl("imessage_bridge_edit") +public func imessage_bridge_edit( + _ threadID: UnsafePointer, + _ messageID: UnsafePointer, + _ text: UnsafePointer +) -> UnsafeMutablePointer? { + runBlocking(timeout: bridgeAutomationTimeout) { + try await BridgeRuntime.shared.platformAPI().editMessage( + threadID: String(cString: threadID), + messageID: String(cString: messageID), + content: String(cString: text) + ) + return true + } +} + +@_cdecl("imessage_bridge_delete_message") +public func imessage_bridge_delete_message( + _ threadID: UnsafePointer, + _ messageID: UnsafePointer +) -> UnsafeMutablePointer? { + runBlocking(timeout: bridgeAutomationTimeout) { + try await BridgeRuntime.shared.platformAPI().deleteMessage( + threadID: String(cString: threadID), + messageID: String(cString: messageID) + ) + return true + } +} + +@_cdecl("imessage_bridge_react") +public func imessage_bridge_react( + _ threadID: UnsafePointer, + _ messageID: UnsafePointer, + _ reactionKey: UnsafePointer, + _ enabled: Int32 +) -> UnsafeMutablePointer? { + runBlocking(timeout: bridgeAutomationTimeout) { + let api = try BridgeRuntime.shared.platformAPI() + if enabled != 0 { + try await api.addReaction( + threadID: String(cString: threadID), + messageID: String(cString: messageID), + reactionKey: String(cString: reactionKey) + ) + } else { + try await api.removeReaction( + threadID: String(cString: threadID), + messageID: String(cString: messageID), + reactionKey: String(cString: reactionKey) + ) + } + return true + } +} + +@_cdecl("imessage_bridge_mark_read") +public func imessage_bridge_mark_read(_ threadID: UnsafePointer) -> UnsafeMutablePointer? { + runBlocking(timeout: bridgeAutomationTimeout) { + try await BridgeRuntime.shared.platformAPI().sendReadReceipt(threadID: String(cString: threadID)) + return true + } +} + +@_cdecl("imessage_bridge_mark_unread") +public func imessage_bridge_mark_unread(_ threadID: UnsafePointer) -> UnsafeMutablePointer? { + runBlocking(timeout: bridgeAutomationTimeout) { + try await BridgeRuntime.shared.platformAPI().markAsUnread(threadID: String(cString: threadID)) + return true + } +} + +@_cdecl("imessage_bridge_mute") +public func imessage_bridge_mute( + _ threadID: UnsafePointer, + _ muted: Int32 +) -> UnsafeMutablePointer? { + runBlocking(timeout: bridgeAutomationTimeout) { + try await BridgeRuntime.shared.platformAPI().updateThread( + threadID: String(cString: threadID), + muted: muted != 0 + ) + return true + } +} + +@_cdecl("imessage_bridge_delete_chat") +public func imessage_bridge_delete_chat(_ threadID: UnsafePointer) -> UnsafeMutablePointer? { + runBlocking(timeout: bridgeAutomationTimeout) { + try await BridgeRuntime.shared.platformAPI().deleteThread(threadID: String(cString: threadID)) + return true + } +} + +@_cdecl("imessage_bridge_notify_anyway") +public func imessage_bridge_notify_anyway(_ threadID: UnsafePointer) -> UnsafeMutablePointer? { + runBlocking(timeout: bridgeAutomationTimeout) { + try await BridgeRuntime.shared.platformAPI().notifyAnyway(threadID: String(cString: threadID)) + return true + } +} + +@_cdecl("imessage_bridge_activity_status") +public func imessage_bridge_activity_status(_ threadID: UnsafePointer) -> UnsafeMutablePointer? { + runBlocking { + try await BridgeRuntime.shared.platformAPI().getThreadActivityStatus(threadID: String(cString: threadID)).jsonObject + } +} + +@_cdecl("imessage_bridge_typing") +public func imessage_bridge_typing( + _ threadID: UnsafePointer, + _ enabled: Int32 +) -> UnsafeMutablePointer? { + runBlocking(timeout: bridgeAutomationTimeout) { + try await BridgeRuntime.shared.platformAPI().sendActivityIndicator( + type: enabled != 0 ? "typing" : "none", + threadID: String(cString: threadID) + ) + return true + } +} + +@_cdecl("imessage_bridge_watch_chat") +public func imessage_bridge_watch_chat(_ threadID: UnsafePointer) -> UnsafeMutablePointer? { + runBlocking(timeout: bridgeAutomationTimeout) { + try await BridgeRuntime.shared.watchChat(threadID: String(cString: threadID)) + return true + } +} + +@_cdecl("imessage_bridge_search_messages") +public func imessage_bridge_search_messages( + _ query: UnsafePointer, + _ threadID: UnsafePointer?, + _ paginationJSON: UnsafePointer?, + _ limit: Int32 +) -> UnsafeMutablePointer? { + runBlocking { + let thread = threadID.map(String.init(cString:)).flatMap { $0.isEmpty ? nil : $0 } + let pagination = try parsePagination(paginationJSON) + let page = try await BridgeRuntime.shared.platformAPI().searchMessages( + typed: String(cString: query), + threadID: thread, + mediaOnly: nil, + sender: nil, + pagination: pagination, + limit: limit > 0 ? Int(limit) : nil + ) + return page.jsonObject + } +} + +@_cdecl("imessage_bridge_get_asset") +public func imessage_bridge_get_asset( + _ pathHex: UnsafePointer, + _ methodName: UnsafePointer? +) -> UnsafeMutablePointer? { + runBlocking { + let method = methodName.map(String.init(cString:)).flatMap { $0.isEmpty ? nil : $0 } + let result = try await BridgeRuntime.shared.platformAPI().getAsset( + pathHex: String(cString: pathHex), + methodName: method + ) + return assetResponse(result) + } +} + +@_cdecl("imessage_bridge_load_attachment") +public func imessage_bridge_load_attachment(_ messageID: UnsafePointer) -> UnsafeMutablePointer? { + runBlocking(timeout: bridgeAutomationTimeout) { + try await BridgeRuntime.shared.platformAPI().loadAttachment(messageID: String(cString: messageID)) + return true + } +} + +@_cdecl("imessage_bridge_start_events") +public func imessage_bridge_start_events() -> UnsafeMutablePointer? { + runBlocking { + try await BridgeRuntime.shared.startEvents() + return true + } +} + +@_cdecl("imessage_bridge_next_events") +public func imessage_bridge_next_events(_ timeoutMilliseconds: Int32) -> UnsafeMutablePointer? { + let events = BridgeRuntime.shared.nextEventBatch(timeoutMilliseconds: Int(timeoutMilliseconds)) + return response(ok: events) +} diff --git a/src/IMessage/Sources/IMessageTests/MessageIDTests.swift b/src/IMessage/Sources/IMessageTests/MessageIDTests.swift index 609559d9..9f7c6db3 100644 --- a/src/IMessage/Sources/IMessageTests/MessageIDTests.swift +++ b/src/IMessage/Sources/IMessageTests/MessageIDTests.swift @@ -27,6 +27,13 @@ private let fixtureMessageGUID = "08E8CAE0-FA6F-408D-8E22-FB0712D116D9" #expect(trailing.partIndex == nil) } +@Test func threadIDGroupDetectionDoesNotRequireRoomName() { + #expect(threadIsGroup(threadID: "iMessage;+;untitled-group", roomName: nil)) + #expect(threadIsGroup(threadID: "SMS;+;untitled-group", roomName: "")) + #expect(threadIsGroup(threadID: "iMessage;-;alice@example.com", roomName: "Alice and Bob")) + #expect(!threadIsGroup(threadID: "iMessage;-;alice@example.com", roomName: nil)) +} + @Test func messagesDeepLinkAllowsPartAddressedMessageGUID() throws { let items = try queryItems(for: MessagesDeepLink.message(guid: fixtureMessageGUID, partIndex: 1, overlay: false).url())