From d4712f5d6034d1fb7dec668a8c0eb3a8d9555b4e Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sat, 17 Aug 2024 01:07:59 +0300 Subject: [PATCH] main: delete old bridge --- .gitlab-ci.yml | 7 +- Dockerfile | 7 +- Dockerfile.ci | 7 +- Dockerfile.v2.ci | 15 - backfill.go | 668 ----- build-v2.sh | 9 - build.sh | 2 +- cmd/mautrix-meta/legacyprovision.go | 10 +- cmd/mautrix-meta/main.go | 8 +- commands.go | 593 ----- config/bridge.go | 235 -- config/config.go | 82 - config/upgrade.go | 182 -- custompuppet.go | 97 - database/backfilltask.go | 134 - database/database.go | 51 - database/legacymigrate/migrate.go | 233 -- database/message.go | 234 -- database/portal.go | 212 -- database/puppet.go | 145 -- database/reaction.go | 140 - database/upgrades/00-latest.sql | 137 - database/upgrades/02-edit-count.sql | 4 - database/upgrades/03-backfill-queue.sql | 39 - database/upgrades/04-wa-device-id.sql | 2 - database/upgrades/05-wa-server-name.sql | 3 - .../upgrades/06-user-portal-last-read.sql | 2 - database/upgrades/upgrades.go | 32 - database/user.go | 122 - database/userportal.go | 121 - docker-run.sh | 6 +- example-config.yaml | 338 --- go.mod | 2 + main.go | 352 --- messagetracking.go | 324 --- messagix/types/client.go | 58 + msgconv/from-matrix.go | 165 -- msgconv/from-meta.go | 835 ------ msgconv/from-whatsapp.go | 464 ---- msgconv/media.go | 239 -- msgconv/mentions.go | 95 - msgconv/msgconv.go | 58 - msgconv/to-whatsapp.go | 315 --- pkg/connector/config.go | 14 +- pkg/connector/connector.go | 6 +- pkg/connector/login.go | 32 +- pkg/connector/userinfo.go | 2 +- pkg/msgconv/from-whatsapp.go | 4 +- pkg/msgconv/msgconv.go | 4 +- portal.go | 2246 ----------------- provisioning.go | 152 -- puppet.go | 346 --- user.go | 1314 ---------- 53 files changed, 112 insertions(+), 10792 deletions(-) delete mode 100644 Dockerfile.v2.ci delete mode 100644 backfill.go delete mode 100755 build-v2.sh delete mode 100644 commands.go delete mode 100644 config/bridge.go delete mode 100644 config/config.go delete mode 100644 config/upgrade.go delete mode 100644 custompuppet.go delete mode 100644 database/backfilltask.go delete mode 100644 database/database.go delete mode 100644 database/legacymigrate/migrate.go delete mode 100644 database/message.go delete mode 100644 database/portal.go delete mode 100644 database/puppet.go delete mode 100644 database/reaction.go delete mode 100644 database/upgrades/00-latest.sql delete mode 100644 database/upgrades/02-edit-count.sql delete mode 100644 database/upgrades/03-backfill-queue.sql delete mode 100644 database/upgrades/04-wa-device-id.sql delete mode 100644 database/upgrades/05-wa-server-name.sql delete mode 100644 database/upgrades/06-user-portal-last-read.sql delete mode 100644 database/upgrades/upgrades.go delete mode 100644 database/user.go delete mode 100644 database/userportal.go delete mode 100644 example-config.yaml delete mode 100644 main.go delete mode 100644 messagetracking.go delete mode 100644 msgconv/from-matrix.go delete mode 100644 msgconv/from-meta.go delete mode 100644 msgconv/from-whatsapp.go delete mode 100644 msgconv/media.go delete mode 100644 msgconv/mentions.go delete mode 100644 msgconv/msgconv.go delete mode 100644 msgconv/to-whatsapp.go delete mode 100644 portal.go delete mode 100644 provisioning.go delete mode 100644 puppet.go delete mode 100644 user.go diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 4b68389..2fa759a 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,8 +1,3 @@ include: -#- project: 'mautrix/ci' -# file: '/go.yml' - project: 'mautrix/ci' - file: '/gov2.yml' - -variables: - BINARY_NAME_V2: mautrix-meta + file: '/gov2-as-default.yml' diff --git a/Dockerfile b/Dockerfile index 563e6bf..eb31ce1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1-alpine3.19 AS builder +FROM golang:1-alpine3.20 AS builder RUN apk add --no-cache git ca-certificates build-base su-exec olm-dev @@ -6,15 +6,14 @@ COPY . /build WORKDIR /build RUN go build -o /usr/bin/mautrix-meta -FROM alpine:3.19 +FROM alpine:3.20 ENV UID=1337 \ GID=1337 -RUN apk add --no-cache ffmpeg su-exec ca-certificates olm bash jq yq curl +RUN apk add --no-cache ffmpeg su-exec ca-certificates olm bash jq yq-go curl COPY --from=builder /usr/bin/mautrix-meta /usr/bin/mautrix-meta -COPY --from=builder /build/example-config.yaml /opt/mautrix-meta/example-config.yaml COPY --from=builder /build/docker-run.sh /docker-run.sh VOLUME /data diff --git a/Dockerfile.ci b/Dockerfile.ci index dc94b8d..a0356e0 100644 --- a/Dockerfile.ci +++ b/Dockerfile.ci @@ -1,14 +1,15 @@ -FROM alpine:3.19 +FROM alpine:3.20 ENV UID=1337 \ GID=1337 -RUN apk add --no-cache ffmpeg su-exec ca-certificates bash jq curl yq +RUN apk add --no-cache ffmpeg su-exec ca-certificates bash jq curl yq-go ARG EXECUTABLE=./mautrix-meta COPY $EXECUTABLE /usr/bin/mautrix-meta -COPY ./example-config.yaml /opt/mautrix-meta/example-config.yaml COPY ./docker-run.sh /docker-run.sh +ENV BRIDGEV2=1 VOLUME /data +WORKDIR /data CMD ["/docker-run.sh"] diff --git a/Dockerfile.v2.ci b/Dockerfile.v2.ci deleted file mode 100644 index a0356e0..0000000 --- a/Dockerfile.v2.ci +++ /dev/null @@ -1,15 +0,0 @@ -FROM alpine:3.20 - -ENV UID=1337 \ - GID=1337 - -RUN apk add --no-cache ffmpeg su-exec ca-certificates bash jq curl yq-go - -ARG EXECUTABLE=./mautrix-meta -COPY $EXECUTABLE /usr/bin/mautrix-meta -COPY ./docker-run.sh /docker-run.sh -ENV BRIDGEV2=1 -VOLUME /data -WORKDIR /data - -CMD ["/docker-run.sh"] diff --git a/backfill.go b/backfill.go deleted file mode 100644 index 359c737..0000000 --- a/backfill.go +++ /dev/null @@ -1,668 +0,0 @@ -// mautrix-meta - A Matrix-Facebook Messenger and Instagram DM puppeting bridge. -// Copyright (C) 2024 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package main - -import ( - "cmp" - "context" - "crypto/sha256" - "encoding/base64" - "fmt" - "math/rand" - "slices" - "strconv" - "sync" - "sync/atomic" - "time" - - "github.com/rs/zerolog" - "go.mau.fi/util/variationselector" - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/appservice" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" - - "go.mau.fi/mautrix-meta/database" - "go.mau.fi/mautrix-meta/messagix/socket" - "go.mau.fi/mautrix-meta/messagix/table" -) - -func (user *User) StopBackfillLoop() { - if fn := user.stopBackfillTask.Swap(nil); fn != nil { - (*fn)() - } -} - -type BackfillCollector struct { - *table.UpsertMessages - Source id.UserID - MaxPages int - Forward bool - LastMessage *database.Message - Task *database.BackfillTask - Done func() -} - -func (user *User) handleBackfillTask(ctx context.Context, task *database.BackfillTask) { - log := zerolog.Ctx(ctx) - log.Debug().Any("task", task).Msg("Got backfill task") - portal := user.bridge.GetExistingPortalByThreadID(task.Key) - task.DispatchedAt = time.Now() - task.CompletedAt = time.Time{} - if !portal.MoreToBackfill { - log.Debug().Int64("portal_id", task.Key.ThreadID).Msg("Nothing more to backfill in portal") - task.Finished = true - task.CompletedAt = time.Now() - if err := task.Upsert(ctx); err != nil { - log.Err(err).Msg("Failed to save backfill task") - } - return - } - if err := task.Upsert(ctx); err != nil { - log.Err(err).Msg("Failed to save backfill task") - } - ok := portal.requestMoreHistory(ctx, user, portal.OldestMessageTS, portal.OldestMessageID) - if !ok { - task.CooldownUntil = time.Now().Add(1 * time.Hour) - if err := task.Upsert(ctx); err != nil { - log.Err(err).Msg("Failed to save backfill task") - } - return - } - backfillDone := make(chan struct{}) - doneCallback := sync.OnceFunc(func() { - close(backfillDone) - }) - portal.backfillCollector = &BackfillCollector{ - UpsertMessages: &table.UpsertMessages{ - Range: &table.LSInsertNewMessageRange{ - ThreadKey: portal.ThreadID, - MinTimestampMsTemplate: portal.OldestMessageTS, - MaxTimestampMsTemplate: portal.OldestMessageTS, - MinMessageId: portal.OldestMessageID, - MaxMessageId: portal.OldestMessageID, - MinTimestampMs: portal.OldestMessageTS, - MaxTimestampMs: portal.OldestMessageTS, - HasMoreBefore: true, - HasMoreAfter: true, - }, - }, - Source: user.MXID, - MaxPages: user.bridge.Config.Bridge.Backfill.Queue.PagesAtOnce, - Forward: false, - Task: task, - Done: doneCallback, - } - select { - case <-backfillDone: - case <-ctx.Done(): - return - } - if !portal.MoreToBackfill { - task.Finished = true - } - task.CompletedAt = time.Now() - if err := task.Upsert(ctx); err != nil { - log.Err(err).Msg("Failed to save backfill task") - } - log.Debug().Any("task", task).Msg("Finished backfill task") -} - -func (user *User) BackfillLoop() { - if !user.bridge.Config.Bridge.Backfill.Enabled || !user.bridge.SpecVersions.Supports(mautrix.BeeperFeatureBatchSending) { - return - } - log := user.log.With().Str("action", "backfill loop").Logger() - defer func() { - log.Debug().Msg("Backfill loop stopped") - }() - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - oldFn := user.stopBackfillTask.Swap(&cancel) - if oldFn != nil { - (*oldFn)() - } - ctx = log.WithContext(ctx) - var extraTime time.Duration - sleepBetweenTasks := user.bridge.Config.Bridge.Backfill.Queue.SleepBetweenTasks - initialSleep := time.Duration(rand.Int63n(sleepBetweenTasks.Nanoseconds())) + (sleepBetweenTasks / 2) - log.Debug().Stringer("sleep_duration", initialSleep).Msg("Starting backfill loop after initial delay") - select { - case <-time.After(initialSleep): - case <-ctx.Done(): - return - } - log.Debug().Msg("Backfill loop started") - for { - task, err := user.bridge.DB.BackfillTask.GetNext(ctx, user.MXID) - if err != nil { - log.Err(err).Msg("Failed to get next backfill task") - } else if task != nil { - user.handleBackfillTask(ctx, task) - extraTime = 0 - } else if extraTime < 1*time.Minute { - extraTime += 5 * time.Second - } - select { - case <-time.After(sleepBetweenTasks + extraTime): - case <-ctx.Done(): - return - } - } -} - -func (portal *Portal) requestMoreHistory(ctx context.Context, user *User, minTimestampMS int64, minMessageID string) bool { - resp, err := user.Client.ExecuteTasks(&socket.FetchMessagesTask{ - ThreadKey: portal.ThreadID, - Direction: 0, - ReferenceTimestampMs: minTimestampMS, - ReferenceMessageId: minMessageID, - SyncGroup: 1, - Cursor: user.Client.SyncManager.GetCursor(1), - }) - zerolog.Ctx(ctx).Trace().Any("resp_data", resp).Msg("Response data for fetching messages") - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to request more history") - return false - } else { - zerolog.Ctx(ctx).Debug(). - Int64("min_timestamp_ms", minTimestampMS). - Str("min_message_id", minMessageID). - Msg("Requested more history") - return true - } -} - -var globalUpsertCounter atomic.Int64 - -func (portal *Portal) handleMetaExistingRange(user *User, rng *table.LSUpdateExistingMessageRange) { - portal.backfillLock.Lock() - defer portal.backfillLock.Unlock() - - log := portal.log.With(). - Str("action", "handle meta existing range"). - Stringer("source_mxid", user.MXID). - Int("global_upsert_counter", int(globalUpsertCounter.Add(1))). - Logger() - logEvt := log.Info(). - Int64("timestamp_ms", rng.TimestampMS). - Bool("bool2", rng.UnknownBool2). - Bool("bool3", rng.UnknownBool3) - if portal.backfillCollector == nil { - logEvt.Msg("Ignoring update existing message range command with no backfill collector") - } else if portal.backfillCollector.Source != user.MXID { - logEvt.Stringer("prev_mxid", portal.backfillCollector.Source). - Msg("Ignoring update existing message range command for another user") - } else if portal.backfillCollector.Range.MinTimestampMs != rng.TimestampMS { - logEvt.Int64("prev_timestamp_ms", portal.backfillCollector.Range.MinTimestampMs). - Msg("Ignoring update existing message range command with different timestamp") - } else { - if len(portal.backfillCollector.Messages) == 0 { - logEvt.Msg("Update existing range marked backfill as done, no messages found") - if portal.backfillCollector.Done != nil { - portal.backfillCollector.Done() - } - portal.MoreToBackfill = false - err := portal.Update(log.WithContext(context.TODO())) - if err != nil { - log.Err(err).Msg("Failed to save portal in database") - } - } else { - logEvt.Msg("Update existing range marked backfill as done, processing collected history now") - if rng.UnknownBool2 && !rng.UnknownBool3 { - portal.backfillCollector.Range.HasMoreBefore = false - } else { - portal.backfillCollector.Range.HasMoreAfter = false - } - portal.handleMessageBatch(log.WithContext(context.TODO()), user, portal.backfillCollector.UpsertMessages, portal.backfillCollector.Forward, portal.backfillCollector.LastMessage, portal.backfillCollector.Done) - } - portal.backfillCollector = nil - } -} - -func (portal *Portal) handleMetaUpsertMessages(user *User, upsert *table.UpsertMessages) { - portal.backfillLock.Lock() - defer portal.backfillLock.Unlock() - - if !portal.bridge.Config.Bridge.Backfill.Enabled { - return - } else if upsert.Range == nil { - portal.log.Warn().Int("message_count", len(upsert.Messages)).Msg("Ignoring upsert messages without range") - return - } - log := portal.log.With(). - Str("action", "handle meta upsert"). - Stringer("source_mxid", user.MXID). - Int("global_upsert_counter", int(globalUpsertCounter.Add(1))). - Logger() - log.Info(). - Int64("min_timestamp_ms", upsert.Range.MinTimestampMs). - Str("min_message_id", upsert.Range.MinMessageId). - Int64("max_timestamp_ms", upsert.Range.MaxTimestampMs). - Str("max_message_id", upsert.Range.MaxMessageId). - Bool("has_more_before", upsert.Range.HasMoreBefore). - Bool("has_more_after", upsert.Range.HasMoreAfter). - Int("message_count", len(upsert.Messages)). - Msg("Received upsert messages") - ctx := log.WithContext(context.TODO()) - - // Check if someone is already collecting messages for backfill - if portal.backfillCollector != nil { - if user.MXID != portal.backfillCollector.Source { - log.Warn().Stringer("prev_mxid", portal.backfillCollector.Source).Msg("Ignoring upsert for another user") - return - } else if upsert.Range.MaxTimestampMs > portal.backfillCollector.Range.MinTimestampMs { - log.Warn(). - Int64("prev_min_timestamp_ms", portal.backfillCollector.Range.MinTimestampMs). - Msg("Ignoring unexpected upsert messages while collecting history") - return - } - if portal.backfillCollector.MaxPages > 0 { - portal.backfillCollector.MaxPages-- - } - portal.backfillCollector.UpsertMessages = portal.backfillCollector.Join(upsert) - pageLimitReached := portal.backfillCollector.MaxPages == 0 - endOfChatReached := !upsert.Range.HasMoreBefore - existingMessagesReached := portal.backfillCollector.LastMessage != nil && portal.backfillCollector.Range.MinTimestampMs <= portal.backfillCollector.LastMessage.Timestamp.UnixMilli() - if portal.backfillCollector.Task != nil { - portal.backfillCollector.Task.PageCount++ - if portal.bridge.Config.Bridge.Backfill.Queue.MaxPages >= 0 && portal.backfillCollector.Task.PageCount >= portal.bridge.Config.Bridge.Backfill.Queue.MaxPages { - log.Debug().Any("task", portal.backfillCollector.Task).Msg("Marking backfill task as finished (reached page limit)") - pageLimitReached = true - } - } - logEvt := log.Debug(). - Bool("page_limit_reached", pageLimitReached). - Bool("end_of_chat_reached", endOfChatReached). - Bool("existing_messages_reached", existingMessagesReached) - if !pageLimitReached && !endOfChatReached && !existingMessagesReached { - logEvt.Msg("Requesting more history as collector still has room") - portal.requestMoreHistory(ctx, user, upsert.Range.MinTimestampMs, upsert.Range.MinMessageId) - return - } - logEvt.Msg("Processing collected history now") - portal.handleMessageBatch(ctx, user, portal.backfillCollector.UpsertMessages, portal.backfillCollector.Forward, portal.backfillCollector.LastMessage, portal.backfillCollector.Done) - portal.backfillCollector = nil - return - } - - // No active collector, check the last bridged message - lastMessage, err := portal.bridge.DB.Message.GetLastByTimestamp(ctx, portal.PortalKey, time.Now().Add(1*time.Minute)) - if err != nil { - log.Err(err).Msg("Failed to get last message to check if upsert batch should be handled") - return - } - if lastMessage == nil { - // Chat is empty, request more history or bridge the one received message immediately depending on history_fetch_count - if portal.bridge.Config.Bridge.Backfill.HistoryFetchPages != 0 { - log.Debug().Msg("Got first historical message in empty chat, requesting more") - portal.backfillCollector = &BackfillCollector{ - UpsertMessages: upsert, - Source: user.MXID, - MaxPages: portal.bridge.Config.Bridge.Backfill.HistoryFetchPages, - Forward: true, - } - portal.requestMoreHistory(ctx, user, upsert.Range.MinTimestampMs, upsert.Range.MinMessageId) - } else { - log.Debug().Msg("Got first historical message in empty chat, bridging it immediately") - portal.handleMessageBatch(ctx, user, upsert, true, nil, nil) - } - } else if upsert.Range.MaxTimestampMs > lastMessage.Timestamp.UnixMilli() && upsert.Range.MaxMessageId != lastMessage.ID { - // Chat is not empty and the upsert contains a newer message than the last bridged one, - // request more history to fill the gap or bridge the received one immediately depending on catchup_fetch_count - if portal.bridge.Config.Bridge.Backfill.CatchupFetchPages > 0 { - log.Debug().Msg("Got upsert of new messages, requesting more") - portal.backfillCollector = &BackfillCollector{ - UpsertMessages: upsert, - Source: user.MXID, - MaxPages: portal.bridge.Config.Bridge.Backfill.CatchupFetchPages, - Forward: true, - LastMessage: lastMessage, - } - portal.requestMoreHistory(ctx, user, upsert.Range.MinTimestampMs, upsert.Range.MinMessageId) - } else { - log.Debug().Msg("Got upsert of new messages, bridging them immediately") - portal.handleMessageBatch(ctx, user, upsert, true, lastMessage, nil) - } - } else { - // Chat is not empty and the upsert doesn't contain new messages (and it's not a part of a backfill collector), ignore it. - log.Debug(). - Int64("last_message_ts", lastMessage.Timestamp.UnixMilli()). - Str("last_message_id", lastMessage.ID). - Int64("upsert_max_ts", upsert.Range.MaxTimestampMs). - Str("upsert_max_id", upsert.Range.MaxMessageId). - Msg("Ignoring unrequested upsert before last message") - queueConfig := portal.bridge.Config.Bridge.Backfill.Queue - if queueConfig.MaxPages != 0 && portal.bridge.SpecVersions.Supports(mautrix.BeeperFeatureBatchSending) { - task := portal.bridge.DB.BackfillTask.NewWithValues(portal.PortalKey, user.MXID) - err = task.InsertIfNotExists(ctx) - if err != nil { - log.Err(err).Msg("Failed to ensure backfill task exists") - } - } - } -} - -func (portal *Portal) deterministicEventID(msgID string, partIndex int) id.EventID { - data := fmt.Sprintf("%s/%s", portal.MXID, msgID) - if partIndex != 0 { - data = fmt.Sprintf("%s/%d", data, partIndex) - } - sum := sha256.Sum256([]byte(data)) - return id.EventID(fmt.Sprintf("$%s:%s.com", base64.RawURLEncoding.EncodeToString(sum[:]), portal.bridge.BeeperNetworkName)) -} - -type BackfillPartMetadata struct { - Intent *appservice.IntentAPI - MessageID string - OTID int64 - Sender int64 - PartIndex int - EditCount int64 - Reactions []*table.LSUpsertReaction - InBatchReact *table.LSUpsertReaction -} - -func (portal *Portal) handleMessageBatch(ctx context.Context, source *User, upsert *table.UpsertMessages, forward bool, lastMessage *database.Message, doneCallback func()) { - // The messages are probably already sorted in reverse order (newest to oldest). We want to sort them again to be safe, - // but reverse first to make the sorting algorithm's job easier if it's already sorted. - slices.Reverse(upsert.Messages) - slices.SortFunc(upsert.Messages, func(a, b *table.WrappedMessage) int { - key := cmp.Compare(a.PrimarySortKey, b.PrimarySortKey) - if key == 0 { - key = cmp.Compare(a.SecondarySortKey, b.SecondarySortKey) - } - return key - }) - log := zerolog.Ctx(ctx) - upsert.Messages = slices.CompactFunc(upsert.Messages, func(a, b *table.WrappedMessage) bool { - if a.MessageId == b.MessageId { - log.Debug(). - Str("message_id", a.MessageId). - Bool("attachment_counts_match", len(a.XMAAttachments) == len(b.XMAAttachments) && len(a.BlobAttachments) == len(b.BlobAttachments) && len(a.Stickers) == len(b.Stickers)). - Msg("Backfill batch contained duplicate message") - return true - } - return false - }) - if lastMessage != nil { - // For catchup backfills, delete any messages that are older than the last bridged message. - upsert.Messages = slices.DeleteFunc(upsert.Messages, func(message *table.WrappedMessage) bool { - return message.TimestampMs <= lastMessage.Timestamp.UnixMilli() - }) - } - if portal.OldestMessageTS == 0 || portal.OldestMessageTS > upsert.Range.MinTimestampMs { - portal.OldestMessageTS = upsert.Range.MinTimestampMs - portal.OldestMessageID = upsert.Range.MinMessageId - portal.MoreToBackfill = upsert.Range.HasMoreBefore - err := portal.Update(ctx) - if err != nil { - log.Err(err).Msg("Failed to save oldest message ID/timestamp in database") - } else { - log.Debug(). - Bool("more_to_backfill", portal.MoreToBackfill). - Int64("oldest_message_ts", portal.OldestMessageTS). - Str("oldest_message_id", portal.OldestMessageID). - Msg("Saved oldest message ID/timestamp in database") - } - } - if len(upsert.Messages) == 0 { - log.Warn().Msg("Got empty batch of historical messages") - return - } - log.Info(). - Int64("oldest_message_ts", upsert.Messages[0].TimestampMs). - Str("oldest_message_id", upsert.Messages[0].MessageId). - Int64("newest_message_ts", upsert.Messages[len(upsert.Messages)-1].TimestampMs). - Str("newest_message_id", upsert.Messages[len(upsert.Messages)-1].MessageId). - Int("message_count", len(upsert.Messages)). - Bool("has_more_before", upsert.Range.HasMoreBefore). - Msg("Handling batch of historical messages") - if lastMessage == nil && (upsert.Messages[0].TimestampMs != upsert.Range.MinTimestampMs || upsert.Messages[0].MessageId != upsert.Range.MinMessageId) { - log.Warn(). - Int64("min_timestamp_ms", upsert.Range.MinTimestampMs). - Str("min_message_id", upsert.Range.MinMessageId). - Int64("first_message_ts", upsert.Messages[0].TimestampMs). - Str("first_message_id", upsert.Messages[0].MessageId). - Msg("First message in batch doesn't match range") - } - if !forward { - go func() { - if doneCallback != nil { - defer doneCallback() - } - portal.convertAndSendBackfill(ctx, source, upsert.Messages, upsert.MarkRead, forward) - }() - } else { - if doneCallback != nil { - defer doneCallback() - } - portal.convertAndSendBackfill(ctx, source, upsert.Messages, upsert.MarkRead, forward) - queueConfig := portal.bridge.Config.Bridge.Backfill.Queue - if lastMessage == nil && queueConfig.MaxPages != 0 && portal.bridge.SpecVersions.Supports(mautrix.BeeperFeatureBatchSending) { - task := portal.bridge.DB.BackfillTask.NewWithValues(portal.PortalKey, source.MXID) - err := task.Upsert(ctx) - if err != nil { - log.Err(err).Msg("Failed to save backfill task after initial backfill") - } else { - log.Debug().Msg("Saved backfill task after initial backfill") - } - } - } -} - -func (portal *Portal) convertAndSendBackfill(ctx context.Context, source *User, messages []*table.WrappedMessage, markRead, forward bool) { - log := zerolog.Ctx(ctx) - events := make([]*event.Event, 0, len(messages)) - metas := make([]*BackfillPartMetadata, 0, len(messages)) - ctx = context.WithValue(ctx, msgconvContextKeyClient, source.Client) - if forward { - ctx = context.WithValue(ctx, msgconvContextKeyBackfill, backfillTypeForward) - } else { - ctx = context.WithValue(ctx, msgconvContextKeyBackfill, backfillTypeHistorical) - } - sendReactionsInBatch := portal.bridge.SpecVersions.Supports(mautrix.BeeperFeatureBatchSending) - for _, msg := range messages { - intent := portal.bridge.GetPuppetByID(msg.SenderId).IntentFor(portal) - if intent == nil { - log.Warn().Int64("sender_id", msg.SenderId).Msg("Failed to get intent for sender") - continue - } - ctx := context.WithValue(ctx, msgconvContextKeyIntent, intent) - ctx = log.With(). - Str("message_id", msg.MessageId). - Str("otid", msg.OfflineThreadingId). - Int64("sender_id", msg.SenderId). - Logger().WithContext(ctx) - converted := portal.MsgConv.ToMatrix(ctx, msg) - if portal.bridge.Config.Bridge.CaptionInMessage { - converted.MergeCaption() - } - if len(converted.Parts) == 0 { - log.Warn().Str("message_id", msg.MessageId).Msg("Message was empty after conversion") - continue - } - var reactionsToSendSeparately []*table.LSUpsertReaction - if !sendReactionsInBatch { - reactionsToSendSeparately = msg.Reactions - } - for i, part := range converted.Parts { - content := &event.Content{ - Parsed: part.Content, - Raw: part.Extra, - } - evtType, err := portal.encrypt(ctx, intent, content, part.Type) - if err != nil { - log.Err(err).Str("message_id", msg.MessageId).Int("part_index", i).Msg("Failed to encrypt event") - continue - } - intent.AddDoublePuppetValue(content) - - events = append(events, &event.Event{ - Sender: intent.UserID, - Type: evtType, - Timestamp: msg.TimestampMs, - ID: portal.deterministicEventID(msg.MessageId, i), - RoomID: portal.MXID, - Content: *content, - }) - otid, _ := strconv.ParseInt(msg.OfflineThreadingId, 10, 64) - metas = append(metas, &BackfillPartMetadata{ - Intent: intent, - MessageID: msg.MessageId, - OTID: otid, - Sender: msg.SenderId, - PartIndex: i, - EditCount: msg.EditCount, - Reactions: reactionsToSendSeparately, - }) - reactionsToSendSeparately = nil - } - if sendReactionsInBatch { - reactionTargetEventID := portal.deterministicEventID(msg.MessageId, 0) - for _, react := range msg.Reactions { - reactSender := portal.bridge.GetPuppetByID(react.ActorId) - reactContent := &event.Content{ - Parsed: &event.ReactionEventContent{ - RelatesTo: event.RelatesTo{ - Type: event.RelAnnotation, - EventID: reactionTargetEventID, - Key: variationselector.Add(react.Reaction), - }, - }, - } - reactSender.IntentFor(portal).AddDoublePuppetValue(reactContent) - events = append(events, &event.Event{ - Sender: reactSender.IntentFor(portal).UserID, - Type: event.EventReaction, - Timestamp: react.TimestampMs, - RoomID: portal.MXID, - Content: *reactContent, - }) - metas = append(metas, &BackfillPartMetadata{ - MessageID: msg.MessageId, - InBatchReact: react, - }) - } - } - } - if len(events) == 0 { - log.Info().Msg("No events to send in backfill batch") - return - } - allowNotification := messages[len(messages)-1].TimestampMs < time.Now().Add(-24*time.Hour).UnixMilli() - if unreadHoursThreshold := portal.bridge.Config.Bridge.Backfill.UnreadHoursThreshold; unreadHoursThreshold > 0 && !markRead && len(messages) > 0 { - markRead = messages[len(messages)-1].TimestampMs < time.Now().Add(-time.Duration(unreadHoursThreshold)*time.Hour).UnixMilli() - if markRead { - log.Debug(). - Int64("newest_timestamp_ms", messages[len(messages)-1].TimestampMs). - Msg("Marking chat as read in backfill as it's older than the unread hours threshold") - } - } - if portal.bridge.SpecVersions.Supports(mautrix.BeeperFeatureBatchSending) { - log.Info(). - Int("event_count", len(events)). - Bool("mark_read", markRead). - Bool("allow_notification", allowNotification). - Bool("forward", forward). - Msg("Sending events to Matrix using Beeper batch sending") - portal.sendBackfillBeeper(ctx, source, events, metas, markRead, allowNotification, forward) - } else { - log.Info().Int("event_count", len(events)).Msg("Sending events to Matrix one by one") - portal.sendBackfillLegacy(ctx, source, events, metas, markRead) - } - log.Info().Msg("Finished sending backfill batch") -} - -func (portal *Portal) sendBackfillLegacy(ctx context.Context, source *User, events []*event.Event, metas []*BackfillPartMetadata, markRead bool) { - var lastEventID id.EventID - for i, evt := range events { - resp, err := metas[i].Intent.SendMassagedMessageEvent(ctx, portal.MXID, evt.Type, &evt.Content, evt.Timestamp) - if err != nil { - zerolog.Ctx(ctx).Err(err).Int("evt_index", i).Msg("Failed to send event") - } else { - portal.storeMessageInDB(ctx, resp.EventID, metas[i].MessageID, metas[i].OTID, metas[i].Sender, time.UnixMilli(evt.Timestamp), metas[i].PartIndex) - lastEventID = resp.EventID - } - for _, react := range metas[i].Reactions { - portal.handleMetaReaction(react) - } - } - if markRead && lastEventID != "" { - puppet := portal.bridge.GetPuppetByCustomMXID(source.MXID) - if puppet != nil { - err := portal.SendReadReceipt(ctx, puppet, lastEventID) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to send read receipt after backfill") - } - } - } -} - -func (portal *Portal) sendBackfillBeeper(ctx context.Context, source *User, events []*event.Event, metas []*BackfillPartMetadata, markRead, allowNotification, forward bool) { - var markReadBy id.UserID - if markRead && forward { - markReadBy = source.MXID - } - resp, err := portal.MainIntent().BeeperBatchSend(ctx, portal.MXID, &mautrix.ReqBeeperBatchSend{ - Forward: forward, - SendNotification: allowNotification && forward && !markRead, - MarkReadBy: markReadBy, - Events: events, - }) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to send backfill batch") - return - } else if len(resp.EventIDs) != len(metas) { - zerolog.Ctx(ctx).Error(). - Int("event_count", len(events)). - Int("meta_count", len(metas)). - Msg("Got wrong number of event IDs for backfill batch") - return - } - dbMessages := make([]*database.Message, 0, len(events)) - dbReactions := make([]*database.Reaction, 0) - for i, evtID := range resp.EventIDs { - meta := metas[i] - if meta.InBatchReact != nil { - dbReactions = append(dbReactions, &database.Reaction{ - MessageID: meta.MessageID, - Sender: meta.InBatchReact.ActorId, - Emoji: meta.InBatchReact.Reaction, - MXID: evtID, - }) - } else { - dbMessages = append(dbMessages, &database.Message{ - ID: meta.MessageID, - PartIndex: meta.PartIndex, - Sender: meta.Sender, - OTID: meta.OTID, - MXID: evtID, - Timestamp: time.UnixMilli(events[i].Timestamp), - EditCount: meta.EditCount, - }) - } - } - err = portal.bridge.DB.Message.BulkInsert(ctx, portal.PortalKey, portal.MXID, dbMessages) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to save backfill batch messages to database") - } - err = portal.bridge.DB.Reaction.BulkInsert(ctx, portal.PortalKey, portal.MXID, dbReactions) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to save backfill batch reactions to database") - } -} diff --git a/build-v2.sh b/build-v2.sh deleted file mode 100755 index 2803864..0000000 --- a/build-v2.sh +++ /dev/null @@ -1,9 +0,0 @@ -#!/bin/bash -if [[ -z "$LIBRARY_PATH" && -d /opt/homebrew ]]; then - echo "Using /opt/homebrew for LIBRARY_PATH and CPATH" - export LIBRARY_PATH=/opt/homebrew/lib - export CPATH=/opt/homebrew/include -fi -export MAUTRIX_VERSION=$(cat go.mod | grep 'maunium.net/go/mautrix ' | awk '{ print $2 }') -export GO_LDFLAGS="-s -w -X main.Tag=$(git describe --exact-match --tags 2>/dev/null) -X main.Commit=$(git rev-parse HEAD) -X 'main.BuildTime=`date '+%b %_d %Y, %H:%M:%S'`' -X 'maunium.net/go/mautrix.GoModVersion=$MAUTRIX_VERSION'" -go build -ldflags "$GO_LDFLAGS" ./cmd/mautrix-meta "$@" diff --git a/build.sh b/build.sh index 50b07d4..2803864 100755 --- a/build.sh +++ b/build.sh @@ -6,4 +6,4 @@ if [[ -z "$LIBRARY_PATH" && -d /opt/homebrew ]]; then fi export MAUTRIX_VERSION=$(cat go.mod | grep 'maunium.net/go/mautrix ' | awk '{ print $2 }') export GO_LDFLAGS="-s -w -X main.Tag=$(git describe --exact-match --tags 2>/dev/null) -X main.Commit=$(git rev-parse HEAD) -X 'main.BuildTime=`date '+%b %_d %Y, %H:%M:%S'`' -X 'maunium.net/go/mautrix.GoModVersion=$MAUTRIX_VERSION'" -go build -ldflags "$GO_LDFLAGS" -o mautrix-meta "$@" +go build -ldflags "$GO_LDFLAGS" ./cmd/mautrix-meta "$@" diff --git a/cmd/mautrix-meta/legacyprovision.go b/cmd/mautrix-meta/legacyprovision.go index 198312b..85c36f9 100644 --- a/cmd/mautrix-meta/legacyprovision.go +++ b/cmd/mautrix-meta/legacyprovision.go @@ -9,7 +9,7 @@ import ( "maunium.net/go/mautrix" "maunium.net/go/mautrix/bridgev2" - "go.mau.fi/mautrix-meta/config" + "go.mau.fi/mautrix-meta/messagix/types" "go.mau.fi/mautrix-meta/pkg/connector" ) @@ -30,13 +30,13 @@ type Response struct { Status string `json:"status"` } -func modeToLoginFlowID(mode config.BridgeMode) string { +func modeToLoginFlowID(mode types.Platform) string { switch mode { - case config.ModeFacebook, config.ModeFacebookTor: + case types.Facebook, types.FacebookTor: return connector.FlowIDFacebookCookies - case config.ModeMessenger: + case types.Messenger: return connector.FlowIDMessengerCookies - case config.ModeInstagram: + case types.Instagram: return connector.FlowIDInstagramCookies default: return "" diff --git a/cmd/mautrix-meta/main.go b/cmd/mautrix-meta/main.go index d789962..c4f7c4c 100644 --- a/cmd/mautrix-meta/main.go +++ b/cmd/mautrix-meta/main.go @@ -1,12 +1,12 @@ package main import ( - "strconv" "strings" "maunium.net/go/mautrix/bridgev2/bridgeconfig" "maunium.net/go/mautrix/bridgev2/matrix/mxmain" + "go.mau.fi/mautrix-meta/messagix/types" "go.mau.fi/mautrix-meta/pkg/connector" ) @@ -32,10 +32,10 @@ func main() { m.PostInit = func() { copyData := strings.ReplaceAll( legacyMigrateCopyData, - "'hacky platform placeholder'", - strconv.Itoa(int(c.Config.Mode.ToPlatform())), + "hacky platform placeholder", + c.Config.Mode.String(), ) - if c.Config.Mode == "" { + if c.Config.Mode == types.Unset { copyData = "can't migrate;" } m.CheckLegacyDB( diff --git a/commands.go b/commands.go deleted file mode 100644 index f45ed28..0000000 --- a/commands.go +++ /dev/null @@ -1,593 +0,0 @@ -// mautrix-meta - A Matrix-Facebook Messenger and Instagram DM puppeting bridge. -// Copyright (C) 2024 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package main - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "regexp" - "strings" - "sync" - "time" - - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/bridge/commands" - "maunium.net/go/mautrix/id" - - "go.mau.fi/mautrix-meta/database" - "go.mau.fi/mautrix-meta/messagix/cookies" - "go.mau.fi/mautrix-meta/messagix/methods" - "go.mau.fi/mautrix-meta/messagix/socket" - "go.mau.fi/mautrix-meta/messagix/table" -) - -var ( - HelpSectionConnectionManagement = commands.HelpSection{Name: "Connection management", Order: 11} - HelpSectionCreatingPortals = commands.HelpSection{Name: "Creating portals", Order: 15} - HelpSectionPortalManagement = commands.HelpSection{Name: "Portal management", Order: 20} - HelpSectionMiscellaneous = commands.HelpSection{Name: "Miscellaneous", Order: 30} -) - -type WrappedCommandEvent struct { - *commands.Event - Bridge *MetaBridge - User *User - Portal *Portal -} - -func (br *MetaBridge) RegisterCommands() { - proc := br.CommandProcessor.(*commands.Processor) - proc.AddHandlers( - cmdPing, - cmdLogin, - cmdSyncSpace, - cmdDeleteSession, - cmdToggleEncryption, - cmdSetRelay, - cmdUnsetRelay, - cmdDeletePortal, - cmdDeleteAllPortals, - cmdDeleteThread, - cmdSearch, - cmdRegisterPushNotifications, - ) -} - -func wrapCommand(handler func(*WrappedCommandEvent)) func(*commands.Event) { - return func(ce *commands.Event) { - user := ce.User.(*User) - var portal *Portal - if ce.Portal != nil { - portal = ce.Portal.(*Portal) - } - br := ce.Bridge.Child.(*MetaBridge) - handler(&WrappedCommandEvent{ce, br, user, portal}) - } -} - -var cmdToggleEncryption = &commands.FullHandler{ - Func: wrapCommand(fnToggleEncryption), - Name: "toggle-encryption", - Help: commands.HelpMeta{ - Section: HelpSectionPortalManagement, - Description: "Toggle Messenger-side encryption for the current room", - }, - RequiresPortal: true, - RequiresLogin: true, -} - -func fnToggleEncryption(ce *WrappedCommandEvent) { - if !ce.Bridge.Config.Meta.Mode.IsMessenger() && !ce.Bridge.Config.Meta.IGE2EE { - ce.Reply("Encryption support is not yet enabled in Instagram mode") - return - } else if !ce.Portal.IsPrivateChat() { - ce.Reply("Only private chats can be toggled between encrypted and unencrypted") - return - } - if ce.Portal.ThreadType.IsWhatsApp() { - ce.Portal.ThreadType = table.ONE_TO_ONE - ce.Reply("Messages in this room will now be sent unencrypted over Messenger") - } else { - if len(ce.Args) == 0 || ce.Args[0] != "--force" { - resp, err := ce.User.Client.ExecuteTasks(&socket.CreateWhatsAppThreadTask{ - WAJID: ce.Portal.ThreadID, - OfflineThreadKey: methods.GenerateEpochId(), - ThreadType: table.ENCRYPTED_OVER_WA_ONE_TO_ONE, - FolderType: table.INBOX, - BumpTimestampMS: time.Now().UnixMilli(), - TAMThreadSubtype: 0, - }) - if err != nil { - ce.ZLog.Err(err).Msg("Failed to create WhatsApp thread") - ce.Reply("Failed to create WhatsApp thread") - return - } - ce.ZLog.Trace().Any("create_resp", resp).Msg("Create WhatsApp thread response") - if len(resp.LSIssueNewTask) > 0 { - tasks := make([]socket.Task, len(resp.LSIssueNewTask)) - for i, task := range resp.LSIssueNewTask { - ce.ZLog.Trace().Any("task", task).Msg("Create WhatsApp thread response task") - tasks[i] = task - } - resp, err = ce.User.Client.ExecuteTasks(tasks...) - if err != nil { - ce.ZLog.Err(err).Msg("Failed to create WhatsApp thread (subtask)") - ce.Reply("Failed to create WhatsApp thread") - return - } else { - ce.ZLog.Trace().Any("create_resp", resp).Msg("Create thread response") - } - } - } - ce.Portal.ThreadType = table.ENCRYPTED_OVER_WA_ONE_TO_ONE - ce.Reply("Messages in this room will now be sent encrypted over WhatsApp") - } - err := ce.Portal.Update(ce.Ctx) - if err != nil { - ce.ZLog.Err(err).Msg("Failed to update portal in database") - } -} - -var cmdSetRelay = &commands.FullHandler{ - Func: wrapCommand(fnSetRelay), - Name: "set-relay", - Help: commands.HelpMeta{ - Section: HelpSectionPortalManagement, - Description: "Relay messages in this room through your Meta account.", - }, - RequiresPortal: true, - RequiresLogin: true, -} - -func fnSetRelay(ce *WrappedCommandEvent) { - if !ce.Bridge.Config.Bridge.Relay.Enabled { - ce.Reply("Relay mode is not enabled on this instance of the bridge") - } else if ce.Bridge.Config.Bridge.Relay.AdminOnly && !ce.User.Admin { - ce.Reply("Only bridge admins are allowed to enable relay mode on this instance of the bridge") - } else { - ce.Portal.RelayUserID = ce.User.MXID - err := ce.Portal.Update(ce.Ctx) - if err != nil { - ce.ZLog.Err(err).Msg("Failed to save portal") - } - // TODO reply with Facebook/Instagram instead of Meta - ce.Reply("Messages from non-logged-in users in this room will now be bridged through your Meta account") - } -} - -var cmdUnsetRelay = &commands.FullHandler{ - Func: wrapCommand(fnUnsetRelay), - Name: "unset-relay", - Help: commands.HelpMeta{ - Section: HelpSectionPortalManagement, - Description: "Stop relaying messages in this room.", - }, - RequiresPortal: true, -} - -func fnUnsetRelay(ce *WrappedCommandEvent) { - if !ce.Bridge.Config.Bridge.Relay.Enabled { - ce.Reply("Relay mode is not enabled on this instance of the bridge") - } else if ce.Bridge.Config.Bridge.Relay.AdminOnly && !ce.User.Admin { - ce.Reply("Only bridge admins are allowed to enable relay mode on this instance of the bridge") - } else { - ce.Portal.RelayUserID = "" - err := ce.Portal.Update(ce.Ctx) - if err != nil { - ce.ZLog.Err(err).Msg("Failed to save portal") - } - ce.Reply("Messages from non-logged-in users will no longer be bridged in this room") - } -} - -var cmdDeleteSession = &commands.FullHandler{ - Func: wrapCommand(fnDeleteSession), - Name: "delete-session", - Help: commands.HelpMeta{ - Section: HelpSectionConnectionManagement, - Description: "Disconnect from Meta, clearing sessions but keeping other data. Reconnect with `login`", - }, -} - -func fnDeleteSession(ce *WrappedCommandEvent) { - wasLoggedIn := ce.User.IsLoggedIn() - hadCookies := ce.User.Cookies != nil || ce.User.MetaID != 0 - ce.User.DeleteSession() - if wasLoggedIn { - ce.Reply("Disconnected and deleted session") - } else if hadCookies { - ce.Reply("Wasn't connected, but deleted session") - } else { - ce.Reply("You weren't logged in, but deleted session anyway") - } -} - -var cmdPing = &commands.FullHandler{ - Func: wrapCommand(fnPing), - Name: "ping", - Help: commands.HelpMeta{ - Section: commands.HelpSectionAuth, - Description: "Check your connection to Meta", - }, -} - -func fnPing(ce *WrappedCommandEvent) { - if ce.User.MetaID == 0 { - ce.Reply("You're not logged in") - } else if !ce.User.IsLoggedIn() { - ce.Reply("You were logged in at some point, but are not anymore") - } else if !ce.User.Client.IsConnected() { - ce.Reply("You're logged into Meta, but not connected to the server") - } else { - ce.Reply("You're logged into Meta and probably connected to the server") - } -} - -var cmdSyncSpace = &commands.FullHandler{ - Func: wrapCommand(fnSyncSpace), - Name: "sync-space", - Help: commands.HelpMeta{ - Section: HelpSectionMiscellaneous, - Description: "Synchronize your personal filtering space", - }, - RequiresLogin: true, -} - -func fnSyncSpace(ce *WrappedCommandEvent) { - if !ce.Bridge.Config.Bridge.PersonalFilteringSpaces { - ce.Reply("Personal filtering spaces are not enabled on this instance of the bridge") - return - } - ctx := ce.Ctx - dmKeys, err := ce.Bridge.DB.Portal.FindPrivateChatsNotInSpace(ctx, ce.User.MetaID) - if err != nil { - ce.ZLog.Err(err).Msg("Failed to get private chat keys") - ce.Reply("Failed to get private chat IDs from database") - return - } - count := 0 - allPortals := ce.Bridge.GetAllPortalsWithMXID() - for _, portal := range allPortals { - if portal.IsPrivateChat() { - continue - } - if ce.Bridge.StateStore.IsInRoom(ctx, portal.MXID, ce.User.MXID) && portal.addToPersonalSpace(ctx, ce.User) { - count++ - } - } - for _, key := range dmKeys { - portal := ce.Bridge.GetExistingPortalByThreadID(key) - portal.addToPersonalSpace(ctx, ce.User) - count++ - } - plural := "s" - if count == 1 { - plural = "" - } - ce.Reply("Added %d room%s to space", count, plural) -} - -var cmdRegisterPushNotifications = &commands.FullHandler{ - Func: wrapCommand(fnRegisterForPushNotifications), - Name: "register-push-notifications", - Help: commands.HelpMeta{ - Section: commands.HelpSectionGeneral, - Description: "Register for push notifications", - Args: "", - }, -} - -func fnRegisterForPushNotifications(ce *WrappedCommandEvent) { - endpoint := ce.Args[0] - var err error - if ce.User.Cookies.Platform.IsMessenger() { - err = ce.User.Client.Facebook.RegisterPushNotifications(endpoint) - } else { - err = ce.User.Client.Instagram.RegisterPushNotifications(endpoint) - } - if err != nil { - ce.Reply("failed to register for push notifications") - } - ce.Reply("successfully registered for push notifications") -} - -var cmdLogin = &commands.FullHandler{ - Func: wrapCommand(fnLogin), - Name: "login", - Help: commands.HelpMeta{ - Section: commands.HelpSectionAuth, - Description: "Link the bridge to your Meta account.", - }, -} - -func fnLogin(ce *WrappedCommandEvent) { - if ce.User.IsLoggedIn() { - if ce.User.Client.IsConnected() { - ce.Reply("You're already logged in") - } else { - ce.Reply("You're already logged in, but not connected 🤔") - } - return - } - - ce.Reply("Paste your cookies here (either as a JSON object or cURL request). " + - "See full instructions at ") - ce.User.commandState = &commands.CommandState{ - Next: wrappedFnLoginEnterCookies, - Action: "Login", - } -} - -var wrappedFnLoginEnterCookies = commands.MinimalHandlerFunc(wrapCommand(fnLoginEnterCookies)) -var curlCookieRegex = regexp.MustCompile(`-H '[cC]ookie: ([^']*)'`) - -func fnLoginEnterCookies(ce *WrappedCommandEvent) { - var newCookies cookies.Cookies - newCookies.Platform = database.MessagixPlatform - ce.Redact() - if strings.HasPrefix(strings.TrimSpace(ce.RawArgs), "curl") { - cookieHeader := curlCookieRegex.FindStringSubmatch(ce.RawArgs) - if len(cookieHeader) != 2 { - ce.Reply("Couldn't find `-H 'Cookie: ...'` in curl command") - return - } - parsed := (&http.Request{Header: http.Header{"Cookie": {cookieHeader[1]}}}).Cookies() - data := make(map[string]string) - for _, cookie := range parsed { - data[cookie.Name] = cookie.Value - } - rawData, _ := json.Marshal(data) - err := json.Unmarshal(rawData, &newCookies) - if err != nil { - ce.Reply("Failed to parse cookies into struct: %v", err) - return - } - } else { - err := json.Unmarshal([]byte(ce.RawArgs), &newCookies) - if err != nil { - ce.Reply("Failed to parse input as JSON: %v", err) - return - } - } - missingRequiredCookies := newCookies.GetMissingCookieNames() - if len(missingRequiredCookies) > 0 { - ce.Reply("Missing some cookies: %v", missingRequiredCookies) - return - } - err := ce.User.Login(ce.Ctx, &newCookies) - if err != nil { - ce.Reply("Failed to log in: %v", err) - } else { - ce.Reply("Successfully logged in as %d", ce.User.MetaID) - } -} - -var cmdDeleteThread = &commands.FullHandler{ - Func: wrapCommand(fnDeleteThread), - Name: "delete-chat", - Aliases: []string{"delete-thread"}, - Help: commands.HelpMeta{ - Section: HelpSectionPortalManagement, - Description: "Delete the current chat on Meta and then delete the Matrix room", - }, - RequiresLogin: true, - RequiresPortal: true, -} - -func fnDeleteThread(ce *WrappedCommandEvent) { - if ce.Portal.ThreadType.IsWhatsApp() { - ce.Reply("Deleting encrypted chats is not yet supported") - return - } - resp, err := ce.User.Client.ExecuteTasks(&socket.DeleteThreadTask{ - ThreadKey: ce.Portal.ThreadID, - RemoveType: 0, - SyncGroup: 1, // 95 for encrypted chats - }) - ce.ZLog.Trace().Any("response_data", resp).Err(err).Msg("Delete thread response") - if err != nil { - ce.ZLog.Err(err).Msg("Failed to delete thread") - ce.Reply("Failed to delete thread") - } else { - ce.Portal.Delete() - ce.Portal.Cleanup(ce.Ctx, false) - } -} - -var cmdSearch = &commands.FullHandler{ - Func: wrapCommand(fnSearch), - Name: "search", - Help: commands.HelpMeta{ - Section: HelpSectionCreatingPortals, - Description: "Search for a user on Meta", - Args: "", - }, - RequiresLogin: true, -} - -func fnSearch(ce *WrappedCommandEvent) { - task := &socket.SearchUserTask{ - Query: ce.RawArgs, - SupportedTypes: []table.SearchType{ - table.SearchTypeContact, table.SearchTypeGroup, table.SearchTypePage, table.SearchTypeNonContact, - table.SearchTypeIGContactFollowing, table.SearchTypeIGContactNonFollowing, - table.SearchTypeIGNonContactFollowing, table.SearchTypeIGNonContactNonFollowing, - }, - SurfaceType: 15, - Secondary: false, - } - if ce.Bridge.Config.Meta.Mode.IsMessenger() { - task.SurfaceType = 5 - task.SupportedTypes = append(task.SupportedTypes, table.SearchTypeCommunityMessagingThread) - } - taskCopy := *task - taskCopy.Secondary = true - secondaryTask := &taskCopy - - go func() { - time.Sleep(10 * time.Millisecond) - resp, err := ce.User.Client.ExecuteTasks(secondaryTask) - ce.ZLog.Trace().Any("response_data", resp).Err(err).Msg("Search secondary response") - // The secondary response doesn't seem to have anything important, so just ignore it - }() - - resp, err := ce.User.Client.ExecuteTasks(task) - ce.ZLog.Trace().Any("response_data", resp).Msg("Search primary response") - if err != nil { - ce.ZLog.Err(err).Msg("Failed to search users") - ce.Reply("Failed to search for users (see logs for more details)") - return - } - puppets := make([]*Puppet, 0, len(resp.LSInsertSearchResult)) - subtitles := make([]string, 0, len(resp.LSInsertSearchResult)) - var wg sync.WaitGroup - wg.Add(1) - for _, result := range resp.LSInsertSearchResult { - if result.ThreadType == table.ONE_TO_ONE && result.CanViewerMessage && result.GetFBID() != 0 { - puppet := ce.Bridge.GetPuppetByID(result.GetFBID()) - puppets = append(puppets, puppet) - subtitles = append(subtitles, result.ContextLine) - wg.Add(1) - go func(result *table.LSInsertSearchResult) { - defer wg.Done() - puppet.UpdateInfo(ce.Ctx, result) - }(result) - } - } - wg.Done() - wg.Wait() - if len(puppets) == 0 { - ce.Reply("No results") - return - } - var output strings.Builder - output.WriteString("Results:\n\n") - for i, puppet := range puppets { - _, _ = fmt.Fprintf(&output, "* [%s](%s) (`%d`)\n %s\n", puppet.Name, puppet.MXID.URI().MatrixToURL(), puppet.ID, subtitles[i]) - } - ce.Reply(output.String()) -} - -func canDeletePortal(ctx context.Context, portal *Portal, userID id.UserID) bool { - if len(portal.MXID) == 0 { - return false - } - - members, err := portal.MainIntent().JoinedMembers(ctx, portal.MXID) - if err != nil { - portal.log.Err(err). - Str("user_id", userID.String()). - Msg("Failed to get joined members to check if user can delete portal") - return false - } - for otherUser := range members.Joined { - _, isPuppet := portal.bridge.ParsePuppetMXID(otherUser) - if isPuppet || otherUser == portal.bridge.Bot.UserID || otherUser == userID { - continue - } - user := portal.bridge.GetUserByMXID(otherUser) - if user != nil && user.IsLoggedIn() { - return false - } - } - return true -} - -var cmdDeletePortal = &commands.FullHandler{ - Func: wrapCommand(fnDeletePortal), - Name: "delete-portal", - Help: commands.HelpMeta{ - Section: HelpSectionPortalManagement, - Description: "Delete the current portal. If the portal is used by other people, this is limited to bridge admins.", - }, - RequiresPortal: true, -} - -func fnDeletePortal(ce *WrappedCommandEvent) { - if !ce.User.Admin && !canDeletePortal(ce.Ctx, ce.Portal, ce.User.MXID) { - ce.Reply("Only bridge admins can delete portals with other Matrix users") - return - } - - ce.Portal.log.Info().Stringer("user_id", ce.User.MXID).Msg("User requested deletion of portal") - ce.Portal.Delete() - ce.Portal.Cleanup(ce.Ctx, false) -} - -var cmdDeleteAllPortals = &commands.FullHandler{ - Func: wrapCommand(fnDeleteAllPortals), - Name: "delete-all-portals", - Help: commands.HelpMeta{ - Section: HelpSectionPortalManagement, - Description: "Delete all portals.", - }, -} - -func fnDeleteAllPortals(ce *WrappedCommandEvent) { - portals := ce.Bridge.GetAllPortalsWithMXID() - var portalsToDelete []*Portal - - if ce.User.Admin { - portalsToDelete = portals - } else { - portalsToDelete = portals[:0] - for _, portal := range portals { - if canDeletePortal(ce.Ctx, portal, ce.User.MXID) { - portalsToDelete = append(portalsToDelete, portal) - } - } - } - if len(portalsToDelete) == 0 { - ce.Reply("Didn't find any portals to delete") - return - } - - leave := func(portal *Portal) { - if len(portal.MXID) > 0 { - _, _ = portal.MainIntent().KickUser(ce.Ctx, portal.MXID, &mautrix.ReqKickUser{ - Reason: "Deleting portal", - UserID: ce.User.MXID, - }) - } - } - customPuppet := ce.Bridge.GetPuppetByCustomMXID(ce.User.MXID) - if customPuppet != nil && customPuppet.CustomIntent() != nil { - intent := customPuppet.CustomIntent() - leave = func(portal *Portal) { - if len(portal.MXID) > 0 { - _, _ = intent.LeaveRoom(ce.Ctx, portal.MXID) - _, _ = intent.ForgetRoom(ce.Ctx, portal.MXID) - } - } - } - ce.Reply("Found %d portals, deleting...", len(portalsToDelete)) - for _, portal := range portalsToDelete { - portal.Delete() - leave(portal) - } - ce.Reply("Finished deleting portal info. Now cleaning up rooms in background.") - - backgroundCtx := context.TODO() - go func() { - for _, portal := range portalsToDelete { - portal.Cleanup(backgroundCtx, false) - } - ce.Reply("Finished background cleanup of deleted portal rooms.") - }() -} diff --git a/config/bridge.go b/config/bridge.go deleted file mode 100644 index 7377537..0000000 --- a/config/bridge.go +++ /dev/null @@ -1,235 +0,0 @@ -// mautrix-meta - A Matrix-Facebook Messenger and Instagram DM puppeting bridge. -// Copyright (C) 2024 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package config - -import ( - "errors" - "fmt" - "strings" - "text/template" - "time" - - "maunium.net/go/mautrix/bridge/bridgeconfig" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" -) - -type BridgeConfig struct { - UsernameTemplate string `yaml:"username_template"` - DisplaynameTemplate string `yaml:"displayname_template"` - PrivateChatPortalMeta string `yaml:"private_chat_portal_meta"` - - PortalMessageBuffer int `yaml:"portal_message_buffer"` - - PersonalFilteringSpaces bool `yaml:"personal_filtering_spaces"` - BridgeNotices bool `yaml:"bridge_notices"` - DeliveryReceipts bool `yaml:"delivery_receipts"` - MessageStatusEvents bool `yaml:"message_status_events"` - MessageErrorNotices bool `yaml:"message_error_notices"` - DisableBridgeAlerts bool `yaml:"disable_bridge_alerts"` - SyncDirectChatList bool `yaml:"sync_direct_chat_list"` - ResendBridgeInfo bool `yaml:"resend_bridge_info"` - CaptionInMessage bool `yaml:"caption_in_message"` - FederateRooms bool `yaml:"federate_rooms"` - MuteBridging string `yaml:"mute_bridging"` - - DoublePuppetConfig bridgeconfig.DoublePuppetConfig `yaml:",inline"` - - MessageHandlingTimeout struct { - ErrorAfterStr string `yaml:"error_after"` - DeadlineStr string `yaml:"deadline"` - - ErrorAfter time.Duration `yaml:"-"` - Deadline time.Duration `yaml:"-"` - } `yaml:"message_handling_timeout"` - - CommandPrefix string `yaml:"command_prefix"` - - Backfill struct { - Enabled bool `yaml:"enabled"` - InboxFetchPages int `yaml:"inbox_fetch_pages"` - HistoryFetchPages int `yaml:"history_fetch_pages"` - CatchupFetchPages int `yaml:"catchup_fetch_pages"` - UnreadHoursThreshold int `yaml:"unread_hours_threshold"` - Queue struct { - PagesAtOnce int `yaml:"pages_at_once"` - MaxPages int `yaml:"max_pages"` - SleepBetweenTasks time.Duration `yaml:"sleep_between_tasks"` - DontFetchXMA bool `yaml:"dont_fetch_xma"` - } `yaml:"queue"` - } `yaml:"backfill"` - DisableXMA bool `yaml:"disable_xma"` - - ManagementRoomText bridgeconfig.ManagementRoomTexts `yaml:"management_room_text"` - - Encryption bridgeconfig.EncryptionConfig `yaml:"encryption"` - - Provisioning struct { - Prefix string `yaml:"prefix"` - SharedSecret string `yaml:"shared_secret"` - DebugEndpoints bool `yaml:"debug_endpoints"` - } `yaml:"provisioning"` - - Permissions bridgeconfig.PermissionConfig `yaml:"permissions"` - - Relay RelaybotConfig `yaml:"relay"` - - usernameTemplate *template.Template `yaml:"-"` - displaynameTemplate *template.Template `yaml:"-"` -} - -func (bc *BridgeConfig) GetResendBridgeInfo() bool { - return bc.ResendBridgeInfo -} - -func (bc *BridgeConfig) EnableMessageStatusEvents() bool { - return bc.MessageStatusEvents -} - -func (bc *BridgeConfig) EnableMessageErrorNotices() bool { - return bc.MessageErrorNotices -} - -func boolToInt(val bool) int { - if val { - return 1 - } - return 0 -} - -func (bc *BridgeConfig) Validate() error { - _, hasWildcard := bc.Permissions["*"] - _, hasExampleDomain := bc.Permissions["example.com"] - _, hasExampleUser := bc.Permissions["@admin:example.com"] - exampleLen := boolToInt(hasWildcard) + boolToInt(hasExampleUser) + boolToInt(hasExampleDomain) - if len(bc.Permissions) <= exampleLen { - return errors.New("bridge.permissions not configured") - } - return nil -} - -type umBridgeConfig BridgeConfig - -func (bc *BridgeConfig) UnmarshalYAML(unmarshal func(interface{}) error) error { - err := unmarshal((*umBridgeConfig)(bc)) - if err != nil { - return err - } - - bc.usernameTemplate, err = template.New("username").Parse(bc.UsernameTemplate) - if err != nil { - return err - } else if !strings.Contains(bc.FormatUsername("1234567890"), "1234567890") { - return fmt.Errorf("username template is missing user ID placeholder") - } - bc.displaynameTemplate, err = template.New("displayname").Parse(bc.DisplaynameTemplate) - if err != nil { - return err - } - - return nil -} - -var _ bridgeconfig.BridgeConfig = (*BridgeConfig)(nil) - -func (bc BridgeConfig) GetDoublePuppetConfig() bridgeconfig.DoublePuppetConfig { - return bc.DoublePuppetConfig -} - -func (bc BridgeConfig) GetEncryptionConfig() bridgeconfig.EncryptionConfig { - return bc.Encryption -} - -func (bc BridgeConfig) GetCommandPrefix() string { - return bc.CommandPrefix -} - -func (bc BridgeConfig) GetManagementRoomTexts() bridgeconfig.ManagementRoomTexts { - return bc.ManagementRoomText -} - -func (bc BridgeConfig) FormatUsername(userID string) string { - var buffer strings.Builder - _ = bc.usernameTemplate.Execute(&buffer, userID) - return buffer.String() -} - -type DisplaynameParams struct { - DisplayName string - Username string - ID int64 -} - -func (bc BridgeConfig) FormatDisplayname(params DisplaynameParams) string { - var buffer strings.Builder - _ = bc.displaynameTemplate.Execute(&buffer, params) - return buffer.String() -} - -type RelaybotConfig struct { - Enabled bool `yaml:"enabled"` - AdminOnly bool `yaml:"admin_only"` - MessageFormats map[event.MessageType]string `yaml:"message_formats"` - messageTemplates *template.Template `yaml:"-"` -} - -type umRelaybotConfig RelaybotConfig - -func (rc *RelaybotConfig) UnmarshalYAML(unmarshal func(interface{}) error) error { - err := unmarshal((*umRelaybotConfig)(rc)) - if err != nil { - return err - } - - rc.messageTemplates = template.New("messageTemplates") - for key, format := range rc.MessageFormats { - _, err := rc.messageTemplates.New(string(key)).Parse(format) - if err != nil { - return err - } - } - - return nil -} - -type Sender struct { - UserID string - event.MemberEventContent -} - -type formatData struct { - Sender Sender - Message string - Content *event.MessageEventContent -} - -func (rc *RelaybotConfig) FormatMessage(content *event.MessageEventContent, sender id.UserID, member event.MemberEventContent) (string, error) { - if len(member.Displayname) == 0 { - member.Displayname = sender.String() - } - member.Displayname = template.HTMLEscapeString(member.Displayname) - var output strings.Builder - err := rc.messageTemplates.ExecuteTemplate(&output, string(content.MsgType), formatData{ - Sender: Sender{ - UserID: template.HTMLEscapeString(sender.String()), - MemberEventContent: member, - }, - Content: content, - Message: content.FormattedBody, - }) - return output.String(), err -} diff --git a/config/config.go b/config/config.go deleted file mode 100644 index fe55908..0000000 --- a/config/config.go +++ /dev/null @@ -1,82 +0,0 @@ -// mautrix-meta - A Matrix-Facebook Messenger and Instagram DM puppeting bridge. -// Copyright (C) 2024 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package config - -import ( - "maunium.net/go/mautrix/bridge/bridgeconfig" - "maunium.net/go/mautrix/id" - - "go.mau.fi/mautrix-meta/messagix/types" -) - -type BridgeMode string - -const ( - ModeInstagram BridgeMode = "instagram" - ModeFacebook BridgeMode = "facebook" - ModeFacebookTor BridgeMode = "facebook-tor" - ModeMessenger BridgeMode = "messenger" -) - -func (bm BridgeMode) IsValid() bool { - return bm == ModeInstagram || bm == ModeFacebook || bm == ModeFacebookTor || bm == ModeMessenger -} - -func (bm BridgeMode) IsMessenger() bool { - return bm == ModeFacebook || bm == ModeFacebookTor || bm == ModeMessenger -} - -func (bm BridgeMode) IsInstagram() bool { - return bm == ModeInstagram -} - -func (bm BridgeMode) ToPlatform() types.Platform { - switch bm { - case ModeInstagram: - return types.Instagram - case ModeFacebook: - return types.Facebook - case ModeFacebookTor: - return types.FacebookTor - case ModeMessenger: - return types.Messenger - default: - return types.Unset - } -} - -type Config struct { - *bridgeconfig.BaseConfig `yaml:",inline"` - - Meta struct { - Mode BridgeMode `yaml:"mode"` - IGE2EE bool `yaml:"ig_e2ee"` - Proxy string `yaml:"proxy"` - GetProxyFrom string `yaml:"get_proxy_from"` - MinFullReconnectIntervalSeconds int `yaml:"min_full_reconnect_interval_seconds"` - ForceRefreshIntervalSeconds int `yaml:"force_refresh_interval_seconds"` - } `yaml:"meta"` - - Bridge BridgeConfig `yaml:"bridge"` -} - -func (config *Config) CanAutoDoublePuppet(userID id.UserID) bool { - _, homeserver, _ := userID.Parse() - _, hasSecret := config.Bridge.DoublePuppetConfig.SharedSecretMap[homeserver] - - return hasSecret -} diff --git a/config/upgrade.go b/config/upgrade.go deleted file mode 100644 index 6b146d4..0000000 --- a/config/upgrade.go +++ /dev/null @@ -1,182 +0,0 @@ -// mautrix-meta - A Matrix-Facebook Messenger and Instagram DM puppeting bridge. -// Copyright (C) 2024 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package config - -import ( - "net/url" - "strings" - - up "go.mau.fi/util/configupgrade" - "go.mau.fi/util/random" - "maunium.net/go/mautrix/bridge/bridgeconfig" -) - -func DoUpgrade(helper up.Helper) { - bridgeconfig.Upgrader.DoUpgrade(helper) - - legacyDB, ok := helper.Get(up.Str, "appservice", "database") - if ok { - if strings.HasPrefix(legacyDB, "postgres") { - parsedDB, err := url.Parse(legacyDB) - if err != nil { - panic(err) - } - q := parsedDB.Query() - if parsedDB.Host == "" && !q.Has("host") { - q.Set("host", "/var/run/postgresql") - } else if !q.Has("sslmode") { - q.Set("sslmode", "disable") - } - parsedDB.RawQuery = q.Encode() - helper.Set(up.Str, parsedDB.String(), "appservice", "database", "uri") - helper.Set(up.Str, "postgres", "appservice", "database", "type") - } else { - dbPath := strings.TrimPrefix(strings.TrimPrefix(legacyDB, "sqlite:"), "///") - helper.Set(up.Str, dbPath, "appservice", "database", "uri") - helper.Set(up.Str, "sqlite3-fk-wal", "appservice", "database", "type") - } - } - if legacyDBMinSize, ok := helper.Get(up.Int, "appservice", "database_opts", "min_size"); ok { - helper.Set(up.Int, legacyDBMinSize, "appservice", "database", "max_idle_conns") - } - if legacyDBMaxSize, ok := helper.Get(up.Int, "appservice", "database_opts", "max_size"); ok { - helper.Set(up.Int, legacyDBMaxSize, "appservice", "database", "max_open_conns") - } - if legacyBotUsername, ok := helper.Get(up.Str, "appservice", "bot_username"); ok { - helper.Set(up.Str, legacyBotUsername, "appservice", "bot", "username") - } - if legacyBotDisplayname, ok := helper.Get(up.Str, "appservice", "bot_displayname"); ok { - helper.Set(up.Str, legacyBotDisplayname, "appservice", "bot", "displayname") - } - if legacyBotAvatar, ok := helper.Get(up.Str, "appservice", "bot_avatar"); ok { - helper.Set(up.Str, legacyBotAvatar, "appservice", "bot", "avatar") - } - - helper.Copy(up.Str, "meta", "mode") - helper.Copy(up.Bool, "meta", "ig_e2ee") - helper.Copy(up.Str|up.Null, "meta", "proxy") - helper.Copy(up.Str|up.Null, "meta", "get_proxy_from") - helper.Copy(up.Int, "meta", "min_full_reconnect_interval_seconds") - helper.Copy(up.Int, "meta", "force_refresh_interval_seconds") - - if usernameTemplate, ok := helper.Get(up.Str, "bridge", "username_template"); ok && strings.Contains(usernameTemplate, "{userid}") { - helper.Set(up.Str, strings.ReplaceAll(usernameTemplate, "{userid}", "{{.}}"), "bridge", "username_template") - } else { - helper.Copy(up.Str, "bridge", "username_template") - } - if displaynameTemplate, ok := helper.Get(up.Str, "bridge", "displayname_template"); ok && strings.Contains(displaynameTemplate, "{displayname}") { - helper.Set(up.Str, strings.ReplaceAll(displaynameTemplate, "{displayname}", `{{or .DisplayName "Unknown user"}}`), "bridge", "displayname_template") - } else { - helper.Copy(up.Str, "bridge", "displayname_template") - } - helper.Copy(up.Str, "bridge", "private_chat_portal_meta") - helper.Copy(up.Int, "bridge", "portal_message_buffer") - helper.Copy(up.Bool, "bridge", "personal_filtering_spaces") - helper.Copy(up.Bool, "bridge", "bridge_notices") - helper.Copy(up.Bool, "bridge", "delivery_receipts") - helper.Copy(up.Bool, "bridge", "message_status_events") - helper.Copy(up.Bool, "bridge", "message_error_notices") - helper.Copy(up.Bool, "bridge", "disable_bridge_alerts") - helper.Copy(up.Bool, "bridge", "sync_direct_chat_list") - helper.Copy(up.Bool, "bridge", "resend_bridge_info") - helper.Copy(up.Bool, "bridge", "caption_in_message") - muteBridgingVal, _ := helper.Get(up.Str, "bridge", "mute_bridging") - switch muteBridgingVal { - case "always", "on-create", "never": - helper.Copy(up.Str, "bridge", "mute_bridging") - default: - // Don't copy invalid values - } - helper.Copy(up.Bool, "bridge", "federate_rooms") - helper.Copy(up.Map, "bridge", "double_puppet_server_map") - helper.Copy(up.Bool, "bridge", "double_puppet_allow_discovery") - helper.Copy(up.Map, "bridge", "login_shared_secret_map") - helper.Copy(up.Str, "bridge", "command_prefix") - helper.Copy(up.Bool, "bridge", "backfill", "enabled") - helper.Copy(up.Int, "bridge", "backfill", "inbox_fetch_pages") - helper.Copy(up.Int, "bridge", "backfill", "history_fetch_pages") - helper.Copy(up.Int, "bridge", "backfill", "catchup_fetch_pages") - helper.Copy(up.Int, "bridge", "backfill", "unread_hours_threshold") - helper.Copy(up.Int, "bridge", "backfill", "queue", "pages_at_once") - helper.Copy(up.Int, "bridge", "backfill", "queue", "max_pages") - helper.Copy(up.Str, "bridge", "backfill", "queue", "sleep_between_tasks") - helper.Copy(up.Bool, "bridge", "backfill", "queue", "dont_fetch_xma") - helper.Copy(up.Bool, "bridge", "disable_xma") - helper.Copy(up.Str, "bridge", "management_room_text", "welcome") - helper.Copy(up.Str, "bridge", "management_room_text", "welcome_connected") - helper.Copy(up.Str, "bridge", "management_room_text", "welcome_unconnected") - helper.Copy(up.Str|up.Null, "bridge", "management_room_text", "additional_help") - helper.Copy(up.Bool, "bridge", "encryption", "allow") - helper.Copy(up.Bool, "bridge", "encryption", "default") - helper.Copy(up.Bool, "bridge", "encryption", "require") - helper.Copy(up.Bool, "bridge", "encryption", "appservice") - helper.Copy(up.Bool, "bridge", "encryption", "allow_key_sharing") - helper.Copy(up.Bool, "bridge", "encryption", "delete_keys", "delete_outbound_on_ack") - helper.Copy(up.Bool, "bridge", "encryption", "delete_keys", "dont_store_outbound") - helper.Copy(up.Bool, "bridge", "encryption", "delete_keys", "ratchet_on_decrypt") - helper.Copy(up.Bool, "bridge", "encryption", "delete_keys", "delete_fully_used_on_decrypt") - helper.Copy(up.Bool, "bridge", "encryption", "delete_keys", "delete_prev_on_new_session") - helper.Copy(up.Bool, "bridge", "encryption", "delete_keys", "delete_on_device_delete") - helper.Copy(up.Bool, "bridge", "encryption", "delete_keys", "periodically_delete_expired") - helper.Copy(up.Bool, "bridge", "encryption", "delete_keys", "delete_outdated_inbound") - helper.Copy(up.Str, "bridge", "encryption", "verification_levels", "receive") - helper.Copy(up.Str, "bridge", "encryption", "verification_levels", "send") - helper.Copy(up.Str, "bridge", "encryption", "verification_levels", "share") - helper.Copy(up.Bool, "bridge", "encryption", "rotation", "enable_custom") - helper.Copy(up.Int, "bridge", "encryption", "rotation", "milliseconds") - helper.Copy(up.Int, "bridge", "encryption", "rotation", "messages") - helper.Copy(up.Bool, "bridge", "encryption", "rotation", "disable_device_change_key_rotation") - - helper.Copy(up.Str, "bridge", "provisioning", "prefix") - if secret, ok := helper.Get(up.Str, "bridge", "provisioning", "shared_secret"); !ok || secret == "generate" { - sharedSecret := random.String(64) - helper.Set(up.Str, sharedSecret, "bridge", "provisioning", "shared_secret") - } else { - helper.Copy(up.Str, "bridge", "provisioning", "shared_secret") - } - helper.Copy(up.Bool, "bridge", "provisioning", "debug_endpoints") - - helper.Copy(up.Map, "bridge", "permissions") - helper.Copy(up.Bool, "bridge", "relay", "enabled") - helper.Copy(up.Bool, "bridge", "relay", "admin_only") - if textRelayFormat, ok := helper.Get(up.Str, "bridge", "relay", "message_formats", "m.text"); ok && strings.Contains(textRelayFormat, "$message") && !strings.Contains(textRelayFormat, ".Message") { - // don't copy legacy message formats - } else { - helper.Copy(up.Map, "bridge", "relay", "message_formats") - } -} - -var SpacedBlocks = [][]string{ - {"homeserver", "software"}, - {"appservice"}, - {"appservice", "hostname"}, - {"appservice", "database"}, - {"appservice", "id"}, - {"appservice", "ephemeral_events"}, - {"appservice", "as_token"}, - {"meta"}, - {"bridge"}, - {"bridge", "personal_filtering_spaces"}, - {"bridge", "command_prefix"}, - {"bridge", "backfill"}, - {"bridge", "management_room_text"}, - {"bridge", "encryption"}, - {"bridge", "provisioning"}, - {"bridge", "permissions"}, - {"bridge", "relay"}, - {"logging"}, -} diff --git a/custompuppet.go b/custompuppet.go deleted file mode 100644 index edd8d03..0000000 --- a/custompuppet.go +++ /dev/null @@ -1,97 +0,0 @@ -// mautrix-meta - A Matrix-Facebook Messenger and Instagram DM puppeting bridge. -// Copyright (C) 2024 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package main - -import ( - "context" - "fmt" - - "maunium.net/go/mautrix/id" -) - -func (puppet *Puppet) SwitchCustomMXID(accessToken string, mxid id.UserID) error { - puppet.CustomMXID = mxid - puppet.AccessToken = accessToken - err := puppet.Update(context.TODO()) - if err != nil { - return fmt.Errorf("failed to save access token: %w", err) - } - err = puppet.StartCustomMXID(false) - if err != nil { - return err - } - // TODO leave rooms with default puppet - return nil -} - -func (puppet *Puppet) ClearCustomMXID() { - save := puppet.CustomMXID != "" || puppet.AccessToken != "" - puppet.bridge.puppetsLock.Lock() - if puppet.CustomMXID != "" && puppet.bridge.puppetsByCustomMXID[puppet.CustomMXID] == puppet { - delete(puppet.bridge.puppetsByCustomMXID, puppet.CustomMXID) - } - puppet.bridge.puppetsLock.Unlock() - puppet.CustomMXID = "" - puppet.AccessToken = "" - puppet.customIntent = nil - puppet.customUser = nil - if save { - err := puppet.Update(context.TODO()) - if err != nil { - puppet.log.Err(err).Msg("Failed to clear custom MXID") - } - } -} - -func (puppet *Puppet) StartCustomMXID(reloginOnFail bool) error { - newIntent, newAccessToken, err := puppet.bridge.DoublePuppet.Setup(context.TODO(), puppet.CustomMXID, puppet.AccessToken, reloginOnFail) - if err != nil { - puppet.ClearCustomMXID() - return err - } - puppet.bridge.puppetsLock.Lock() - puppet.bridge.puppetsByCustomMXID[puppet.CustomMXID] = puppet - puppet.bridge.puppetsLock.Unlock() - if puppet.AccessToken != newAccessToken { - puppet.AccessToken = newAccessToken - err = puppet.Update(context.TODO()) - } - puppet.customIntent = newIntent - puppet.customUser = puppet.bridge.GetUserByMXID(puppet.CustomMXID) - return err -} - -func (user *User) tryAutomaticDoublePuppeting() { - if !user.bridge.Config.CanAutoDoublePuppet(user.MXID) { - return - } - user.log.Debug().Msg("Checking if double puppeting needs to be enabled") - puppet := user.bridge.GetPuppetByID(user.MetaID) - if len(puppet.CustomMXID) > 0 { - user.log.Debug().Msg("User already has double-puppeting enabled") - // Custom puppet already enabled - return - } - puppet.CustomMXID = user.MXID - err := puppet.StartCustomMXID(true) - if err != nil { - user.log.Warn().Err(err).Msg("Failed to login with shared secret") - } else { - // TODO leave rooms with default puppet - user.log.Debug().Msg("Successfully automatically enabled custom puppet") - } -} diff --git a/database/backfilltask.go b/database/backfilltask.go deleted file mode 100644 index f13579f..0000000 --- a/database/backfilltask.go +++ /dev/null @@ -1,134 +0,0 @@ -// mautrix-meta - A Matrix-Facebook Messenger and Instagram DM puppeting bridge. -// Copyright (C) 2024 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package database - -import ( - "context" - "time" - - "go.mau.fi/util/dbutil" - "maunium.net/go/mautrix/id" -) - -const ( - putBackfillTask = ` - INSERT INTO backfill_task ( - portal_id, portal_receiver, user_mxid, priority, page_count, finished, - dispatched_at, completed_at, cooldown_until - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) - ON CONFLICT (portal_id, portal_receiver, user_mxid) DO UPDATE - SET priority=excluded.priority, page_count=excluded.page_count, finished=excluded.finished, - dispatched_at=excluded.dispatched_at, completed_at=excluded.completed_at, cooldown_until=excluded.cooldown_until - ` - insertBackfillTaskIfNotExists = ` - INSERT INTO backfill_task ( - portal_id, portal_receiver, user_mxid, priority, page_count, finished, - dispatched_at, completed_at, cooldown_until - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) - ON CONFLICT (portal_id, portal_receiver, user_mxid) DO NOTHING - ` - getNextBackfillTask = ` - SELECT portal_id, portal_receiver, user_mxid, priority, page_count, finished, dispatched_at, completed_at, cooldown_until - FROM backfill_task - WHERE user_mxid=$1 AND finished=false AND cooldown_until<$2 AND (dispatched_at<$3 OR completed_at<>0) - ORDER BY priority DESC, completed_at, dispatched_at LIMIT 1 - ` -) - -type BackfillTaskQuery struct { - *dbutil.QueryHelper[*BackfillTask] -} - -type BackfillTask struct { - qh *dbutil.QueryHelper[*BackfillTask] - - Key PortalKey - UserMXID id.UserID - Priority int - PageCount int - Finished bool - DispatchedAt time.Time - CompletedAt time.Time - CooldownUntil time.Time -} - -func newBackfillTask(qh *dbutil.QueryHelper[*BackfillTask]) *BackfillTask { - return &BackfillTask{qh: qh} -} - -func (btq *BackfillTaskQuery) NewWithValues(portalKey PortalKey, userID id.UserID) *BackfillTask { - return &BackfillTask{ - qh: btq.QueryHelper, - - Key: portalKey, - UserMXID: userID, - DispatchedAt: time.Now(), - CompletedAt: time.Now(), - } -} - -func (btq *BackfillTaskQuery) GetNext(ctx context.Context, userID id.UserID) (*BackfillTask, error) { - return btq.QueryOne(ctx, getNextBackfillTask, userID, time.Now().UnixMilli(), time.Now().Add(-1*time.Hour).UnixMilli()) -} - -func (task *BackfillTask) Scan(row dbutil.Scannable) (*BackfillTask, error) { - var dispatchedAt, completedAt, cooldownUntil int64 - err := row.Scan(&task.Key.ThreadID, &task.Key.Receiver, &task.UserMXID, &task.Priority, &task.PageCount, &task.Finished, &dispatchedAt, &completedAt, &cooldownUntil) - if err != nil { - return nil, err - } - task.DispatchedAt = timeFromUnixMilli(dispatchedAt) - task.CompletedAt = timeFromUnixMilli(completedAt) - task.CooldownUntil = timeFromUnixMilli(cooldownUntil) - return task, nil -} - -func timeFromUnixMilli(unix int64) time.Time { - if unix == 0 { - return time.Time{} - } - return time.UnixMilli(unix) -} - -func unixMilliOrZero(time time.Time) int64 { - if time.IsZero() { - return 0 - } - return time.UnixMilli() -} - -func (task *BackfillTask) sqlVariables() []any { - return []any{ - task.Key.ThreadID, - task.Key.Receiver, - task.UserMXID, - task.Priority, - task.PageCount, - task.Finished, - unixMilliOrZero(task.DispatchedAt), - unixMilliOrZero(task.CompletedAt), - unixMilliOrZero(task.CooldownUntil), - } -} - -func (task *BackfillTask) Upsert(ctx context.Context) error { - return task.qh.Exec(ctx, putBackfillTask, task.sqlVariables()...) -} - -func (task *BackfillTask) InsertIfNotExists(ctx context.Context) error { - return task.qh.Exec(ctx, insertBackfillTaskIfNotExists, task.sqlVariables()...) -} diff --git a/database/database.go b/database/database.go deleted file mode 100644 index fbffafc..0000000 --- a/database/database.go +++ /dev/null @@ -1,51 +0,0 @@ -// mautrix-meta - A Matrix-Facebook Messenger and Instagram DM puppeting bridge. -// Copyright (C) 2024 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package database - -import ( - _ "embed" - - _ "github.com/lib/pq" - _ "github.com/mattn/go-sqlite3" - "go.mau.fi/util/dbutil" - - "go.mau.fi/mautrix-meta/database/upgrades" -) - -type Database struct { - *dbutil.Database - - User *UserQuery - Portal *PortalQuery - Puppet *PuppetQuery - Message *MessageQuery - Reaction *ReactionQuery - BackfillTask *BackfillTaskQuery -} - -func New(db *dbutil.Database) *Database { - db.UpgradeTable = upgrades.Table - return &Database{ - Database: db, - User: &UserQuery{dbutil.MakeQueryHelper(db, newUser)}, - Portal: &PortalQuery{dbutil.MakeQueryHelper(db, newPortal)}, - Puppet: &PuppetQuery{dbutil.MakeQueryHelper(db, newPuppet)}, - Message: &MessageQuery{dbutil.MakeQueryHelper(db, newMessage)}, - Reaction: &ReactionQuery{dbutil.MakeQueryHelper(db, newReaction)}, - BackfillTask: &BackfillTaskQuery{dbutil.MakeQueryHelper(db, newBackfillTask)}, - } -} diff --git a/database/legacymigrate/migrate.go b/database/legacymigrate/migrate.go deleted file mode 100644 index e529874..0000000 --- a/database/legacymigrate/migrate.go +++ /dev/null @@ -1,233 +0,0 @@ -// mautrix-meta - A Matrix-Facebook Messenger and Instagram DM puppeting bridge. -// Copyright (C) 2024 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package legacymigrate - -import ( - "context" - "database/sql" - "fmt" - "time" - - "github.com/rs/zerolog" - "go.mau.fi/util/dbutil" - "go.mau.fi/util/exerrors" - "maunium.net/go/mautrix/id" - - "go.mau.fi/mautrix-meta/database" - "go.mau.fi/mautrix-meta/messagix/table" -) - -type Insertable interface { - Insert(context.Context) error -} - -type ToNewable[T Insertable] interface { - ToNew(*database.Database) T -} - -type LegacyUser struct { - MXID id.UserID - NoticeRoom id.RoomID -} - -func (lu *LegacyUser) ToNew(db *database.Database) *database.User { - dbUser := db.User.New() - dbUser.MXID = lu.MXID - dbUser.ManagementRoom = lu.NoticeRoom - return dbUser -} - -type LegacyPortal struct { - FBID int64 - FBReceiver int64 - FBType string - MXID sql.NullString - Name sql.NullString - PhotoID sql.NullString - AvatarURL sql.NullString - Encrypted bool - NameSet bool - AvatarSet bool -} - -func (lp *LegacyPortal) ToNew(db *database.Database) *database.Portal { - dbPortal := db.Portal.New() - dbPortal.PortalKey.ThreadID = lp.FBID - dbPortal.PortalKey.Receiver = lp.FBReceiver - switch lp.FBType { - case "USER": - dbPortal.ThreadType = table.ONE_TO_ONE - case "GROUP": - dbPortal.ThreadType = table.GROUP_THREAD - default: - dbPortal.ThreadType = table.UNKNOWN_THREAD_TYPE - } - dbPortal.MXID = id.RoomID(lp.MXID.String) - dbPortal.Name = lp.Name.String - dbPortal.AvatarID = lp.PhotoID.String - dbPortal.AvatarURL, _ = id.ParseContentURI(lp.AvatarURL.String) - dbPortal.Encrypted = lp.Encrypted - dbPortal.NameSet = lp.NameSet - dbPortal.AvatarSet = lp.AvatarSet - return dbPortal -} - -type LegacyPuppet struct { - FBID int64 - Name sql.NullString - PhotoID sql.NullString - PhotoMXC sql.NullString - NameSet bool - AvatarSet bool -} - -func (lp *LegacyPuppet) ToNew(db *database.Database) *database.Puppet { - dbPuppet := db.Puppet.New() - dbPuppet.ID = lp.FBID - dbPuppet.Name = lp.Name.String - dbPuppet.AvatarID = lp.PhotoID.String - dbPuppet.AvatarURL, _ = id.ParseContentURI(lp.PhotoMXC.String) - dbPuppet.NameSet = lp.NameSet - dbPuppet.AvatarSet = lp.AvatarSet - return dbPuppet -} - -type LegacyMessage struct { - FBID string - FBTxnID sql.NullInt64 - Index int - FBChat int64 - FBReceiver int64 - FBSender int64 - Timestamp int64 - MXID id.EventID - MXRoom id.RoomID -} - -func (lm *LegacyMessage) ToNew(db *database.Database) *database.Message { - dbMessage := db.Message.New() - dbMessage.ID = lm.FBID - dbMessage.OTID = lm.FBTxnID.Int64 - dbMessage.PartIndex = lm.Index - dbMessage.ThreadID = lm.FBChat - dbMessage.ThreadReceiver = lm.FBReceiver - dbMessage.Sender = lm.FBSender - dbMessage.Timestamp = time.UnixMilli(lm.Timestamp) - dbMessage.MXID = lm.MXID - dbMessage.RoomID = lm.MXRoom - return dbMessage -} - -type LegacyReaction struct { - FBMsgID string - FBThreadID int64 - FBReceiver int64 - FBSender int64 - Reaction string - MXID id.EventID - MXRoom id.RoomID -} - -func (lr *LegacyReaction) ToNew(db *database.Database) *database.Reaction { - dbReaction := db.Reaction.New() - dbReaction.MessageID = lr.FBMsgID - dbReaction.ThreadID = lr.FBThreadID - dbReaction.ThreadReceiver = lr.FBReceiver - dbReaction.Sender = lr.FBSender - dbReaction.Emoji = lr.Reaction - dbReaction.MXID = lr.MXID - dbReaction.RoomID = lr.MXRoom - return dbReaction -} - -type reinserter[T ToNewable[I], I Insertable] struct { - db *database.Database - ctx context.Context -} - -func (r reinserter[T, I]) do(m T) (bool, error) { - return true, m.ToNew(r.db).Insert(r.ctx) -} - -func Migrate(ctx context.Context, targetDB *database.Database, sourceDialect, sourceURI string) { - log := zerolog.Ctx(ctx) - sourceDB := exerrors.Must(dbutil.NewWithDialect(sourceURI, sourceDialect)) - sourceDB.Log = dbutil.ZeroLoggerPtr(log) - - oldDBOwner := exerrors.Must(dbutil.ScanSingleColumn[string](sourceDB.QueryRow(ctx, "SELECT owner FROM database_owner"))) - if oldDBOwner != "mautrix-facebook" { - panic(fmt.Errorf("source database is %s, not mautrix-facebook", oldDBOwner)) - } - oldDBVersion := exerrors.Must(dbutil.ScanSingleColumn[int](sourceDB.QueryRow(ctx, "SELECT version FROM version"))) - if oldDBVersion != 12 { - panic(fmt.Errorf("source database is not on latest version (got %d, expected 12)", oldDBVersion)) - } - log.Debug(). - Str("owner", oldDBOwner). - Int("version", oldDBVersion). - Msg("Source database version confirmed") - - log.Info().Msg("Upgrading target database") - exerrors.PanicIfNotNil(targetDB.Upgrade(ctx)) - - log.Info().Msg("Migrating data") - origCtx := ctx - exerrors.PanicIfNotNil(targetDB.DoTxn(ctx, nil, func(ctx context.Context) error { - err := dbutil.NewSimpleReflectRowIter[LegacyUser](sourceDB.Query(origCtx, ` - SELECT mxid, notice_room FROM "user" WHERE notice_room<>'' - `)).Iter(reinserter[*LegacyUser, *database.User]{targetDB, ctx}.do) - if err != nil { - log.Error().Msg("Failed to copy users") - return err - } - err = dbutil.NewSimpleReflectRowIter[LegacyPortal](sourceDB.Query(origCtx, ` - SELECT fbid, fb_receiver, fb_type, mxid, name, photo_id, avatar_url, encrypted, name_set, avatar_set FROM portal - `)).Iter(reinserter[*LegacyPortal, *database.Portal]{targetDB, ctx}.do) - if err != nil { - log.Error().Msg("Failed to copy portals") - return err - } - err = dbutil.NewSimpleReflectRowIter[LegacyPuppet](sourceDB.Query(origCtx, ` - SELECT fbid, name, photo_id, photo_mxc, name_set, avatar_set FROM puppet - `)).Iter(reinserter[*LegacyPuppet, *database.Puppet]{targetDB, ctx}.do) - if err != nil { - log.Error().Msg("Failed to copy puppets") - return err - } - err = dbutil.NewSimpleReflectRowIter[LegacyMessage](sourceDB.Query(origCtx, ` - SELECT fbid, fb_txn_id, "index", fb_chat, fb_receiver, fb_sender, timestamp, mxid, mx_room - FROM message WHERE fbid<>'' AND fb_sender<>0 - `)).Iter(reinserter[*LegacyMessage, *database.Message]{targetDB, ctx}.do) - if err != nil { - log.Error().Msg("Failed to copy messages") - return err - } - err = dbutil.NewSimpleReflectRowIter[LegacyReaction](sourceDB.Query(origCtx, ` - SELECT reaction.fb_msgid, message.fb_chat, reaction.fb_receiver, - reaction.fb_sender, reaction.reaction, reaction.mxid, reaction.mx_room - FROM reaction - JOIN message ON reaction.fb_msgid=message.fbid AND message."index"=0 - WHERE reaction.fb_sender<>0 AND message.fb_chat<>0 AND reaction.fb_msgid='mid.$gABC3ypFJHACT3lHXb2NrQyNVgAAq' - `)).Iter(reinserter[*LegacyReaction, *database.Reaction]{targetDB, ctx}.do) - if err != nil { - log.Error().Msg("Failed to copy reactions") - return err - } - return nil - })) - log.Info().Msg("Migration complete") -} diff --git a/database/message.go b/database/message.go deleted file mode 100644 index 49d0334..0000000 --- a/database/message.go +++ /dev/null @@ -1,234 +0,0 @@ -// mautrix-meta - A Matrix-Facebook Messenger and Instagram DM puppeting bridge. -// Copyright (C) 2024 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package database - -import ( - "context" - "database/sql" - "errors" - "fmt" - "strings" - "time" - - "go.mau.fi/util/dbutil" - "maunium.net/go/mautrix/id" -) - -const ( - getMessageByMXIDQuery = ` - SELECT id, part_index, thread_id, thread_receiver, msg_sender, otid, mxid, mx_room, timestamp, edit_count FROM message - WHERE mxid=$1 - ` - getMessagePartByIDQuery = ` - SELECT id, part_index, thread_id, thread_receiver, msg_sender, otid, mxid, mx_room, timestamp, edit_count FROM message - WHERE id=$1 AND part_index=$2 AND thread_receiver=$3 - ` - getLastMessagePartByIDQuery = ` - SELECT id, part_index, thread_id, thread_receiver, msg_sender, otid, mxid, mx_room, timestamp, edit_count FROM message - WHERE id=$1 AND thread_receiver=$2 - ORDER BY part_index DESC LIMIT 1 - ` - getLastPartByTimestampQuery = ` - SELECT id, part_index, thread_id, thread_receiver, msg_sender, otid, mxid, mx_room, timestamp, edit_count FROM message - WHERE thread_id=$1 AND thread_receiver=$2 AND timestamp<=$3 - ORDER BY timestamp DESC, part_index DESC LIMIT 1 - ` - getAllMessagePartsByIDQuery = ` - SELECT id, part_index, thread_id, thread_receiver, msg_sender, otid, mxid, mx_room, timestamp, edit_count FROM message - WHERE id=$1 AND thread_receiver=$2 - ` - getMessagesBetweenTimeQuery = ` - SELECT id, part_index, thread_id, thread_receiver, msg_sender, otid, mxid, mx_room, timestamp, edit_count FROM message - WHERE thread_id=$1 AND thread_receiver=$2 AND timestamp>$3 AND timestamp<=$4 AND part_index=0 - ORDER BY timestamp ASC - ` - findEditTargetPortalFromMessageQuery = ` - SELECT thread_id, thread_receiver FROM message - WHERE id=$1 AND (thread_receiver=$2 OR thread_receiver=0) AND part_index=0 - ` - insertMessageQuery = ` - INSERT INTO message (id, part_index, thread_id, thread_receiver, msg_sender, otid, mxid, mx_room, timestamp, edit_count) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) - ` - insertQueryValuePlaceholder = `($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)` - bulkInsertPlaceholderTemplate = `($%d, $%d, $1, $2, $%d, $%d, $%d, $3, $%d, $%d)` - deleteMessageQuery = ` - DELETE FROM message - WHERE id=$1 AND thread_receiver=$2 AND part_index=$3 - ` - updateMessageEditCountQuery = ` - UPDATE message SET edit_count=$4 WHERE id=$1 AND thread_receiver=$2 AND part_index=$3 - ` -) - -func init() { - if strings.ReplaceAll(insertMessageQuery, insertQueryValuePlaceholder, "meow") == insertMessageQuery { - panic("Bulk insert query placeholder not found") - } -} - -type MessageQuery struct { - *dbutil.QueryHelper[*Message] -} - -type Message struct { - qh *dbutil.QueryHelper[*Message] - - ID string - PartIndex int - ThreadID int64 - ThreadReceiver int64 - Sender int64 - OTID int64 - - MXID id.EventID - RoomID id.RoomID - - Timestamp time.Time - EditCount int64 -} - -func newMessage(qh *dbutil.QueryHelper[*Message]) *Message { - return &Message{qh: qh} -} - -func (mq *MessageQuery) GetByMXID(ctx context.Context, mxid id.EventID) (*Message, error) { - return mq.QueryOne(ctx, getMessageByMXIDQuery, mxid) -} - -func (mq *MessageQuery) GetByID(ctx context.Context, id string, partIndex int, receiver int64) (*Message, error) { - return mq.QueryOne(ctx, getMessagePartByIDQuery, id, partIndex, receiver) -} - -func (mq *MessageQuery) GetLastByTimestamp(ctx context.Context, key PortalKey, timestamp time.Time) (*Message, error) { - return mq.QueryOne(ctx, getLastPartByTimestampQuery, key.ThreadID, key.Receiver, timestamp.UnixMilli()) -} - -func (mq *MessageQuery) GetLastPartByID(ctx context.Context, id string, receiver int64) (*Message, error) { - return mq.QueryOne(ctx, getLastMessagePartByIDQuery, id, receiver) -} - -func (mq *MessageQuery) GetAllPartsByID(ctx context.Context, id string, receiver int64) ([]*Message, error) { - return mq.QueryMany(ctx, getAllMessagePartsByIDQuery, id, receiver) -} - -func (mq *MessageQuery) GetAllBetweenTimestamps(ctx context.Context, key PortalKey, min, max time.Time) ([]*Message, error) { - return mq.QueryMany(ctx, getMessagesBetweenTimeQuery, key.ThreadID, key.Receiver, min.UnixMilli(), max.UnixMilli()) -} - -func (mq *MessageQuery) FindEditTargetPortal(ctx context.Context, id string, receiver int64) (key PortalKey, err error) { - err = mq.GetDB().QueryRow(ctx, findEditTargetPortalFromMessageQuery, id, receiver).Scan(&key.ThreadID, &key.Receiver) - if errors.Is(err, sql.ErrNoRows) { - err = nil - } - return -} - -type bulkInserter[T any] interface { - GetDB() *dbutil.Database - BulkInsertChunk(context.Context, PortalKey, id.RoomID, []T) error -} - -const BulkInsertChunkSize = 100 - -func doBulkInsert[T any](q bulkInserter[T], ctx context.Context, thread PortalKey, roomID id.RoomID, entries []T) error { - if len(entries) == 0 { - return nil - } - return q.GetDB().DoTxn(ctx, nil, func(ctx context.Context) error { - for i := 0; i < len(entries); i += BulkInsertChunkSize { - messageChunk := entries[i:] - if len(messageChunk) > BulkInsertChunkSize { - messageChunk = messageChunk[:BulkInsertChunkSize] - } - err := q.BulkInsertChunk(ctx, thread, roomID, messageChunk) - if err != nil { - return err - } - } - return nil - }) -} - -func (mq *MessageQuery) BulkInsert(ctx context.Context, thread PortalKey, roomID id.RoomID, messages []*Message) error { - return doBulkInsert[*Message](mq, ctx, thread, roomID, messages) -} - -func (mq *MessageQuery) BulkInsertChunk(ctx context.Context, thread PortalKey, roomID id.RoomID, messages []*Message) error { - if len(messages) == 0 { - return nil - } - placeholders := make([]string, len(messages)) - values := make([]any, 3+len(messages)*7) - values[0] = thread.ThreadID - values[1] = thread.Receiver - values[2] = roomID - for i, msg := range messages { - baseIndex := 3 + i*7 - placeholders[i] = fmt.Sprintf(bulkInsertPlaceholderTemplate, baseIndex+1, baseIndex+2, baseIndex+3, baseIndex+4, baseIndex+5, baseIndex+6, baseIndex+7) - values[baseIndex] = msg.ID - values[baseIndex+1] = msg.PartIndex - values[baseIndex+2] = msg.Sender - values[baseIndex+3] = msg.OTID - values[baseIndex+4] = msg.MXID - values[baseIndex+5] = msg.Timestamp.UnixMilli() - values[baseIndex+6] = msg.EditCount - } - query := strings.ReplaceAll(insertMessageQuery, insertQueryValuePlaceholder, strings.Join(placeholders, ",")) - return mq.Exec(ctx, query, values...) -} - -func (msg *Message) Scan(row dbutil.Scannable) (*Message, error) { - var timestamp int64 - err := row.Scan( - &msg.ID, &msg.PartIndex, &msg.ThreadID, &msg.ThreadReceiver, &msg.Sender, &msg.OTID, &msg.MXID, &msg.RoomID, ×tamp, &msg.EditCount, - ) - if err != nil { - return nil, err - } - msg.Timestamp = time.UnixMilli(timestamp) - return msg, nil -} - -func (msg *Message) sqlVariables() []any { - return []any{msg.ID, msg.PartIndex, msg.ThreadID, msg.ThreadReceiver, msg.Sender, msg.OTID, msg.MXID, msg.RoomID, msg.Timestamp.UnixMilli(), msg.EditCount} -} - -func (msg *Message) Insert(ctx context.Context) error { - return msg.qh.Exec(ctx, insertMessageQuery, msg.sqlVariables()...) -} - -func (msg *Message) Delete(ctx context.Context) error { - return msg.qh.Exec(ctx, deleteMessageQuery, msg.ID, msg.ThreadReceiver, msg.PartIndex) -} - -func (msg *Message) UpdateEditCount(ctx context.Context, count int64) error { - msg.EditCount = count - return msg.qh.Exec(ctx, updateMessageEditCountQuery, msg.ID, msg.ThreadReceiver, msg.PartIndex, msg.EditCount) -} - -func (msg *Message) EditTimestamp() int64 { - return msg.EditCount -} - -func (msg *Message) UpdateEditTimestamp(ctx context.Context, ts int64) error { - return msg.UpdateEditCount(ctx, ts) -} - -func (msg *Message) IsUnencrypted() bool { - return strings.HasPrefix(msg.ID, "mid.$") -} diff --git a/database/portal.go b/database/portal.go deleted file mode 100644 index 110cd1a..0000000 --- a/database/portal.go +++ /dev/null @@ -1,212 +0,0 @@ -// mautrix-meta - A Matrix-Facebook Messenger and Instagram DM puppeting bridge. -// Copyright (C) 2024 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package database - -import ( - "context" - "database/sql" - "strconv" - - "go.mau.fi/util/dbutil" - "go.mau.fi/whatsmeow/types" - "maunium.net/go/mautrix/id" - - "go.mau.fi/mautrix-meta/messagix/table" -) - -const ( - portalBaseSelect = ` - SELECT thread_id, receiver, thread_type, mxid, - name, avatar_id, avatar_url, name_set, avatar_set, - whatsapp_server, encrypted, relay_user_id, - oldest_message_id, oldest_message_ts, more_to_backfill - FROM portal - ` - getPortalByMXIDQuery = portalBaseSelect + `WHERE mxid=$1` - getPortalByThreadIDQuery = portalBaseSelect + `WHERE thread_id=$1 AND (receiver=$2 OR receiver=0)` - getPortalsByReceiver = portalBaseSelect + `WHERE receiver=$1` - getPortalsByOtherUser = portalBaseSelect + `WHERE thread_id=$1 AND thread_type IN (1, 7, 10, 13, 15)` - getAllPortalsWithMXIDQuery = portalBaseSelect + `WHERE mxid IS NOT NULL` - getChatsNotInSpaceQuery = ` - SELECT thread_id FROM portal - LEFT JOIN user_portal ON portal.thread_id=user_portal.portal_thread_id AND portal.receiver=user_portal.portal_receiver - WHERE mxid<>'' AND receiver=$1 AND (user_portal.in_space=false OR user_portal.in_space IS NULL) - ` - insertPortalQuery = ` - INSERT INTO portal ( - thread_id, receiver, thread_type, mxid, - name, avatar_id, avatar_url, name_set, avatar_set, - whatsapp_server, encrypted, relay_user_id, - oldest_message_id, oldest_message_ts, more_to_backfill - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) - ` - updatePortalQuery = ` - UPDATE portal SET - thread_type=$3, mxid=$4, - name=$5, avatar_id=$6, avatar_url=$7, name_set=$8, avatar_set=$9, - whatsapp_server=$10, encrypted=$11, relay_user_id=$12, - oldest_message_id=$13, oldest_message_ts=$14, more_to_backfill=$15 - WHERE thread_id=$1 AND receiver=$2 - ` - deletePortalQuery = `DELETE FROM portal WHERE thread_id=$1 AND receiver=$2` -) - -type PortalQuery struct { - *dbutil.QueryHelper[*Portal] -} - -type PortalKey struct { - ThreadID int64 - Receiver int64 -} - -type Portal struct { - qh *dbutil.QueryHelper[*Portal] - - PortalKey - ThreadType table.ThreadType - MXID id.RoomID - Name string - AvatarID string - AvatarURL id.ContentURI - NameSet bool - AvatarSet bool - - WhatsAppServer string - Encrypted bool - RelayUserID id.UserID - - OldestMessageID string - OldestMessageTS int64 - MoreToBackfill bool -} - -func newPortal(qh *dbutil.QueryHelper[*Portal]) *Portal { - return &Portal{qh: qh} -} - -func (pq *PortalQuery) GetByMXID(ctx context.Context, mxid id.RoomID) (*Portal, error) { - return pq.QueryOne(ctx, getPortalByMXIDQuery, mxid) -} - -func (pq *PortalQuery) GetByThreadID(ctx context.Context, pk PortalKey) (*Portal, error) { - return pq.QueryOne(ctx, getPortalByThreadIDQuery, pk.ThreadID, pk.Receiver) -} - -func (pq *PortalQuery) FindPrivateChatsWith(ctx context.Context, userID int64) ([]*Portal, error) { - return pq.QueryMany(ctx, getPortalsByOtherUser, userID) -} - -func (pq *PortalQuery) FindPrivateChatsOf(ctx context.Context, receiver int64) ([]*Portal, error) { - return pq.QueryMany(ctx, getPortalsByReceiver, receiver) -} - -func (pq *PortalQuery) GetAllWithMXID(ctx context.Context) ([]*Portal, error) { - return pq.QueryMany(ctx, getAllPortalsWithMXIDQuery) -} - -func (pq *PortalQuery) FindPrivateChatsNotInSpace(ctx context.Context, receiver int64) ([]PortalKey, error) { - rows, err := pq.GetDB().Query(ctx, getChatsNotInSpaceQuery, receiver) - if err != nil { - return nil, err - } - return dbutil.NewRowIter(rows, func(rows dbutil.Scannable) (key PortalKey, err error) { - err = rows.Scan(&key.ThreadID) - key.Receiver = receiver - return - }).AsList() -} - -func (p *Portal) IsPrivateChat() bool { - return p.ThreadType.IsOneToOne() -} - -func (p *Portal) JID() types.JID { - jid := types.JID{ - User: strconv.FormatInt(p.ThreadID, 10), - Server: p.WhatsAppServer, - } - if jid.Server == "" { - switch p.ThreadType { - case table.ENCRYPTED_OVER_WA_GROUP: - jid.Server = types.GroupServer - //case table.ENCRYPTED_OVER_WA_ONE_TO_ONE: - // jid.Server = types.DefaultUserServer - default: - jid.Server = types.MessengerServer - } - } - return jid -} - -func (p *Portal) Scan(row dbutil.Scannable) (*Portal, error) { - var mxid sql.NullString - err := row.Scan( - &p.ThreadID, - &p.Receiver, - &p.ThreadType, - &mxid, - &p.Name, - &p.AvatarID, - &p.AvatarURL, - &p.NameSet, - &p.AvatarSet, - &p.WhatsAppServer, - &p.Encrypted, - &p.RelayUserID, - &p.OldestMessageID, - &p.OldestMessageTS, - &p.MoreToBackfill, - ) - if err != nil { - return nil, err - } - p.MXID = id.RoomID(mxid.String) - return p, nil -} - -func (p *Portal) sqlVariables() []any { - return []any{ - p.ThreadID, - p.Receiver, - p.ThreadType, - dbutil.StrPtr(p.MXID), - p.Name, - p.AvatarID, - &p.AvatarURL, - p.NameSet, - p.AvatarSet, - p.WhatsAppServer, - p.Encrypted, - p.RelayUserID, - p.OldestMessageID, - p.OldestMessageTS, - p.MoreToBackfill, - } -} - -func (p *Portal) Insert(ctx context.Context) error { - return p.qh.Exec(ctx, insertPortalQuery, p.sqlVariables()...) -} - -func (p *Portal) Update(ctx context.Context) error { - return p.qh.Exec(ctx, updatePortalQuery, p.sqlVariables()...) -} - -func (p *Portal) Delete(ctx context.Context) error { - return p.qh.Exec(ctx, deletePortalQuery, p.ThreadID, p.Receiver) -} diff --git a/database/puppet.go b/database/puppet.go deleted file mode 100644 index a4bdd77..0000000 --- a/database/puppet.go +++ /dev/null @@ -1,145 +0,0 @@ -// mautrix-meta - A Matrix-Facebook Messenger and Instagram DM puppeting bridge. -// Copyright (C) 2024 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package database - -import ( - "context" - "database/sql" - - "go.mau.fi/util/dbutil" - "go.mau.fi/whatsmeow/types" - "maunium.net/go/mautrix/id" -) - -const ( - puppetBaseSelect = ` - SELECT id, name, username, avatar_id, avatar_url, name_set, avatar_set, - contact_info_set, whatsapp_server, custom_mxid, access_token - FROM puppet - ` - getPuppetByMetaIDQuery = puppetBaseSelect + `WHERE id=$1` - getPuppetByCustomMXIDQuery = puppetBaseSelect + `WHERE custom_mxid=$1` - getPuppetsWithCustomMXID = puppetBaseSelect + `WHERE custom_mxid<>''` - updatePuppetQuery = ` - UPDATE puppet SET - name=$2, username=$3, avatar_id=$4, avatar_url=$5, name_set=$6, avatar_set=$7, - contact_info_set=$8, whatsapp_server=$9, custom_mxid=$10, access_token=$11 - WHERE id=$1 - ` - insertPuppetQuery = ` - INSERT INTO puppet ( - id, name, username, avatar_id, avatar_url, name_set, avatar_set, - contact_info_set, whatsapp_server, custom_mxid, access_token - ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) - ` -) - -type PuppetQuery struct { - *dbutil.QueryHelper[*Puppet] -} - -type Puppet struct { - qh *dbutil.QueryHelper[*Puppet] - - ID int64 - Name string - Username string - AvatarID string - AvatarURL id.ContentURI - NameSet bool - AvatarSet bool - - ContactInfoSet bool - WhatsAppServer string - - CustomMXID id.UserID - AccessToken string -} - -func newPuppet(qh *dbutil.QueryHelper[*Puppet]) *Puppet { - return &Puppet{qh: qh} -} - -func (pq *PuppetQuery) GetByID(ctx context.Context, id int64) (*Puppet, error) { - return pq.QueryOne(ctx, getPuppetByMetaIDQuery, id) -} - -func (pq *PuppetQuery) GetByCustomMXID(ctx context.Context, mxid id.UserID) (*Puppet, error) { - return pq.QueryOne(ctx, getPuppetByCustomMXIDQuery, mxid) -} - -func (pq *PuppetQuery) GetAllWithCustomMXID(ctx context.Context) ([]*Puppet, error) { - return pq.QueryMany(ctx, getPuppetsWithCustomMXID) -} - -func (p *Puppet) Scan(row dbutil.Scannable) (*Puppet, error) { - var customMXID sql.NullString - err := row.Scan( - &p.ID, - &p.Name, - &p.Username, - &p.AvatarID, - &p.AvatarURL, - &p.NameSet, - &p.AvatarSet, - &p.ContactInfoSet, - &p.WhatsAppServer, - &customMXID, - &p.AccessToken, - ) - if err != nil { - return nil, nil - } - p.CustomMXID = id.UserID(customMXID.String) - return p, nil -} - -func (p *Puppet) JID() types.JID { - jid := types.JID{ - User: p.Username, - Server: p.WhatsAppServer, - } - if jid.Server == "" { - jid.Server = types.MessengerServer - } - return jid -} - -func (p *Puppet) sqlVariables() []any { - return []any{ - p.ID, - p.Name, - p.Username, - p.AvatarID, - &p.AvatarURL, - p.NameSet, - p.AvatarSet, - p.ContactInfoSet, - p.WhatsAppServer, - dbutil.StrPtr(p.CustomMXID), - p.AccessToken, - } -} - -func (p *Puppet) Insert(ctx context.Context) error { - return p.qh.Exec(ctx, insertPuppetQuery, p.sqlVariables()...) -} - -func (p *Puppet) Update(ctx context.Context) error { - return p.qh.Exec(ctx, updatePuppetQuery, p.sqlVariables()...) -} diff --git a/database/reaction.go b/database/reaction.go deleted file mode 100644 index 719406b..0000000 --- a/database/reaction.go +++ /dev/null @@ -1,140 +0,0 @@ -// mautrix-meta - A Matrix-Facebook Messenger and Instagram DM puppeting bridge. -// Copyright (C) 2024 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package database - -import ( - "context" - "fmt" - "strings" - - "go.mau.fi/util/dbutil" - "maunium.net/go/mautrix/id" -) - -const ( - getReactionByMXIDQuery = ` - SELECT message_id, thread_id, thread_receiver, reaction_sender, emoji, mxid, mx_room FROM reaction WHERE mxid=$1 - ` - getReactionByIDQuery = ` - SELECT message_id, thread_id, thread_receiver, reaction_sender, emoji, mxid, mx_room - FROM reaction WHERE message_id=$1 AND thread_receiver=$2 AND reaction_sender=$3 - ` - insertReactionQuery = ` - INSERT INTO reaction (message_id, thread_id, thread_receiver, reaction_sender, emoji, mxid, mx_room) - VALUES ($1, $2, $3, $4, $5, $6, $7) - ` - bulkInsertReactionQuery = ` - INSERT INTO reaction (message_id, thread_id, thread_receiver, reaction_sender, emoji, mxid, mx_room) - VALUES ($1, $2, $3, $4, $5, $6, $7) - ON CONFLICT (message_id, thread_receiver, reaction_sender) DO UPDATE SET mxid=excluded.mxid, emoji=excluded.emoji - ` - bulkInsertReactionQueryValuePlaceholder = `($1, $2, $3, $4, $5, $6, $7)` - bulkInsertReactionPlaceholderTemplate = `($%d, $1, $2, $%d, $%d, $%d, $3)` - updateReactionQuery = ` - UPDATE reaction - SET mxid=$1, emoji=$2 - WHERE message_id=$3 AND thread_receiver=$4 AND reaction_sender=$5 - ` - deleteReactionQuery = ` - DELETE FROM reaction WHERE message_id=$1 AND thread_receiver=$2 AND reaction_sender=$3 - ` -) - -func init() { - if strings.ReplaceAll(bulkInsertReactionQuery, bulkInsertReactionQueryValuePlaceholder, "meow") == bulkInsertReactionQuery { - panic("Bulk insert query placeholder not found") - } -} - -type ReactionQuery struct { - *dbutil.QueryHelper[*Reaction] -} - -func newReaction(qh *dbutil.QueryHelper[*Reaction]) *Reaction { - return &Reaction{qh: qh} -} - -type Reaction struct { - qh *dbutil.QueryHelper[*Reaction] - - MessageID string - ThreadID int64 - ThreadReceiver int64 - Sender int64 - - Emoji string - - MXID id.EventID - RoomID id.RoomID -} - -func (rq *ReactionQuery) GetByMXID(ctx context.Context, mxid id.EventID) (*Reaction, error) { - return rq.QueryOne(ctx, getReactionByMXIDQuery, mxid) -} - -func (rq *ReactionQuery) GetByID(ctx context.Context, msgID string, threadReceiver, reactionSender int64) (*Reaction, error) { - return rq.QueryOne(ctx, getReactionByIDQuery, msgID, threadReceiver, reactionSender) -} - -func (rq *ReactionQuery) BulkInsert(ctx context.Context, thread PortalKey, roomID id.RoomID, reactions []*Reaction) error { - return doBulkInsert[*Reaction](rq, ctx, thread, roomID, reactions) -} - -func (rq *ReactionQuery) BulkInsertChunk(ctx context.Context, thread PortalKey, roomID id.RoomID, reactions []*Reaction) error { - if len(reactions) == 0 { - return nil - } - placeholders := make([]string, len(reactions)) - values := make([]any, 3+len(reactions)*4) - values[0] = thread.ThreadID - values[1] = thread.Receiver - values[2] = roomID - for i, react := range reactions { - baseIndex := 3 + i*4 - placeholders[i] = fmt.Sprintf(bulkInsertReactionPlaceholderTemplate, baseIndex+1, baseIndex+2, baseIndex+3, baseIndex+4) - values[baseIndex] = react.MessageID - values[baseIndex+1] = react.Sender - values[baseIndex+2] = react.Emoji - values[baseIndex+3] = react.MXID - } - query := strings.ReplaceAll(bulkInsertReactionQuery, bulkInsertReactionQueryValuePlaceholder, strings.Join(placeholders, ",")) - return rq.Exec(ctx, query, values...) -} - -func (r *Reaction) Scan(row dbutil.Scannable) (*Reaction, error) { - return dbutil.ValueOrErr(r, row.Scan( - &r.MessageID, &r.ThreadID, &r.ThreadReceiver, &r.Sender, &r.Emoji, &r.MXID, &r.RoomID, - )) -} - -func (r *Reaction) sqlVariables() []any { - return []any{ - r.MessageID, r.ThreadID, r.ThreadReceiver, r.Sender, r.Emoji, r.MXID, r.RoomID, - } -} - -func (r *Reaction) Insert(ctx context.Context) error { - return r.qh.Exec(ctx, insertReactionQuery, r.sqlVariables()...) -} - -func (r *Reaction) Update(ctx context.Context) error { - return r.qh.Exec(ctx, updateReactionQuery, r.MXID, r.Emoji, r.MessageID, r.ThreadReceiver, r.Sender) -} - -func (r *Reaction) Delete(ctx context.Context) error { - return r.qh.Exec(ctx, deleteReactionQuery, r.MessageID, r.ThreadReceiver, r.Sender) -} diff --git a/database/upgrades/00-latest.sql b/database/upgrades/00-latest.sql deleted file mode 100644 index 38d6616..0000000 --- a/database/upgrades/00-latest.sql +++ /dev/null @@ -1,137 +0,0 @@ --- v0 -> v6 (compatible with v3+): Latest revision - -CREATE TABLE portal ( - thread_id BIGINT NOT NULL, - receiver BIGINT NOT NULL, - thread_type INTEGER NOT NULL, - mxid TEXT, - - name TEXT NOT NULL, - avatar_id TEXT NOT NULL, - avatar_url TEXT NOT NULL, - name_set BOOLEAN NOT NULL DEFAULT false, - avatar_set BOOLEAN NOT NULL DEFAULT false, - - whatsapp_server TEXT NOT NULL DEFAULT '', - - encrypted BOOLEAN NOT NULL DEFAULT false, - relay_user_id TEXT NOT NULL, - - oldest_message_id TEXT NOT NULL, - oldest_message_ts BIGINT NOT NULL, - more_to_backfill BOOLEAN NOT NULL, - - PRIMARY KEY (thread_id, receiver), - CONSTRAINT portal_mxid_unique UNIQUE(mxid) -); - -CREATE TABLE puppet ( - id BIGINT NOT NULL PRIMARY KEY, - name TEXT NOT NULL, - username TEXT NOT NULL, - avatar_id TEXT NOT NULL, - avatar_url TEXT NOT NULL, - name_set BOOLEAN NOT NULL DEFAULT false, - avatar_set BOOLEAN NOT NULL DEFAULT false, - - contact_info_set BOOLEAN NOT NULL DEFAULT false, - whatsapp_server TEXT NOT NULL DEFAULT '', - - custom_mxid TEXT, - access_token TEXT NOT NULL, - - CONSTRAINT puppet_custom_mxid_unique UNIQUE(custom_mxid) -); - -CREATE TABLE "user" ( - mxid TEXT NOT NULL PRIMARY KEY, - meta_id BIGINT, - wa_device_id INTEGER, - cookies jsonb, - - inbox_fetched BOOLEAN NOT NULL, - - management_room TEXT, - space_room TEXT, - - CONSTRAINT user_meta_id_unique UNIQUE(meta_id) -); - -CREATE TABLE user_portal ( - user_mxid TEXT NOT NULL, - portal_thread_id BIGINT NOT NULL, - portal_receiver BIGINT NOT NULL, - - last_read_ts BIGINT NOT NULL DEFAULT 0, - in_space BOOLEAN NOT NULL DEFAULT false, - - PRIMARY KEY (user_mxid, portal_thread_id, portal_receiver), - CONSTRAINT user_portal_user_fkey FOREIGN KEY (user_mxid) - REFERENCES "user"(mxid) ON UPDATE CASCADE ON DELETE CASCADE, - CONSTRAINT user_portal_portal_fkey FOREIGN KEY (portal_thread_id, portal_receiver) - REFERENCES portal(thread_id, receiver) ON UPDATE CASCADE ON DELETE CASCADE -); - -CREATE TABLE backfill_task ( - portal_id BIGINT NOT NULL, - portal_receiver BIGINT NOT NULL, - user_mxid TEXT NOT NULL, - - priority INTEGER NOT NULL, - page_count INTEGER NOT NULL, - finished BOOLEAN NOT NULL, - dispatched_at BIGINT NOT NULL, - completed_at BIGINT NOT NULL, - cooldown_until BIGINT NOT NULL, - - PRIMARY KEY (portal_id, portal_receiver, user_mxid), - CONSTRAINT backfill_task_user_fkey FOREIGN KEY (user_mxid) - REFERENCES "user" (mxid) ON UPDATE CASCADE ON DELETE CASCADE, - CONSTRAINT backfill_task_portal_fkey FOREIGN KEY (portal_id, portal_receiver) - REFERENCES portal (thread_id, receiver) ON UPDATE CASCADE ON DELETE CASCADE -); - -CREATE TABLE message ( - id TEXT NOT NULL, - part_index INTEGER NOT NULL, - thread_id BIGINT NOT NULL, - thread_receiver BIGINT NOT NULL, - msg_sender BIGINT NOT NULL, - otid BIGINT NOT NULL, - - mxid TEXT NOT NULL, - mx_room TEXT NOT NULL, - - timestamp BIGINT NOT NULL, - edit_count BIGINT NOT NULL, - - PRIMARY KEY (id, part_index, thread_receiver), - CONSTRAINT message_portal_fkey FOREIGN KEY (thread_id, thread_receiver) - REFERENCES portal(thread_id, receiver) ON DELETE CASCADE ON UPDATE CASCADE, - CONSTRAINT message_puppet_fkey FOREIGN KEY (msg_sender) - REFERENCES puppet(id) ON DELETE CASCADE ON UPDATE CASCADE, - CONSTRAINT message_mxid_unique UNIQUE (mxid) -); - -CREATE TABLE reaction ( - message_id TEXT NOT NULL, - -- Part index is not used by reactions, but is required for the foreign key - _part_index INTEGER NOT NULL DEFAULT 0, - thread_id BIGINT NOT NULL, - thread_receiver BIGINT NOT NULL, - reaction_sender BIGINT NOT NULL, - - emoji TEXT NOT NULL, - - mxid TEXT NOT NULL, - mx_room TEXT NOT NULL, - - PRIMARY KEY (message_id, thread_receiver, reaction_sender), - CONSTRAINT reaction_message_fkey FOREIGN KEY (message_id, _part_index, thread_receiver) - REFERENCES message (id, part_index, thread_receiver) ON DELETE CASCADE ON UPDATE CASCADE, - CONSTRAINT reaction_puppet_fkey FOREIGN KEY (reaction_sender) - REFERENCES puppet(id) ON DELETE CASCADE ON UPDATE CASCADE, - CONSTRAINT reaction_portal_fkey FOREIGN KEY (thread_id, thread_receiver) - REFERENCES portal(thread_id, receiver) ON DELETE CASCADE ON UPDATE CASCADE, - CONSTRAINT reaction_mxid_unique UNIQUE (mxid) -); diff --git a/database/upgrades/02-edit-count.sql b/database/upgrades/02-edit-count.sql deleted file mode 100644 index 17bb143..0000000 --- a/database/upgrades/02-edit-count.sql +++ /dev/null @@ -1,4 +0,0 @@ --- v2: Store message edit count -ALTER TABLE message ADD COLUMN edit_count BIGINT NOT NULL DEFAULT 0; --- only: postgres -ALTER TABLE message ALTER COLUMN edit_count DROP DEFAULT; diff --git a/database/upgrades/03-backfill-queue.sql b/database/upgrades/03-backfill-queue.sql deleted file mode 100644 index a0fe521..0000000 --- a/database/upgrades/03-backfill-queue.sql +++ /dev/null @@ -1,39 +0,0 @@ --- v3: Add backfill queue -ALTER TABLE portal ADD COLUMN oldest_message_id TEXT NOT NULL DEFAULT ''; -ALTER TABLE portal ADD COLUMN oldest_message_ts BIGINT NOT NULL DEFAULT 0; -ALTER TABLE portal ADD COLUMN more_to_backfill BOOL NOT NULL DEFAULT true; -UPDATE portal SET (oldest_message_id, oldest_message_ts) = ( - SELECT id, timestamp - FROM message - WHERE thread_id = portal.thread_id - AND thread_receiver = portal.receiver - ORDER BY timestamp ASC - LIMIT 1 -) WHERE EXISTS(SELECT 1 FROM message WHERE thread_id = portal.thread_id AND thread_receiver = portal.receiver); --- only: postgres for next 3 lines -ALTER TABLE portal ALTER COLUMN oldest_message_id DROP DEFAULT; -ALTER TABLE portal ALTER COLUMN oldest_message_ts DROP DEFAULT; -ALTER TABLE portal ALTER COLUMN more_to_backfill DROP DEFAULT; - -CREATE TABLE backfill_task ( - portal_id BIGINT NOT NULL, - portal_receiver BIGINT NOT NULL, - user_mxid TEXT NOT NULL, - - priority INTEGER NOT NULL, - page_count INTEGER NOT NULL, - finished BOOLEAN NOT NULL, - dispatched_at BIGINT NOT NULL, - completed_at BIGINT NOT NULL, - cooldown_until BIGINT NOT NULL, - - PRIMARY KEY (portal_id, portal_receiver, user_mxid), - CONSTRAINT backfill_task_user_fkey FOREIGN KEY (user_mxid) - REFERENCES "user" (mxid) ON UPDATE CASCADE ON DELETE CASCADE, - CONSTRAINT backfill_task_portal_fkey FOREIGN KEY (portal_id, portal_receiver) - REFERENCES portal (thread_id, receiver) ON UPDATE CASCADE ON DELETE CASCADE -); - -ALTER TABLE "user" ADD COLUMN inbox_fetched BOOLEAN NOT NULL DEFAULT false; --- only: postgres -ALTER TABLE "user" ALTER COLUMN inbox_fetched DROP DEFAULT; diff --git a/database/upgrades/04-wa-device-id.sql b/database/upgrades/04-wa-device-id.sql deleted file mode 100644 index 1e064d6..0000000 --- a/database/upgrades/04-wa-device-id.sql +++ /dev/null @@ -1,2 +0,0 @@ --- v4 (compatible with v3+): Store WhatsApp device ID for users -ALTER TABLE "user" ADD COLUMN wa_device_id INTEGER; diff --git a/database/upgrades/05-wa-server-name.sql b/database/upgrades/05-wa-server-name.sql deleted file mode 100644 index 523e5ae..0000000 --- a/database/upgrades/05-wa-server-name.sql +++ /dev/null @@ -1,3 +0,0 @@ --- v5 (compatible with v3+): Store WhatsApp server name for portals and puppets -ALTER TABLE portal ADD COLUMN whatsapp_server TEXT NOT NULL DEFAULT ''; -ALTER TABLE puppet ADD COLUMN whatsapp_server TEXT NOT NULL DEFAULT ''; diff --git a/database/upgrades/06-user-portal-last-read.sql b/database/upgrades/06-user-portal-last-read.sql deleted file mode 100644 index 007d964..0000000 --- a/database/upgrades/06-user-portal-last-read.sql +++ /dev/null @@ -1,2 +0,0 @@ --- v6 (compatible with v3+): Store last read timestamp for chats -ALTER TABLE user_portal ADD COLUMN last_read_ts BIGINT NOT NULL DEFAULT 0; diff --git a/database/upgrades/upgrades.go b/database/upgrades/upgrades.go deleted file mode 100644 index a9c505d..0000000 --- a/database/upgrades/upgrades.go +++ /dev/null @@ -1,32 +0,0 @@ -// mautrix-meta - A Matrix-Facebook Messenger and Instagram DM puppeting bridge. -// Copyright (C) 2024 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package upgrades - -import ( - "embed" - - "go.mau.fi/util/dbutil" -) - -var Table dbutil.UpgradeTable - -//go:embed *.sql -var rawUpgrades embed.FS - -func init() { - Table.RegisterFS(rawUpgrades) -} diff --git a/database/user.go b/database/user.go deleted file mode 100644 index ce8f0af..0000000 --- a/database/user.go +++ /dev/null @@ -1,122 +0,0 @@ -// mautrix-meta - A Matrix-Facebook Messenger and Instagram DM puppeting bridge. -// Copyright (C) 2024 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package database - -import ( - "context" - "database/sql" - "sync" - "time" - - "go.mau.fi/util/dbutil" - "maunium.net/go/mautrix/id" - - "go.mau.fi/mautrix-meta/messagix/cookies" - "go.mau.fi/mautrix-meta/messagix/types" -) - -const ( - getUserByMXIDQuery = `SELECT mxid, meta_id, wa_device_id, cookies, inbox_fetched, management_room, space_room FROM "user" WHERE mxid=$1` - getUserByMetaIDQuery = `SELECT mxid, meta_id, wa_device_id, cookies, inbox_fetched, management_room, space_room FROM "user" WHERE meta_id=$1` - getAllLoggedInUsersQuery = `SELECT mxid, meta_id, wa_device_id, cookies, inbox_fetched, management_room, space_room FROM "user" WHERE cookies IS NOT NULL` - insertUserQuery = `INSERT INTO "user" (mxid, meta_id, wa_device_id, cookies, inbox_fetched, management_room, space_room) VALUES ($1, $2, $3, $4, $5, $6, $7)` - updateUserQuery = `UPDATE "user" SET meta_id=$2, wa_device_id=$3, cookies=$4, inbox_fetched=$5, management_room=$6, space_room=$7 WHERE mxid=$1` -) - -type UserQuery struct { - *dbutil.QueryHelper[*User] -} - -type User struct { - qh *dbutil.QueryHelper[*User] - - MXID id.UserID - MetaID int64 - WADeviceID uint16 - Cookies *cookies.Cookies - InboxFetched bool - ManagementRoom id.RoomID - SpaceRoom id.RoomID - - lastReadCache map[PortalKey]time.Time - lastReadCacheLock sync.Mutex - inSpaceCache map[PortalKey]bool - inSpaceCacheLock sync.Mutex -} - -func newUser(qh *dbutil.QueryHelper[*User]) *User { - return &User{ - qh: qh, - - lastReadCache: make(map[PortalKey]time.Time), - inSpaceCache: make(map[PortalKey]bool), - } -} - -func (uq *UserQuery) GetByMXID(ctx context.Context, mxid id.UserID) (*User, error) { - return uq.QueryOne(ctx, getUserByMXIDQuery, mxid) -} - -func (uq *UserQuery) GetByMetaID(ctx context.Context, id int64) (*User, error) { - return uq.QueryOne(ctx, getUserByMetaIDQuery, id) -} - -func (uq *UserQuery) GetAllLoggedIn(ctx context.Context) ([]*User, error) { - return uq.QueryMany(ctx, getAllLoggedInUsersQuery) -} - -func (u *User) sqlVariables() []any { - return []any{u.MXID, dbutil.NumPtr(u.MetaID), u.WADeviceID, dbutil.JSONPtr(u.Cookies), u.InboxFetched, dbutil.StrPtr(u.ManagementRoom), dbutil.StrPtr(u.SpaceRoom)} -} - -func (u *User) Insert(ctx context.Context) error { - return u.qh.Exec(ctx, insertUserQuery, u.sqlVariables()...) -} - -func (u *User) Update(ctx context.Context) error { - return u.qh.Exec(ctx, updateUserQuery, u.sqlVariables()...) -} - -var MessagixPlatform types.Platform - -func (u *User) Scan(row dbutil.Scannable) (*User, error) { - var managementRoom, spaceRoom sql.NullString - var metaID sql.NullInt64 - var waDeviceID sql.NullInt32 - var newCookies cookies.Cookies - newCookies.Platform = MessagixPlatform - err := row.Scan( - &u.MXID, - &metaID, - &waDeviceID, - &dbutil.JSON{Data: &newCookies}, - &u.InboxFetched, - &managementRoom, - &spaceRoom, - ) - if err != nil { - return nil, err - } - if newCookies.IsLoggedIn() { - u.Cookies = &newCookies - } - u.MetaID = metaID.Int64 - u.WADeviceID = uint16(waDeviceID.Int32) - u.ManagementRoom = id.RoomID(managementRoom.String) - u.SpaceRoom = id.RoomID(spaceRoom.String) - return u, nil -} diff --git a/database/userportal.go b/database/userportal.go deleted file mode 100644 index 2888e14..0000000 --- a/database/userportal.go +++ /dev/null @@ -1,121 +0,0 @@ -// mautrix-meta - A Matrix-Facebook Messenger and Instagram DM puppeting bridge. -// Copyright (C) 2024 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package database - -import ( - "context" - "database/sql" - "errors" - "time" - - "github.com/rs/zerolog" -) - -const ( - getLastReadTSQuery = `SELECT last_read_ts FROM user_portal WHERE user_mxid=$1 AND portal_thread_id=$2 AND portal_receiver=$3` - setLastReadTSQuery = ` - INSERT INTO user_portal (user_mxid, portal_thread_id, portal_receiver, last_read_ts) VALUES ($1, $2, $3, $4) - ON CONFLICT (user_mxid, portal_thread_id, portal_receiver) DO UPDATE - SET last_read_ts=excluded.last_read_ts WHERE user_portal.last_read_ts?_txlock=immediate` is recommended. - # https://github.com/mattn/go-sqlite3#connection-string - # Postgres: Connection string. For example, postgres://user:password@host/database?sslmode=disable - # To connect via Unix socket, use something like postgres:///dbname?host=/var/run/postgresql - uri: postgres://user:password@host/database?sslmode=disable - # Maximum number of connections. Mostly relevant for Postgres. - max_open_conns: 20 - max_idle_conns: 2 - # Maximum connection idle time and lifetime before they're closed. Disabled if null. - # Parsed with https://pkg.go.dev/time#ParseDuration - max_conn_idle_time: null - max_conn_lifetime: null - - # The unique ID of this appservice. - id: instagram - # Appservice bot details. - bot: - # Username of the appservice bot. - username: instagrambot - # Display name and avatar for bot. Set to "remove" to remove display name/avatar, leave empty - # to leave display name/avatar as-is. - displayname: Instagram bridge bot - # You can use mxc://maunium.net/JxjlbZUlCPULEeHZSwleUXQv for an Instagram avatar, - # or mxc://maunium.net/ygtkteZsXnGJLJHRchUwYWak for Facebook Messenger - avatar: mxc://maunium.net/JxjlbZUlCPULEeHZSwleUXQv - - # Whether or not to receive ephemeral events via appservice transactions. - # Requires MSC2409 support (i.e. Synapse 1.22+). - ephemeral_events: true - # Should incoming events be handled asynchronously? - # This may be necessary for large public instances with lots of messages going through. - # However, messages will not be guaranteed to be bridged in the same order they were sent in. - async_transactions: false - - # Authentication tokens for AS <-> HS communication. Autogenerated; do not modify. - as_token: "This value is generated when generating the registration" - hs_token: "This value is generated when generating the registration" - -meta: - # Which service is this bridge for? Available options: - # * facebook - connect to FB Messenger via facebook.com - # * facebook-tor - connect to FB Messenger via facebookwkhpilnemxj7asaniu7vnjjbiltxjqhye3mhbshg7kx5tfyd.onion - # (note: does not currently proxy media downloads) - # * messenger - connect to FB Messenger via messenger.com (can be used with the facebook side deactivated) - # * instagram - connect to Instagram DMs via instagram.com - # - # Remember to change the appservice id, bot profile info, bridge username_template and management_room_text too. - mode: instagram - # When in Instagram mode, should the bridge connect to WhatsApp servers for encrypted chats? - # In FB/Messenger mode encryption is always enabled, this option only affects Instagram mode. - ig_e2ee: false - # Static proxy address (HTTP or SOCKS5) for connecting to Meta. - proxy: - # HTTP endpoint to request new proxy address from, for dynamically assigned proxies. - # The endpoint must return a JSON body with a string field called proxy_url. - get_proxy_from: - # Minimum interval between full reconnects in seconds, default is 1 hour - min_full_reconnect_interval_seconds: 3600 - # Interval to force refresh the connection (full reconnect), default is 20 hours. Set 0 to disable force refreshes. - force_refresh_interval_seconds: 72000 - -# Bridge config -bridge: - # Localpart template of MXIDs for FB/IG users. - # {{.}} is replaced with the internal ID of the FB/IG user. - username_template: instagram_{{.}} - # Displayname template for FB/IG users. This is also used as the room name in DMs if private_chat_portal_meta is enabled. - # {{.DisplayName}} - The display name set by the user. - # {{.Username}} - The username set by the user. - # {{.ID}} - The internal user ID of the user. - displayname_template: '{{or .DisplayName .Username "Unknown user"}}' - # Whether to explicitly set the avatar and room name for private chat portal rooms. - # If set to `default`, this will be enabled in encrypted rooms and disabled in unencrypted rooms. - # If set to `always`, all DM rooms will have explicit names and avatars set. - # If set to `never`, DM rooms will never have names and avatars set. - private_chat_portal_meta: default - - portal_message_buffer: 128 - - # Should the bridge create a space for each logged-in user and add bridged rooms to it? - # Users who logged in before turning this on should run `!meta sync-space` to create and fill the space for the first time. - personal_filtering_spaces: false - # Should Matrix m.notice-type messages be bridged? - bridge_notices: true - # Should the bridge send a read receipt from the bridge bot when a message has been sent to FB/IG? - delivery_receipts: false - # Whether the bridge should send the message status as a custom com.beeper.message_send_status event. - message_status_events: false - # Whether the bridge should send error notices via m.notice events when a message fails to bridge. - message_error_notices: true - # Should the bridge never send alerts to the bridge management room? - # These are mostly things like the user being logged out. - disable_bridge_alerts: false - # Should the bridge update the m.direct account data event when double puppeting is enabled. - # Note that updating the m.direct event is not atomic and is therefore prone to race conditions. - sync_direct_chat_list: false - # Set this to true to tell the bridge to re-send m.bridge events to all rooms on the next run. - # This field will automatically be changed back to false after it, except if the config file is not writable. - resend_bridge_info: false - # Send captions in the same message as images. This will send data compatible with both MSC2530. - # This is currently not supported in most clients. - caption_in_message: false - # Whether or not created rooms should have federation enabled. - # If false, created portal rooms will never be federated. - federate_rooms: true - # Should mute status be bridged? Allowed options: always, on-create, never - mute_bridging: on-create - # Servers to always allow double puppeting from - double_puppet_server_map: - example.com: https://example.com - # Allow using double puppeting from any server with a valid client .well-known file. - double_puppet_allow_discovery: false - # Shared secrets for https://github.com/devture/matrix-synapse-shared-secret-auth - # - # If set, double puppeting will be enabled automatically for local users - # instead of users having to find an access token and run `login-matrix` - # manually. - login_shared_secret_map: - example.com: foobar - - # The prefix for commands. Only required in non-management rooms. - # If set to "default", will be determined based on meta -> mode, "!ig" for instagram and "!fb" for facebook - command_prefix: default - - backfill: - # If disabled, old messages will never be bridged. - enabled: true - # By default, Meta sends info about approximately 20 recent threads. If this is set to something else than 0, - # the bridge will request more threads on first login, until it reaches the specified number of pages - # or the end of the inbox. - inbox_fetch_pages: 0 - # By default, Meta only sends one old message per thread. If this is set to a something else than 0, - # the bridge will delay handling the one automatically received message and request more messages to backfill. - # One page usually contains 20 messages. This can technically be set to -1 to fetch all messages, - # but that will block bridging messages until the entire backfill is completed. - history_fetch_pages: 0 - # Same as above, but for catchup backfills (i.e. when the bridge is restarted). - catchup_fetch_pages: 5 - # Maximum age of chats to leave as unread when backfilling. 0 means all chats can be left as unread. - # If non-zero, chats that are older than this will be marked as read, even if they're still unread on Meta. - unread_hours_threshold: 0 - # Backfill queue settings. Only relevant for Beeper, because standard Matrix servers - # don't support inserting messages into room history. - queue: - # How many pages of messages to request in one go (without sleeping between requests)? - pages_at_once: 5 - # Maximum number of pages to fetch. -1 to fetch all pages until the start of the chat. - max_pages: -1 - # How long to sleep after fetching a bunch of pages ("bunch" defined by pages_at_once). - sleep_between_tasks: 20s - # Disable fetching XMA media (reels, stories, etc) when backfilling. - dont_fetch_xma: true - # Disable fetching XMA media entirely. - disable_xma: false - - # Messages sent upon joining a management room. - # Markdown is supported. The defaults are listed below. - management_room_text: - # Sent when joining a room. - welcome: "Hello, I'm an Instagram bridge bot." - # Sent when joining a management room and the user is already logged in. - welcome_connected: "Use `help` for help." - # Sent when joining a management room and the user is not logged in. - welcome_unconnected: "Use `help` for help or `login` to log in." - # Optional extra text sent when joining a management room. - additional_help: "" - - # End-to-bridge encryption support options. - # - # See https://docs.mau.fi/bridges/general/end-to-bridge-encryption.html for more info. - encryption: - # Allow encryption, work in group chat rooms with e2ee enabled - allow: false - # Default to encryption, force-enable encryption in all portals the bridge creates - # This will cause the bridge bot to be in private chats for the encryption to work properly. - default: false - # Whether to use MSC2409/MSC3202 instead of /sync long polling for receiving encryption-related data. - appservice: false - # Require encryption, drop any unencrypted messages. - require: false - # Enable key sharing? If enabled, key requests for rooms where users are in will be fulfilled. - # You must use a client that supports requesting keys from other users to use this feature. - allow_key_sharing: false - # Options for deleting megolm sessions from the bridge. - delete_keys: - # Beeper-specific: delete outbound sessions when hungryserv confirms - # that the user has uploaded the key to key backup. - delete_outbound_on_ack: false - # Don't store outbound sessions in the inbound table. - dont_store_outbound: false - # Ratchet megolm sessions forward after decrypting messages. - ratchet_on_decrypt: false - # Delete fully used keys (index >= max_messages) after decrypting messages. - delete_fully_used_on_decrypt: false - # Delete previous megolm sessions from same device when receiving a new one. - delete_prev_on_new_session: false - # Delete megolm sessions received from a device when the device is deleted. - delete_on_device_delete: false - # Periodically delete megolm sessions when 2x max_age has passed since receiving the session. - periodically_delete_expired: false - # Delete inbound megolm sessions that don't have the received_at field used for - # automatic ratcheting and expired session deletion. This is meant as a migration - # to delete old keys prior to the bridge update. - delete_outdated_inbound: false - # What level of device verification should be required from users? - # - # Valid levels: - # unverified - Send keys to all device in the room. - # cross-signed-untrusted - Require valid cross-signing, but trust all cross-signing keys. - # cross-signed-tofu - Require valid cross-signing, trust cross-signing keys on first use (and reject changes). - # cross-signed-verified - Require valid cross-signing, plus a valid user signature from the bridge bot. - # Note that creating user signatures from the bridge bot is not currently possible. - # verified - Require manual per-device verification - # (currently only possible by modifying the `trust` column in the `crypto_device` database table). - verification_levels: - # Minimum level for which the bridge should send keys to when bridging messages from FB/IG to Matrix. - receive: unverified - # Minimum level that the bridge should accept for incoming Matrix messages. - send: unverified - # Minimum level that the bridge should require for accepting key requests. - share: cross-signed-tofu - # Options for Megolm room key rotation. These options allow you to - # configure the m.room.encryption event content. See: - # https://spec.matrix.org/v1.3/client-server-api/#mroomencryption for - # more information about that event. - rotation: - # Enable custom Megolm room key rotation settings. Note that these - # settings will only apply to rooms created after this option is - # set. - enable_custom: false - # The maximum number of milliseconds a session should be used - # before changing it. The Matrix spec recommends 604800000 (a week) - # as the default. - milliseconds: 604800000 - # The maximum number of messages that should be sent with a given a - # session before changing it. The Matrix spec recommends 100 as the - # default. - messages: 100 - - # Disable rotating keys when a user's devices change? - # You should not enable this option unless you understand all the implications. - disable_device_change_key_rotation: false - - # Settings for provisioning API - provisioning: - # Prefix for the provisioning API paths. - prefix: /_matrix/provision - # Shared secret for authentication. If set to "generate", a random secret will be generated, - # or if set to "disable", the provisioning API will be disabled. - shared_secret: generate - # Enable debug API at /debug with provisioning authentication. - debug_endpoints: false - - # Permissions for using the bridge. - # Permitted values: - # relay - Talk through the relaybot (if enabled), no access otherwise - # user - Access to use the bridge to chat with a Meta account. - # admin - User level and some additional administration tools - # Permitted keys: - # * - All Matrix users - # domain - All users on that homeserver - # mxid - Specific user - permissions: - "*": relay - "example.com": user - "@admin:example.com": admin - - # Settings for relay mode - relay: - # Whether relay mode should be allowed. If allowed, `!wa set-relay` can be used to turn any - # authenticated user into a relaybot for that chat. - enabled: false - # Should only admins be allowed to set themselves as relay users? - admin_only: true - # The formats to use when sending messages to Meta via the relaybot. - message_formats: - m.text: "{{ .Sender.Displayname }}: {{ .Message }}" - m.notice: "{{ .Sender.Displayname }}: {{ .Message }}" - m.emote: "* {{ .Sender.Displayname }} {{ .Message }}" - m.file: "{{ .Sender.Displayname }} sent a file" - m.image: "{{ .Sender.Displayname }} sent an image" - m.audio: "{{ .Sender.Displayname }} sent an audio file" - m.video: "{{ .Sender.Displayname }} sent a video" - m.location: "{{ .Sender.Displayname }} sent a location" - -# Logging config. See https://github.com/tulir/zeroconfig for details. -logging: - min_level: debug - writers: - - type: stdout - format: pretty-colored - - type: file - format: json - filename: ./logs/mautrix-meta.log - max_size: 100 - max_backups: 10 - compress: true diff --git a/go.mod b/go.mod index e9ab605..d2fad57 100644 --- a/go.mod +++ b/go.mod @@ -42,3 +42,5 @@ require ( golang.org/x/text v0.17.0 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect ) + +//replace maunium.net/go/mautrix => ../mautrix-go diff --git a/main.go b/main.go deleted file mode 100644 index 9a25ce2..0000000 --- a/main.go +++ /dev/null @@ -1,352 +0,0 @@ -// mautrix-meta - A Matrix-Facebook Messenger and Instagram DM puppeting bridge. -// Copyright (C) 2024 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package main - -import ( - "context" - _ "embed" - "fmt" - "os" - "strings" - "sync" - - "github.com/rs/zerolog" - "go.mau.fi/util/configupgrade" - flag "maunium.net/go/mauflag" - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/bridge" - "maunium.net/go/mautrix/bridge/commands" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/format" - "maunium.net/go/mautrix/id" - - "go.mau.fi/whatsmeow/store/sqlstore" - waLog "go.mau.fi/whatsmeow/util/log" - - "go.mau.fi/mautrix-meta/config" - "go.mau.fi/mautrix-meta/database" - "go.mau.fi/mautrix-meta/database/legacymigrate" - "go.mau.fi/mautrix-meta/messagix/socket" - "go.mau.fi/mautrix-meta/messagix/table" - "go.mau.fi/mautrix-meta/messagix/types" - "go.mau.fi/mautrix-meta/msgconv" -) - -//go:embed example-config.yaml -var ExampleConfig string - -// Information to find out exactly which commit the bridge was built from. -// These are filled at build time with the -X linker flag. -var ( - Tag = "unknown" - Commit = "unknown" - BuildTime = "unknown" -) - -var migrateLegacyFrom = flag.Make(). - LongKey("db-migrate-from"). - Usage("Migrate from a legacy mautrix-facebook database"). - String() - -type MetaBridge struct { - bridge.Bridge - - Config *config.Config - DB *database.Database - - DeviceStore *sqlstore.Container - - provisioning *ProvisioningAPI - - usersByMXID map[id.UserID]*User - usersByMetaID map[int64]*User - usersLock sync.Mutex - - managementRooms map[id.RoomID]*User - managementRoomsLock sync.Mutex - - portalsByMXID map[id.RoomID]*Portal - portalsByID map[database.PortalKey]*Portal - portalsLock sync.Mutex - - puppets map[int64]*Puppet - puppetsByCustomMXID map[id.UserID]*Puppet - puppetsLock sync.Mutex -} - -var _ bridge.ChildOverride = (*MetaBridge)(nil) - -func (br *MetaBridge) GetExampleConfig() string { - return ExampleConfig -} - -func (br *MetaBridge) GetConfigPtr() interface{} { - br.Config = &config.Config{ - BaseConfig: &br.Bridge.Config, - } - br.Config.BaseConfig.Bridge = &br.Config.Bridge - return br.Config -} - -func (br *MetaBridge) ValidateConfig() error { - if !br.Config.Meta.Mode.IsValid() { - return fmt.Errorf("invalid meta bridge mode %q", br.Config.Meta.Mode) - } - return nil -} - -func (br *MetaBridge) Init() { - br.DB = database.New(br.Bridge.DB) - if *migrateLegacyFrom != "" { - if br.Config.Meta.Mode.IsInstagram() { - br.ZLog.Fatal().Msg("Instagram database can't be migrated") - } - dialect := "sqlite3" - if strings.HasPrefix(*migrateLegacyFrom, "postgres") { - dialect = "postgres" - } - br.ZLog.Info().Str("legacy_db_dialect", dialect).Msg("Database migration requested") - legacymigrate.Migrate(br.ZLog.WithContext(context.Background()), br.DB, dialect, *migrateLegacyFrom) - _ = br.DB.Close() - os.Exit(0) - } - - var defaultCommandPrefix string - switch br.Config.Meta.Mode { - case config.ModeInstagram: - msgconv.MediaReferer = "https://www.instagram.com/" - br.ProtocolName = "Instagram DM" - br.BeeperServiceName = "instagramgo" - br.BeeperNetworkName = "instagram" - defaultCommandPrefix = "!ig" - database.MessagixPlatform = types.Instagram - case config.ModeFacebook, config.ModeMessenger, config.ModeFacebookTor: - switch br.Config.Meta.Mode { - case config.ModeFacebook: - msgconv.MediaReferer = "https://www.facebook.com/" - database.MessagixPlatform = types.Facebook - case config.ModeMessenger: - msgconv.MediaReferer = "https://www.messenger.com/" - database.MessagixPlatform = types.Messenger - case config.ModeFacebookTor: - //msgconv.MediaReferer = "https://www.facebookwkhpilnemxj7asaniu7vnjjbiltxjqhye3mhbshg7kx5tfyd.onion/" - // Media is currently not proxied for efficiency - // TODO make it configurable (requires passing the proxy to mediaHTTPClient) - msgconv.BypassOnionForMedia = true - msgconv.MediaReferer = "https://www.facebook.com/" - database.MessagixPlatform = types.FacebookTor - } - br.ProtocolName = "Facebook Messenger" - br.BeeperServiceName = "facebookgo" - br.BeeperNetworkName = "facebook" - defaultCommandPrefix = "!fb" - } - if br.Config.Bridge.CommandPrefix == "default" { - br.Config.Bridge.CommandPrefix = defaultCommandPrefix - } - br.CommandProcessor = commands.NewProcessor(&br.Bridge) - br.RegisterCommands() - - br.DeviceStore = sqlstore.NewWithDB(br.DB.RawDB, br.DB.Dialect.String(), waLog.Zerolog(br.ZLog.With().Str("db_section", "whatsmeow").Logger())) - - ss := br.Config.Bridge.Provisioning.SharedSecret - if len(ss) > 0 && ss != "disable" { - br.provisioning = &ProvisioningAPI{bridge: br, log: br.ZLog.With().Str("component", "provisioning").Logger()} - } -} - -func (br *MetaBridge) Start() { - err := br.DeviceStore.Upgrade() - if err != nil { - br.ZLog.Fatal().Err(err).Msg("Failed to upgrade whatsmeow device store") - } - if br.provisioning != nil { - br.ZLog.Debug().Msg("Initializing provisioning API") - br.provisioning.Init() - } - go br.StartUsers() -} - -func (br *MetaBridge) Stop() { - for _, user := range br.usersByMXID { - user.log.Debug().Msg("Disconnecting user") - user.Disconnect() - } -} - -func (br *MetaBridge) GetIPortal(mxid id.RoomID) bridge.Portal { - p := br.GetPortalByMXID(mxid) - if p == nil { - return nil - } - return p -} - -func (br *MetaBridge) GetIUser(mxid id.UserID, create bool) bridge.User { - p := br.GetUserByMXID(mxid) - if p == nil { - return nil - } - return p -} - -func (br *MetaBridge) IsGhost(mxid id.UserID) bool { - _, isGhost := br.ParsePuppetMXID(mxid) - return isGhost -} - -func (br *MetaBridge) GetIGhost(mxid id.UserID) bridge.Ghost { - p := br.GetPuppetByMXID(mxid) - if p == nil { - return nil - } - return p -} - -func (br *MetaBridge) CreatePrivatePortal(roomID id.RoomID, brInviter bridge.User, brGhost bridge.Ghost) { - inviter := brInviter.(*User) - puppet := brGhost.(*Puppet) - - log := br.ZLog.With(). - Str("action", "create private portal"). - Stringer("target_room_id", roomID). - Stringer("inviter_mxid", inviter.MXID). - Int64("invitee_fbid", puppet.ID). - Logger() - ctx := log.WithContext(context.TODO()) - log.Debug().Msg("Creating private chat portal") - - portal := br.GetPortalByThreadID(database.PortalKey{ - ThreadID: puppet.ID, - Receiver: inviter.MetaID, - }, table.ONE_TO_ONE) - if len(portal.MXID) == 0 { - resp, err := inviter.Client.ExecuteTasks( - &socket.CreateThreadTask{ - ThreadFBID: portal.ThreadID, - ForceUpsert: 0, - UseOpenMessengerTransport: 0, - SyncGroup: 1, - MetadataOnly: 0, - PreviewOnly: 0, - }, - ) - log.Trace().Any("response_data", resp).Msg("DM thread create response") - if err != nil { - log.Err(err).Msg("Failed to create DM thread") - _, _ = puppet.DefaultIntent().LeaveRoom(ctx, roomID, &mautrix.ReqLeave{Reason: "Failed to create DM thread"}) - return - } - br.createPrivatePortalFromInvite(ctx, roomID, inviter, puppet, portal) - return - } - log.Debug(). - Str("existing_room_id", portal.MXID.String()). - Msg("Existing private chat portal found, trying to invite user") - - ok := portal.ensureUserInvited(ctx, inviter) - if !ok { - log.Warn().Msg("Failed to invite user to existing private chat portal. Redirecting portal to new room") - br.createPrivatePortalFromInvite(ctx, roomID, inviter, puppet, portal) - return - } - intent := puppet.DefaultIntent() - errorMessage := fmt.Sprintf("You already have a private chat portal with me at [%[1]s](https://matrix.to/#/%[1]s)", portal.MXID) - errorContent := format.RenderMarkdown(errorMessage, true, false) - _, _ = intent.SendMessageEvent(ctx, roomID, event.EventMessage, errorContent) - log.Debug().Msg("Leaving ghost from private chat room after accepting invite because we already have a chat with the user") - _, _ = intent.LeaveRoom(ctx, roomID) -} - -func (br *MetaBridge) createPrivatePortalFromInvite(ctx context.Context, roomID id.RoomID, inviter *User, puppet *Puppet, portal *Portal) { - log := zerolog.Ctx(ctx) - log.Debug().Msg("Creating private portal from invite") - - // Check if room is already encrypted - var existingEncryption event.EncryptionEventContent - var encryptionEnabled bool - err := portal.MainIntent().StateEvent(ctx, roomID, event.StateEncryption, "", &existingEncryption) - if err != nil { - log.Err(err).Msg("Failed to check if encryption is enabled in private chat room") - } else { - encryptionEnabled = existingEncryption.Algorithm == id.AlgorithmMegolmV1 - } - portal.MXID = roomID - br.portalsLock.Lock() - br.portalsByMXID[portal.MXID] = portal - br.portalsLock.Unlock() - intent := puppet.DefaultIntent() - - if br.Config.Bridge.Encryption.Default || encryptionEnabled { - log.Debug().Msg("Adding bridge bot to new private chat portal as encryption is enabled") - _, err = intent.InviteUser(ctx, roomID, &mautrix.ReqInviteUser{UserID: br.Bot.UserID}) - if err != nil { - log.Err(err).Msg("Failed to invite bridge bot to enable e2be") - } - err = br.Bot.EnsureJoined(ctx, roomID) - if err != nil { - log.Err(err).Msg("Failed to join as bridge bot to enable e2be") - } - if !encryptionEnabled { - _, err = intent.SendStateEvent(ctx, roomID, event.StateEncryption, "", portal.getEncryptionEventContent()) - if err != nil { - log.Err(err).Msg("Failed to enable e2be") - } - } - br.AS.StateStore.SetMembership(ctx, roomID, inviter.MXID, event.MembershipJoin) - br.AS.StateStore.SetMembership(ctx, roomID, puppet.MXID, event.MembershipJoin) - br.AS.StateStore.SetMembership(ctx, roomID, br.Bot.UserID, event.MembershipJoin) - portal.Encrypted = true - } - portal.UpdateInfoFromPuppet(ctx, puppet) - _, _ = intent.SendNotice(ctx, roomID, "Private chat portal created") - log.Info().Msg("Created private chat portal after invite") -} - -func main() { - br := &MetaBridge{ - usersByMXID: make(map[id.UserID]*User), - usersByMetaID: make(map[int64]*User), - - managementRooms: make(map[id.RoomID]*User), - - portalsByMXID: make(map[id.RoomID]*Portal), - portalsByID: make(map[database.PortalKey]*Portal), - - puppets: make(map[int64]*Puppet), - puppetsByCustomMXID: make(map[id.UserID]*Puppet), - } - br.Bridge = bridge.Bridge{ - Name: "mautrix-meta", - URL: "https://github.com/mautrix/meta", - Description: "A Matrix-Facebook Messenger and Instagram DM puppeting bridge.", - Version: "0.3.2", - - CryptoPickleKey: "mautrix.bridge.e2ee", - - ConfigUpgrader: &configupgrade.StructUpgrader{ - SimpleUpgrader: configupgrade.SimpleUpgrader(config.DoUpgrade), - Blocks: config.SpacedBlocks, - Base: ExampleConfig, - }, - - Child: br, - } - br.InitVersion(Tag, Commit, BuildTime) - - br.Main() -} diff --git a/messagetracking.go b/messagetracking.go deleted file mode 100644 index d211d2a..0000000 --- a/messagetracking.go +++ /dev/null @@ -1,324 +0,0 @@ -// mautrix-meta - A Matrix-Facebook Messenger and Instagram DM puppeting bridge. -// Copyright (C) 2024 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package main - -import ( - "context" - "errors" - "fmt" - "sync" - "time" - - "github.com/rs/zerolog" - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/bridge/status" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" - - "go.mau.fi/mautrix-meta/msgconv" -) - -var ( - errUserNotConnected = errors.New("you are not connected to Meta") - errDifferentUser = errors.New("user is not the recipient of this private chat portal") - errUserNotLoggedIn = errors.New("user is not logged in and chat has no relay bot") - errRelaybotNotLoggedIn = errors.New("neither user nor relay bot of chat are logged in") - errCantRelayReactions = errors.New("user is not logged in and reactions can't be relayed") - errMNoticeDisabled = errors.New("bridging m.notice messages is disabled") - errUnexpectedParsedContentType = errors.New("unexpected parsed content type") - - errServerRejected = errors.New("server rejected message") - - errRedactionTargetNotFound = errors.New("redaction target message was not found") - errRedactionTargetSentBySomeoneElse = errors.New("redaction target message was sent by someone else") - errUnreactTargetSentBySomeoneElse = errors.New("redaction target reaction was sent by someone else") - errReactionTargetNotFound = errors.New("reaction target message not found") - errEditUnknownTarget = errors.New("unknown edit target message") - errFailedToGetEditTarget = errors.New("failed to get edit target message") - errEditDifferentSender = errors.New("can't edit message sent by another user") - errEditTooOld = errors.New("message is too old to be edited") - errEditCountExceeded = errors.New("message has been edited too many times") - errEditReverted = errors.New("server reverted the edit") - - errMessageTakingLong = errors.New("bridging the message is taking longer than usual") - errTimeoutBeforeHandling = errors.New("message timed out before handling was started") - - errReloading = errors.New("refresh error; please retry in a few minutes") - errLoggedOut = errors.New("logged out; please relogin to send the message") -) - -func errorToStatusReason(err error) (reason event.MessageStatusReason, status event.MessageStatus, isCertain, sendNotice bool, humanMessage string) { - switch { - case errors.Is(err, errUnexpectedParsedContentType), - errors.Is(err, msgconv.ErrUnsupportedMsgType), - errors.Is(err, msgconv.ErrInvalidGeoURI): - return event.MessageStatusUnsupported, event.MessageStatusFail, true, true, err.Error() - case errors.Is(err, errMNoticeDisabled): - return event.MessageStatusUnsupported, event.MessageStatusFail, true, false, err.Error() - case errors.Is(err, errEditDifferentSender), - errors.Is(err, errEditTooOld), - errors.Is(err, errEditReverted), - errors.Is(err, errEditCountExceeded), - errors.Is(err, errEditUnknownTarget): - return event.MessageStatusUnsupported, event.MessageStatusFail, true, true, err.Error() - case errors.Is(err, errTimeoutBeforeHandling): - return event.MessageStatusTooOld, event.MessageStatusRetriable, true, true, "the message was too old when it reached the bridge, so it was not handled" - case errors.Is(err, context.DeadlineExceeded): - return event.MessageStatusTooOld, event.MessageStatusRetriable, false, true, "handling the message took too long and was cancelled" - case errors.Is(err, errServerRejected), - errors.Is(err, errReloading), - errors.Is(err, errLoggedOut): - return event.MessageStatusGenericError, event.MessageStatusRetriable, false, true, err.Error() - case errors.Is(err, errMessageTakingLong): - return event.MessageStatusTooOld, event.MessageStatusPending, false, true, err.Error() - case errors.Is(err, errRedactionTargetNotFound), - errors.Is(err, errReactionTargetNotFound), - errors.Is(err, errRedactionTargetSentBySomeoneElse), - errors.Is(err, errUnreactTargetSentBySomeoneElse): - return event.MessageStatusGenericError, event.MessageStatusFail, true, false, "" - case errors.Is(err, errUserNotConnected): - return event.MessageStatusGenericError, event.MessageStatusRetriable, true, true, "" - case errors.Is(err, errUserNotLoggedIn), - errors.Is(err, errDifferentUser), - errors.Is(err, errRelaybotNotLoggedIn): - return event.MessageStatusGenericError, event.MessageStatusRetriable, true, false, "" - default: - return event.MessageStatusGenericError, event.MessageStatusRetriable, false, true, "" - } -} - -func (portal *Portal) sendErrorMessage(ctx context.Context, evt *event.Event, err error, confirmed bool, editID id.EventID) id.EventID { - if !portal.bridge.Config.Bridge.MessageErrorNotices { - return "" - } - certainty := "may not have been" - if confirmed { - certainty = "was not" - } - var msgType string - switch evt.Type { - case event.EventMessage: - msgType = "message" - case event.EventReaction: - msgType = "reaction" - case event.EventRedaction: - msgType = "redaction" - //case TypeMSC3381PollResponse, TypeMSC3381V2PollResponse: - // msgType = "poll response" - //case TypeMSC3381PollStart: - // msgType = "poll start" - default: - msgType = "unknown event" - } - msg := fmt.Sprintf("\u26a0 Your %s %s bridged: %v", msgType, certainty, err) - if errors.Is(err, errMessageTakingLong) { - msg = fmt.Sprintf("\u26a0 Bridging your %s is taking longer than usual", msgType) - } - content := &event.MessageEventContent{ - MsgType: event.MsgNotice, - Body: msg, - } - if editID != "" { - content.SetEdit(editID) - } else { - content.SetReply(evt) - } - resp, err := portal.sendMainIntentMessage(ctx, content) - if err != nil { - portal.log.Err(err).Msg("Failed to send bridging error message") - return "" - } - return resp.EventID -} - -func (portal *Portal) sendStatusEvent(ctx context.Context, evtID, lastRetry id.EventID, err error, deliveredTo *[]id.UserID) { - if !portal.bridge.Config.Bridge.MessageStatusEvents { - return - } - if lastRetry == evtID { - lastRetry = "" - } - intent := portal.bridge.Bot - if !portal.Encrypted { - // Bridge bot isn't present in unencrypted DMs - intent = portal.MainIntent() - } - content := event.BeeperMessageStatusEventContent{ - Network: portal.getBridgeInfoStateKey(), - RelatesTo: event.RelatesTo{ - Type: event.RelReference, - EventID: evtID, - }, - DeliveredToUsers: deliveredTo, - LastRetry: lastRetry, - } - if err == nil { - content.Status = event.MessageStatusSuccess - } else { - content.Reason, content.Status, _, _, content.Message = errorToStatusReason(err) - content.Error = err.Error() - } - _, err = intent.SendMessageEvent(ctx, portal.MXID, event.BeeperMessageStatus, &content) - if err != nil { - portal.log.Err(err).Msg("Failed to send message status event") - } -} - -func (portal *Portal) sendDeliveryReceipt(ctx context.Context, eventID id.EventID) { - if portal.bridge.Config.Bridge.DeliveryReceipts { - err := portal.bridge.Bot.SendReceipt(ctx, portal.MXID, eventID, event.ReceiptTypeRead, nil) - if err != nil { - portal.log.Debug().Err(err).Stringer("event_id", eventID).Msg("Failed to send delivery receipt") - } - } -} - -func (portal *Portal) sendMessageMetrics(ctx context.Context, evt *event.Event, err error, part string, ms *metricSender) { - log := portal.log.With(). - Str("handling_step", part). - Str("event_type", evt.Type.String()). - Stringer("event_id", evt.ID). - Stringer("sender", evt.Sender). - Logger() - if evt.Type == event.EventRedaction { - log = log.With().Stringer("redacts", evt.Redacts).Logger() - } - ctx = log.WithContext(ctx) - - origEvtID := evt.ID - if retryMeta := evt.Content.AsMessage().MessageSendRetry; retryMeta != nil { - origEvtID = retryMeta.OriginalEventID - } - if err != nil { - logEvt := log.Error() - if part == "Ignoring" { - logEvt = log.Debug() - } - logEvt.Err(err).Msg("Sending message metrics for event") - reason, statusCode, isCertain, sendNotice, _ := errorToStatusReason(err) - checkpointStatus := status.ReasonToCheckpointStatus(reason, statusCode) - portal.bridge.SendMessageCheckpoint(evt, status.MsgStepRemote, err, checkpointStatus, ms.getRetryNum()) - if sendNotice { - ms.setNoticeID(portal.sendErrorMessage(ctx, evt, err, isCertain, ms.getNoticeID())) - } - portal.sendStatusEvent(ctx, origEvtID, evt.ID, err, nil) - } else { - log.Debug().Msg("Sending metrics for successfully handled Matrix event") - portal.sendDeliveryReceipt(ctx, evt.ID) - portal.bridge.SendMessageSuccessCheckpoint(evt, status.MsgStepRemote, ms.getRetryNum()) - var deliveredTo *[]id.UserID - if portal.IsPrivateChat() { - deliveredTo = &[]id.UserID{} - } - portal.sendStatusEvent(ctx, origEvtID, evt.ID, nil, deliveredTo) - if prevNotice := ms.popNoticeID(); prevNotice != "" { - _, _ = portal.MainIntent().RedactEvent(ctx, portal.MXID, prevNotice, mautrix.ReqRedact{ - Reason: "error resolved", - }) - } - } - if ms != nil { - log.Debug().Object("timings", ms.timings).Msg("Timings for event") - } -} - -type messageTimings struct { - initReceive time.Duration - decrypt time.Duration - implicitRR time.Duration - portalQueue time.Duration - totalReceive time.Duration - - preproc time.Duration - convert time.Duration - totalSend time.Duration -} - -func niceRound(dur time.Duration) time.Duration { - switch { - case dur < time.Millisecond: - return dur - case dur < time.Second: - return dur.Round(100 * time.Microsecond) - default: - return dur.Round(time.Millisecond) - } -} - -func (mt *messageTimings) MarshalZerologObject(evt *zerolog.Event) { - evt. - Dict("bridge", zerolog.Dict(). - Str("init_receive", niceRound(mt.initReceive).String()). - Str("decrypt", niceRound(mt.decrypt).String()). - Str("queue", niceRound(mt.portalQueue).String()). - Str("total_hs_to_portal", niceRound(mt.totalReceive).String())). - Dict("portal", zerolog.Dict(). - Str("implicit_rr", niceRound(mt.implicitRR).String()). - Str("preproc", niceRound(mt.preproc).String()). - Str("convert", niceRound(mt.convert).String()). - Str("total_send", niceRound(mt.totalSend).String())) -} - -type metricSender struct { - portal *Portal - previousNotice id.EventID - lock sync.Mutex - completed bool - retryNum int - timings *messageTimings - ctx context.Context -} - -func (ms *metricSender) getRetryNum() int { - if ms != nil { - return ms.retryNum - } - return 0 -} - -func (ms *metricSender) getNoticeID() id.EventID { - if ms == nil { - return "" - } - return ms.previousNotice -} - -func (ms *metricSender) popNoticeID() id.EventID { - if ms == nil { - return "" - } - evtID := ms.previousNotice - ms.previousNotice = "" - return evtID -} - -func (ms *metricSender) setNoticeID(evtID id.EventID) { - if ms != nil && ms.previousNotice == "" { - ms.previousNotice = evtID - } -} - -func (ms *metricSender) sendMessageMetrics(evt *event.Event, err error, part string, completed bool) { - ms.lock.Lock() - defer ms.lock.Unlock() - if !completed && ms.completed { - return - } - ms.portal.sendMessageMetrics(ms.ctx, evt, err, part, ms) - ms.retryNum++ - ms.completed = completed -} diff --git a/messagix/types/client.go b/messagix/types/client.go index 810dfc1..4c50dfe 100644 --- a/messagix/types/client.go +++ b/messagix/types/client.go @@ -1,5 +1,9 @@ package types +import ( + "fmt" +) + type Platform int const ( @@ -10,6 +14,60 @@ const ( FacebookTor ) +func PlatformFromString(s string) Platform { + switch s { + case "instagram": + return Instagram + case "facebook": + return Facebook + case "messenger": + return Messenger + case "facebook-tor": + return FacebookTor + default: + return Unset + } +} + +func (p *Platform) UnmarshalJSON(data []byte) error { + switch string(data) { + case `"instagram"`, `1`: + *p = Instagram + case `"facebook"`, `2`: + *p = Facebook + case `"messenger"`, `3`: + *p = Messenger + case `"facebook-tor"`, `4`: + *p = FacebookTor + default: + *p = Unset + } + return nil +} + +func (p Platform) String() string { + switch p { + case Instagram: + return "instagram" + case Facebook: + return "facebook" + case Messenger: + return "messenger" + case FacebookTor: + return "facebook-tor" + default: + return "" + } +} + +func (p Platform) MarshalJSON() ([]byte, error) { + return []byte(fmt.Sprintf(`"%s"`, p.String())), nil +} + func (p Platform) IsMessenger() bool { return p == Facebook || p == FacebookTor || p == Messenger } + +func (p Platform) IsValid() bool { + return p == Instagram || p == Facebook || p == FacebookTor || p == Messenger +} diff --git a/msgconv/from-matrix.go b/msgconv/from-matrix.go deleted file mode 100644 index fe070fb..0000000 --- a/msgconv/from-matrix.go +++ /dev/null @@ -1,165 +0,0 @@ -// mautrix-meta - A Matrix-Facebook Messenger and Instagram DM puppeting bridge. -// Copyright (C) 2024 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package msgconv - -import ( - "context" - "errors" - "fmt" - "net/http" - "time" - - "github.com/rs/zerolog" - "go.mau.fi/util/exerrors" - "go.mau.fi/util/exmime" - "go.mau.fi/util/ffmpeg" - "maunium.net/go/mautrix/event" - - "go.mau.fi/mautrix-meta/messagix" - "go.mau.fi/mautrix-meta/messagix/methods" - "go.mau.fi/mautrix-meta/messagix/socket" - "go.mau.fi/mautrix-meta/messagix/table" - "go.mau.fi/mautrix-meta/messagix/types" -) - -var ( - ErrUnsupportedMsgType = errors.New("unsupported msgtype") - ErrMediaDownloadFailed = errors.New("failed to download media") - ErrMediaDecryptFailed = errors.New("failed to decrypt media") - ErrMediaConvertFailed = errors.New("failed to convert") - ErrMediaUploadFailed = errors.New("failed to upload media") - ErrInvalidGeoURI = errors.New("invalid `geo:` URI in message") - ErrURLNotFound = errors.New("url not found") -) - -func (mc *MessageConverter) ToMeta(ctx context.Context, evt *event.Event, content *event.MessageEventContent, relaybotFormatted bool) ([]socket.Task, int64, error) { - if evt.Type == event.EventSticker { - content.MsgType = event.MsgImage - } - - task := &socket.SendMessageTask{ - ThreadId: mc.GetData(ctx).ThreadID, - Otid: methods.GenerateEpochId(), - Source: table.MESSENGER_INBOX_IN_THREAD, - InitiatingSource: table.FACEBOOK_INBOX, - SendType: table.TEXT, - SyncGroup: 1, - - ReplyMetaData: mc.GetMetaReply(ctx, content), - } - if content.MsgType == event.MsgEmote && !relaybotFormatted { - content.Body = "/me " + content.Body - if content.FormattedBody != "" { - content.FormattedBody = "/me " + content.FormattedBody - } - } - switch content.MsgType { - case event.MsgText, event.MsgNotice, event.MsgEmote: - task.Text = content.Body - case event.MsgImage, event.MsgVideo, event.MsgAudio, event.MsgFile: - resp, err := mc.reuploadFileToMeta(ctx, evt, content) - if err != nil { - return nil, 0, err - } - attachmentID := resp.Payload.RealMetadata.GetFbId() - if attachmentID == 0 { - zerolog.Ctx(ctx).Warn().RawJSON("response", resp.Raw).Msg("No fbid received for upload") - return nil, 0, fmt.Errorf("failed to upload attachment: fbid not received") - } - task.SendType = table.MEDIA - task.AttachmentFBIds = []int64{attachmentID} - if content.FileName != "" && content.Body != content.FileName { - // This might not actually be allowed - task.Text = content.Body - } - case event.MsgLocation: - // TODO implement - fallthrough - default: - return nil, 0, fmt.Errorf("%w %s", ErrUnsupportedMsgType, content.MsgType) - } - readTask := &socket.ThreadMarkReadTask{ - ThreadId: task.ThreadId, - SyncGroup: 1, - - LastReadWatermarkTs: time.Now().UnixMilli(), - } - return []socket.Task{task, readTask}, task.Otid, nil -} - -func (mc *MessageConverter) downloadMatrixMedia(ctx context.Context, content *event.MessageEventContent) (data []byte, mimeType, fileName string, err error) { - mxc := content.URL - if content.File != nil { - mxc = content.File.URL - } - data, err = mc.DownloadMatrixMedia(ctx, mxc) - if err != nil { - err = exerrors.NewDualError(ErrMediaDownloadFailed, err) - return - } - if content.File != nil { - err = content.File.DecryptInPlace(data) - if err != nil { - err = exerrors.NewDualError(ErrMediaDecryptFailed, err) - return - } - } - mimeType = content.GetInfo().MimeType - if mimeType == "" { - mimeType = http.DetectContentType(data) - } - fileName = content.FileName - if fileName == "" { - fileName = content.Body - if fileName == "" { - fileName = string(content.MsgType)[2:] + exmime.ExtensionFromMimetype(mimeType) - } - } - return -} - -func (mc *MessageConverter) reuploadFileToMeta(ctx context.Context, evt *event.Event, content *event.MessageEventContent) (*types.MercuryUploadResponse, error) { - threadID := mc.GetData(ctx).ThreadID - data, mimeType, fileName, err := mc.downloadMatrixMedia(ctx, content) - if err != nil { - return nil, err - } - _, isVoice := evt.Content.Raw["org.matrix.msc3245.voice"] - if isVoice { - data, err = ffmpeg.ConvertBytes(ctx, data, ".m4a", []string{}, []string{"-c:a", "aac"}, mimeType) - if err != nil { - return nil, err - } - mimeType = "audio/mp4" - fileName += ".m4a" - } - resp, err := mc.GetClient(ctx).SendMercuryUploadRequest(ctx, threadID, &messagix.MercuryUploadMedia{ - Filename: fileName, - MimeType: mimeType, - MediaData: data, - IsVoiceClip: isVoice, - }) - if err != nil { - zerolog.Ctx(ctx).Debug(). - Str("file_name", fileName). - Str("mime_type", mimeType). - Bool("is_voice_clip", isVoice). - Msg("Failed upload metadata") - return nil, fmt.Errorf("%w: %w", ErrMediaUploadFailed, err) - } - return resp, nil -} diff --git a/msgconv/from-meta.go b/msgconv/from-meta.go deleted file mode 100644 index b022675..0000000 --- a/msgconv/from-meta.go +++ /dev/null @@ -1,835 +0,0 @@ -// mautrix-meta - A Matrix-Facebook Messenger and Instagram DM puppeting bridge. -// Copyright (C) 2024 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package msgconv - -import ( - "bytes" - "context" - "errors" - "fmt" - "html" - "image" - _ "image/gif" - _ "image/jpeg" - _ "image/png" - "net/http" - "net/url" - "regexp" - "slices" - "strconv" - "strings" - - "github.com/rs/zerolog" - "go.mau.fi/util/exmime" - "go.mau.fi/util/ffmpeg" - "golang.org/x/exp/maps" - _ "golang.org/x/image/webp" - "maunium.net/go/mautrix/crypto/attachment" - "maunium.net/go/mautrix/event" - - "go.mau.fi/mautrix-meta/messagix/data/responses" - "go.mau.fi/mautrix-meta/messagix/socket" - "go.mau.fi/mautrix-meta/messagix/table" -) - -type ConvertedMessage struct { - Parts []*ConvertedMessagePart -} - -func (cm *ConvertedMessage) MergeCaption() { - if len(cm.Parts) != 2 || cm.Parts[1].Content.MsgType != event.MsgText { - return - } - switch cm.Parts[0].Content.MsgType { - case event.MsgImage, event.MsgVideo, event.MsgAudio, event.MsgFile: - default: - return - } - mediaContent := cm.Parts[0].Content - textContent := cm.Parts[1].Content - if mediaContent.FileName != "" && mediaContent.FileName != mediaContent.Body { - if textContent.FormattedBody != "" || mediaContent.FormattedBody != "" { - textContent.EnsureHasHTML() - mediaContent.EnsureHasHTML() - mediaContent.FormattedBody = fmt.Sprintf("%s

%s", mediaContent.FormattedBody, textContent.FormattedBody) - } - mediaContent.Body = fmt.Sprintf("%s\n\n%s", mediaContent.Body, textContent.Body) - } else { - mediaContent.FileName = mediaContent.Body - mediaContent.Body = textContent.Body - mediaContent.Format = textContent.Format - mediaContent.FormattedBody = textContent.FormattedBody - } - mediaContent.Mentions = textContent.Mentions - maps.Copy(cm.Parts[0].Extra, cm.Parts[1].Extra) - cm.Parts = cm.Parts[:1] -} - -type ConvertedMessagePart struct { - Type event.Type - Content *event.MessageEventContent - Extra map[string]any -} - -func isProbablyURLPreview(xma *table.WrappedXMA) bool { - return xma.CTA != nil && - xma.CTA.Type_ == "xma_web_url" && - xma.CTA.TargetId == 0 && - xma.CTA.NativeUrl == "" && - strings.Contains(xma.CTA.ActionUrl, "/l.php?") -} - -func (mc *MessageConverter) ToMatrix(ctx context.Context, msg *table.WrappedMessage) *ConvertedMessage { - cm := &ConvertedMessage{ - Parts: make([]*ConvertedMessagePart, 0), - } - if msg.IsUnsent { - return cm - } - for _, blobAtt := range msg.BlobAttachments { - cm.Parts = append(cm.Parts, mc.blobAttachmentToMatrix(ctx, blobAtt)) - } - for _, legacyAtt := range msg.Attachments { - cm.Parts = append(cm.Parts, mc.legacyAttachmentToMatrix(ctx, legacyAtt)) - } - var urlPreviews []*table.WrappedXMA - for _, xmaAtt := range msg.XMAAttachments { - if isProbablyURLPreview(xmaAtt) { - // URL previews are handled in the text section - urlPreviews = append(urlPreviews, xmaAtt) - continue - } else if xmaAtt.CTA != nil && strings.HasPrefix(xmaAtt.CTA.Type_, "xma_poll_") { - // Skip poll metadata entirely for now - continue - } - cm.Parts = append(cm.Parts, mc.xmaAttachmentToMatrix(ctx, xmaAtt)...) - } - for _, sticker := range msg.Stickers { - cm.Parts = append(cm.Parts, mc.stickerToMatrix(ctx, sticker)) - } - if msg.Text != "" || msg.ReplySnippet != "" || len(urlPreviews) > 0 { - mentions := &socket.MentionData{ - MentionIDs: msg.MentionIds, - MentionOffsets: msg.MentionOffsets, - MentionLengths: msg.MentionLengths, - MentionTypes: msg.MentionTypes, - } - content := mc.metaToMatrixText(ctx, msg.Text, mentions) - if msg.IsAdminMessage { - content.MsgType = event.MsgNotice - } - if len(urlPreviews) > 0 { - content.BeeperLinkPreviews = make([]*event.BeeperLinkPreview, len(urlPreviews)) - previewLinks := make([]string, len(urlPreviews)) - for i, preview := range urlPreviews { - content.BeeperLinkPreviews[i] = mc.urlPreviewToBeeper(ctx, preview) - previewLinks[i] = content.BeeperLinkPreviews[i].CanonicalURL - } - // TODO do more fancy detection of whether the link is in the body? - if len(content.Body) == 0 { - content.Body = strings.Join(previewLinks, "\n") - } - } - extra := make(map[string]any) - if msg.ReplySnippet != "" && len(msg.XMAAttachments) > 0 && len(msg.XMAAttachments) != len(urlPreviews) { - extra["com.beeper.relation_snippet"] = msg.ReplySnippet - // This is extremely hacky - isReaction := strings.Contains(msg.ReplySnippet, "Reacted") - if isReaction { - extra["com.beeper.raw_reaction"] = content.Body - } else if msg.Text != "" { - extra["com.beeper.raw_reply_text"] = content.Body - } - content.Body = strings.TrimSpace(fmt.Sprintf("%s\n\n%s", msg.ReplySnippet, content.Body)) - if content.FormattedBody != "" { - content.FormattedBody = strings.TrimSpace(fmt.Sprintf("%s\n\n%s", html.EscapeString(msg.ReplySnippet), content.FormattedBody)) - } - switch msg.ReplySourceTypeV2 { - case table.ReplySourceTypeIGStoryShare: - if isReaction { - extra["com.beeper.relation_preview_type"] = "story_reaction" - } else if msg.Text != "" { - extra["com.beeper.relation_preview_type"] = "story_reply" - } else { - extra["com.beeper.relation_preview_type"] = "story" - } - default: - } - } - cm.Parts = append(cm.Parts, &ConvertedMessagePart{ - Type: event.EventMessage, - Content: content, - Extra: extra, - }) - } - if len(cm.Parts) == 0 { - cm.Parts = append(cm.Parts, &ConvertedMessagePart{ - Type: event.EventMessage, - Content: &event.MessageEventContent{ - MsgType: event.MsgNotice, - Body: "Unsupported message", - }, - Extra: map[string]any{ - "fi.mau.unsupported": true, - }, - }) - } - replyTo, sender := mc.GetMatrixReply(ctx, msg.ReplySourceId, msg.ReplyToUserId) - for _, part := range cm.Parts { - _, hasExternalURL := part.Extra["external_url"] - unsupported, _ := part.Extra["fi.mau.unsupported"].(bool) - if unsupported && !hasExternalURL { - _, threadURL := mc.GetThreadURL(ctx) - if threadURL != "" { - part.Extra["external_url"] = threadURL - part.Content.EnsureHasHTML() - var protocolName string - switch { - case strings.HasPrefix(threadURL, "https://www.instagram.com"): - protocolName = "Instagram" - case strings.HasPrefix(threadURL, "https://www.facebook.com"): - protocolName = "Facebook" - case strings.HasPrefix(threadURL, "https://www.messenger.com"): - protocolName = "Messenger" - default: - protocolName = "native app" - } - part.Content.Body = fmt.Sprintf("%s\n\nOpen in %s: %s", part.Content.Body, protocolName, threadURL) - part.Content.FormattedBody = fmt.Sprintf("%s

Click here to open in %s", part.Content.FormattedBody, threadURL, protocolName) - } - } - if part.Content.Mentions == nil { - part.Content.Mentions = &event.Mentions{} - } - if replyTo != "" { - part.Content.RelatesTo = (&event.RelatesTo{}).SetReplyTo(replyTo) - if !slices.Contains(part.Content.Mentions.UserIDs, sender) { - part.Content.Mentions.UserIDs = append(part.Content.Mentions.UserIDs, sender) - } - } - } - return cm -} - -func errorToNotice(err error, attachmentContainerType string) *ConvertedMessagePart { - errMsg := "Failed to transfer attachment" - if errors.Is(err, ErrURLNotFound) { - errMsg = fmt.Sprintf("Unrecognized %s attachment type", attachmentContainerType) - } else if errors.Is(err, ErrTooLargeFile) { - errMsg = "Too large attachment" - } - return &ConvertedMessagePart{ - Type: event.EventMessage, - Content: &event.MessageEventContent{ - MsgType: event.MsgNotice, - Body: errMsg, - }, - Extra: map[string]any{ - "fi.mau.unsupported": true, - }, - } -} - -func (mc *MessageConverter) blobAttachmentToMatrix(ctx context.Context, att *table.LSInsertBlobAttachment) *ConvertedMessagePart { - url := att.PlayableUrl - mime := att.PlayableUrlMimeType - if mime == "" { - mime = att.AttachmentMimeType - } - duration := att.PlayableDurationMs - var width, height int64 - if url == "" { - url = att.PreviewUrl - mime = att.PreviewUrlMimeType - width, height = att.PreviewWidth, att.PreviewHeight - } - converted, err := mc.reuploadAttachment(ctx, att.AttachmentType, url, att.Filename, mime, int(width), int(height), int(duration)) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to transfer blob media") - return errorToNotice(err, "blob") - } - return converted -} - -func (mc *MessageConverter) legacyAttachmentToMatrix(ctx context.Context, att *table.LSInsertAttachment) *ConvertedMessagePart { - url := att.PlayableUrl - mime := att.PlayableUrlMimeType - if mime == "" { - mime = att.AttachmentMimeType - } - duration := att.PlayableDurationMs - var width, height int64 - if url == "" { - url = att.PreviewUrl - mime = att.PreviewUrlMimeType - width, height = att.PreviewWidth, att.PreviewHeight - } - converted, err := mc.reuploadAttachment(ctx, att.AttachmentType, url, att.Filename, mime, int(width), int(height), int(duration)) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to transfer media") - return errorToNotice(err, "generic") - } - return converted -} - -func (mc *MessageConverter) stickerToMatrix(ctx context.Context, att *table.LSInsertStickerAttachment) *ConvertedMessagePart { - url := att.PlayableUrl - mime := att.PlayableUrlMimeType - var width, height int64 - if url == "" { - url = att.PreviewUrl - mime = att.PreviewUrlMimeType - width, height = att.PreviewWidth, att.PreviewHeight - } - converted, err := mc.reuploadAttachment(ctx, table.AttachmentTypeSticker, url, att.AccessibilitySummaryText, mime, int(width), int(height), 0) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to transfer sticker media") - return errorToNotice(err, "sticker") - } - return converted -} - -func (mc *MessageConverter) instagramFetchedMediaToMatrix(ctx context.Context, att *table.WrappedXMA, resp *responses.Items) (*ConvertedMessagePart, error) { - var url, mime string - var width, height int - var found bool - for _, ver := range resp.VideoVersions { - if ver.Width*ver.Height > width*height { - url = ver.URL - width, height = ver.Width, ver.Height - found = true - } - } - if !found { - for _, ver := range resp.ImageVersions2.Candidates { - if ver.Width*ver.Height > width*height { - url = ver.URL - width, height = ver.Width, ver.Height - found = true - } - } - } - return mc.reuploadAttachment(ctx, att.AttachmentType, url, att.Filename, mime, width, height, int(resp.VideoDuration*1000)) -} - -func (mc *MessageConverter) xmaLocationToMatrix(ctx context.Context, att *table.WrappedXMA) *ConvertedMessagePart { - if att.CTA.NativeUrl == "" { - // This happens for live locations - // TODO figure out how to support them properly - return &ConvertedMessagePart{ - Type: event.EventMessage, - Content: &event.MessageEventContent{ - MsgType: event.MsgNotice, - Body: fmt.Sprintf("%s\n%s", att.TitleText, att.SubtitleText), - }, - } - } - return &ConvertedMessagePart{ - Type: event.EventMessage, - Content: &event.MessageEventContent{ - MsgType: event.MsgLocation, - GeoURI: fmt.Sprintf("geo:%s", att.CTA.NativeUrl), - Body: fmt.Sprintf("%s\n%s", att.TitleText, att.SubtitleText), - }, - } -} - -var reelActionURLRegex = regexp.MustCompile(`^/stories/direct/(\d+)_(\d+)$`) -var reelActionURLRegex2 = regexp.MustCompile(`^https://instagram\.com/stories/([a-z0-9.-_]{3,32})/(\d+)$`) -var usernameRegex = regexp.MustCompile(`^[a-z0-9.-_]{3,32}$`) - -func trimPostTitle(title string, maxLines int) string { - // For some reason Instagram gives maxLines 1 less than what they mean (i.e. what the official clients render) - maxLines++ - lines := strings.SplitN(title, "\n", maxLines+1) - if len(lines) > maxLines { - lines = lines[:maxLines] - lines[maxLines-1] += "…" - } - maxCharacters := maxLines * 50 - for i, line := range lines { - lineRunes := []rune(line) - if len(lineRunes) > maxCharacters { - lines[i] = string(lineRunes[:maxCharacters]) + "…" - lines = lines[:i+1] - break - } - maxCharacters -= len(lineRunes) - } - return strings.Join(lines, "\n") -} - -func removeLPHP(addr string) string { - parsed, _ := url.Parse(addr) - if parsed != nil && parsed.Path == "/l.php" { - return parsed.Query().Get("u") - } - return addr -} - -func addExternalURLCaption(content *event.MessageEventContent, externalURL string) { - if content.FileName == "" { - content.FileName = content.Body - content.Body = externalURL - content.Format = event.FormatHTML - content.FormattedBody = fmt.Sprintf(`%s`, externalURL, externalURL) - } else { - content.EnsureHasHTML() - content.Body = fmt.Sprintf("%s\n\n%s", content.Body, externalURL) - content.FormattedBody = fmt.Sprintf(`%s

%s`, content.FormattedBody, externalURL, externalURL) - } -} - -func (mc *MessageConverter) fetchFullXMA(ctx context.Context, att *table.WrappedXMA, minimalConverted *ConvertedMessagePart) *ConvertedMessagePart { - ig := mc.GetClient(ctx).Instagram - if att.CTA == nil || ig == nil { - minimalConverted.Extra["fi.mau.meta.xma_fetch_status"] = "unsupported" - return minimalConverted - } - log := zerolog.Ctx(ctx) - switch { - case strings.HasPrefix(att.CTA.NativeUrl, "instagram://media/?shortcode="), strings.HasPrefix(att.CTA.NativeUrl, "instagram://reels_share/?shortcode="): - actionURL, _ := url.Parse(removeLPHP(att.CTA.ActionUrl)) - var carouselChildMediaID string - if actionURL != nil { - carouselChildMediaID = actionURL.Query().Get("carousel_share_child_media_id") - } - - mediaShortcode := strings.TrimPrefix(att.CTA.NativeUrl, "instagram://media/?shortcode=") - mediaShortcode = strings.TrimPrefix(mediaShortcode, "instagram://reels_share/?shortcode=") - externalURL := fmt.Sprintf("https://www.instagram.com/p/%s/", mediaShortcode) - minimalConverted.Extra["external_url"] = externalURL - addExternalURLCaption(minimalConverted.Content, externalURL) - if !mc.ShouldFetchXMA(ctx) { - log.Debug().Msg("Not fetching XMA media") - minimalConverted.Extra["fi.mau.meta.xma_fetch_status"] = "skip" - return minimalConverted - } - - log.Trace().Any("cta_data", att.CTA).Msg("Fetching XMA media from CTA data") - resp, err := ig.FetchMedia(strconv.FormatInt(att.CTA.TargetId, 10), mediaShortcode) - if err != nil { - log.Err(err).Int64("target_id", att.CTA.TargetId).Msg("Failed to fetch XMA media") - minimalConverted.Extra["fi.mau.meta.xma_fetch_status"] = "fetch fail" - return minimalConverted - } else if len(resp.Items) == 0 { - log.Warn().Int64("target_id", att.CTA.TargetId).Msg("Got empty XMA media response") - minimalConverted.Extra["fi.mau.meta.xma_fetch_status"] = "empty response" - return minimalConverted - } else { - log.Trace().Int64("target_id", att.CTA.TargetId).Any("response", resp).Msg("Fetched XMA media") - log.Debug().Msg("Fetched XMA media") - targetItem := resp.Items[0] - if targetItem.CarouselMedia != nil && carouselChildMediaID != "" { - for _, subitem := range targetItem.CarouselMedia { - if subitem.ID == carouselChildMediaID { - targetItem = subitem - break - } - } - } - secondConverted, err := mc.instagramFetchedMediaToMatrix(ctx, att, targetItem) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to transfer fetched media") - minimalConverted.Extra["fi.mau.meta.xma_fetch_status"] = "reupload fail" - return minimalConverted - } - secondConverted.Content.Info.ThumbnailInfo = minimalConverted.Content.Info - secondConverted.Content.Info.ThumbnailURL = minimalConverted.Content.URL - secondConverted.Content.Info.ThumbnailFile = minimalConverted.Content.File - secondConverted.Extra["com.beeper.instagram_item_username"] = targetItem.User.Username - if externalURL != "" { - secondConverted.Extra["external_url"] = externalURL - } - secondConverted.Extra["fi.mau.meta.xma_fetch_status"] = "success" - return secondConverted - } - case strings.HasPrefix(att.CTA.ActionUrl, "/stories/direct/"): - log.Trace().Any("cta_data", att.CTA).Msg("Fetching XMA story from CTA data") - externalURL := fmt.Sprintf("https://www.instagram.com%s", att.CTA.ActionUrl) - match := reelActionURLRegex.FindStringSubmatch(att.CTA.ActionUrl) - if usernameRegex.MatchString(att.HeaderTitle) && len(match) == 3 { - // Very hacky way to hopefully fix the URL to work on mobile. - // When fetching the XMA data, this is done again later in a safer way. - externalURL = fmt.Sprintf("https://www.instagram.com/stories/%s/%s/", att.HeaderTitle, match[1]) - } - minimalConverted.Extra["external_url"] = externalURL - addExternalURLCaption(minimalConverted.Content, externalURL) - if !mc.ShouldFetchXMA(ctx) { - log.Debug().Msg("Not fetching XMA media") - minimalConverted.Extra["fi.mau.meta.xma_fetch_status"] = "skip" - return minimalConverted - } - - if len(match) != 3 { - log.Warn().Str("action_url", att.CTA.ActionUrl).Msg("Failed to parse story action URL") - minimalConverted.Extra["fi.mau.meta.xma_fetch_status"] = "parse fail" - return minimalConverted - } else if resp, err := ig.FetchReel([]string{match[2]}, match[1]); err != nil { - log.Err(err).Str("action_url", att.CTA.ActionUrl).Msg("Failed to fetch XMA story") - minimalConverted.Extra["fi.mau.meta.xma_fetch_status"] = "fetch fail" - return minimalConverted - } else if reel, ok := resp.Reels[match[2]]; !ok { - log.Trace(). - Str("action_url", att.CTA.ActionUrl). - Any("response", resp). - Msg("XMA story fetch data") - log.Warn(). - Str("action_url", att.CTA.ActionUrl). - Str("reel_id", match[2]). - Str("media_id", match[1]). - Str("response_status", resp.Status). - Msg("Got empty XMA story response") - minimalConverted.Extra["fi.mau.meta.xma_fetch_status"] = "empty response" - return minimalConverted - } else { - log.Trace(). - Str("action_url", att.CTA.ActionUrl). - Str("reel_id", match[2]). - Str("media_id", match[1]). - Any("response", resp). - Msg("Fetched XMA story") - minimalConverted.Extra["com.beeper.instagram_item_username"] = reel.User.Username - // Update external URL to use username so it works on mobile - externalURL = fmt.Sprintf("https://www.instagram.com/stories/%s/%s/", reel.User.Username, match[1]) - minimalConverted.Extra["external_url"] = externalURL - var relevantItem *responses.Items - foundIDs := make([]string, len(reel.Items)) - for i, item := range reel.Items { - foundIDs[i] = item.Pk - if item.Pk == match[1] { - relevantItem = &item.Items - } - } - if relevantItem == nil { - log.Warn(). - Str("action_url", att.CTA.ActionUrl). - Str("reel_id", match[2]). - Str("media_id", match[1]). - Strs("found_ids", foundIDs). - Msg("Failed to find exact item in fetched XMA story") - minimalConverted.Extra["fi.mau.meta.xma_fetch_status"] = "item not found in response" - return minimalConverted - } - log.Debug().Msg("Fetched XMA story and found exact item") - secondConverted, err := mc.instagramFetchedMediaToMatrix(ctx, att, relevantItem) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to transfer fetched media") - minimalConverted.Extra["fi.mau.meta.xma_fetch_status"] = "reupload fail" - return minimalConverted - } - secondConverted.Content.Info.ThumbnailInfo = minimalConverted.Content.Info - secondConverted.Content.Info.ThumbnailURL = minimalConverted.Content.URL - secondConverted.Content.Info.ThumbnailFile = minimalConverted.Content.File - secondConverted.Extra["com.beeper.instagram_item_username"] = reel.User.Username - if externalURL != "" { - secondConverted.Extra["external_url"] = externalURL - } - secondConverted.Extra["fi.mau.meta.xma_fetch_status"] = "success" - return secondConverted - } - //case strings.HasPrefix(att.CTA.ActionUrl, "/stories/archive/"): - // TODO can these be handled? - case strings.HasPrefix(att.CTA.ActionUrl, "https://instagram.com/stories/"): - log.Trace().Any("cta_data", att.CTA).Msg("Fetching second type of XMA story from CTA data") - externalURL := att.CTA.ActionUrl - minimalConverted.Extra["external_url"] = externalURL - addExternalURLCaption(minimalConverted.Content, externalURL) - if !mc.ShouldFetchXMA(ctx) { - log.Debug().Msg("Not fetching XMA media") - minimalConverted.Extra["fi.mau.meta.xma_fetch_status"] = "skip" - return minimalConverted - } - - if match := reelActionURLRegex2.FindStringSubmatch(att.CTA.ActionUrl); len(match) != 3 { - log.Warn().Str("action_url", att.CTA.ActionUrl).Msg("Failed to parse story action URL (type 2)") - minimalConverted.Extra["fi.mau.meta.xma_fetch_status"] = "parse fail" - return minimalConverted - } else if resp, err := ig.FetchMedia(match[2], ""); err != nil { - log.Err(err).Str("action_url", att.CTA.ActionUrl).Msg("Failed to fetch XMA story (type 2)") - minimalConverted.Extra["fi.mau.meta.xma_fetch_status"] = "fetch fail" - return minimalConverted - } else if len(resp.Items) == 0 { - log.Trace(). - Str("action_url", att.CTA.ActionUrl). - Any("response", resp). - Msg("XMA story fetch data") - log.Warn(). - Str("action_url", att.CTA.ActionUrl). - Str("reel_id", match[2]). - Str("media_id", match[1]). - Str("response_status", resp.Status). - Msg("Got empty XMA story response (type 2)") - minimalConverted.Extra["fi.mau.meta.xma_fetch_status"] = "empty response" - return minimalConverted - } else { - relevantItem := resp.Items[0] - log.Trace(). - Str("action_url", att.CTA.ActionUrl). - Str("reel_id", match[2]). - Str("media_id", match[1]). - Any("response", resp). - Msg("Fetched XMA story (type 2)") - minimalConverted.Extra["com.beeper.instagram_item_username"] = relevantItem.User.Username - log.Debug().Int("item_count", len(resp.Items)).Msg("Fetched XMA story (type 2)") - secondConverted, err := mc.instagramFetchedMediaToMatrix(ctx, att, relevantItem) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to transfer fetched media") - minimalConverted.Extra["fi.mau.meta.xma_fetch_status"] = "reupload fail" - return minimalConverted - } - secondConverted.Content.Info.ThumbnailInfo = minimalConverted.Content.Info - secondConverted.Content.Info.ThumbnailURL = minimalConverted.Content.URL - secondConverted.Content.Info.ThumbnailFile = minimalConverted.Content.File - secondConverted.Extra["com.beeper.instagram_item_username"] = relevantItem.User.Username - if externalURL != "" { - secondConverted.Extra["external_url"] = externalURL - } - secondConverted.Extra["fi.mau.meta.xma_fetch_status"] = "success" - return secondConverted - } - default: - log.Debug(). - Any("cta_data", att.CTA). - Any("xma_data", att.LSInsertXmaAttachment). - Msg("Unrecognized CTA data") - minimalConverted.Extra["fi.mau.meta.xma_fetch_status"] = "unrecognized" - return minimalConverted - } -} - -var instagramProfileURLRegex = regexp.MustCompile(`^https://www.instagram.com/([a-z0-9._]{1,30})$`) - -func (mc *MessageConverter) xmaProfileShareToMatrix(ctx context.Context, att *table.WrappedXMA) *ConvertedMessagePart { - if att.CTA == nil || att.HeaderSubtitleText == "" || att.HeaderImageUrl == "" || att.PlayableUrl != "" { - return nil - } - match := instagramProfileURLRegex.FindStringSubmatch(att.CTA.NativeUrl) - if len(match) != 2 || match[1] != att.HeaderTitle { - return nil - } - return &ConvertedMessagePart{ - Type: event.EventMessage, - Content: &event.MessageEventContent{ - MsgType: event.MsgText, - Format: event.FormatHTML, - Body: fmt.Sprintf("Shared %s's profile: %s", att.HeaderSubtitleText, att.CTA.NativeUrl), - FormattedBody: fmt.Sprintf(`Shared %s's profile: @%s`, att.HeaderSubtitleText, att.CTA.NativeUrl, match[1]), - }, - Extra: map[string]any{ - "external_url": att.CTA.NativeUrl, - }, - } -} - -func (mc *MessageConverter) urlPreviewToBeeper(ctx context.Context, att *table.WrappedXMA) *event.BeeperLinkPreview { - preview := &event.BeeperLinkPreview{ - LinkPreview: event.LinkPreview{ - CanonicalURL: removeLPHP(att.CTA.ActionUrl), - Title: att.TitleText, - Description: att.SubtitleText, - }, - } - if att.PreviewUrl != "" { - converted, err := mc.reuploadAttachment(ctx, att.AttachmentType, att.PreviewUrl, "preview", att.PreviewUrlMimeType, int(att.PreviewWidth), int(att.PreviewHeight), 0) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to reupload URL preview image") - } else { - preview.ImageEncryption = converted.Content.File - preview.ImageURL = converted.Content.URL - preview.ImageWidth = converted.Content.Info.Width - preview.ImageHeight = converted.Content.Info.Height - preview.ImageSize = converted.Content.Info.Size - } - } - return preview -} - -func (mc *MessageConverter) xmaAttachmentToMatrix(ctx context.Context, att *table.WrappedXMA) []*ConvertedMessagePart { - if att.CTA != nil && att.CTA.Type_ == "xma_live_location_sharing" { - return []*ConvertedMessagePart{mc.xmaLocationToMatrix(ctx, att)} - } else if profileShare := mc.xmaProfileShareToMatrix(ctx, att); profileShare != nil { - return []*ConvertedMessagePart{profileShare} - } - url := att.PlayableUrl - mime := att.PlayableUrlMimeType - var width, height int64 - if url == "" { - url = att.PreviewUrl - mime = att.PreviewUrlMimeType - width, height = att.PreviewWidth, att.PreviewHeight - } - // Slightly hacky hack to make reuploadAttachment add gif metadata if the shouldAutoplayVideo flag is set. - // No idea why Instagram has two different ways of flagging gifs. - if att.ShouldAutoplayVideo { - att.AttachmentType = table.AttachmentTypeAnimatedImage - } - converted, err := mc.reuploadAttachment(ctx, att.AttachmentType, url, att.Filename, mime, int(width), int(height), 0) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to transfer XMA media") - converted = errorToNotice(err, "XMA") - } else { - converted = mc.fetchFullXMA(ctx, att, converted) - } - _, hasExternalURL := converted.Extra["external_url"] - if !hasExternalURL && att.CTA != nil && att.CTA.ActionUrl != "" { - externalURL := removeLPHP(att.CTA.ActionUrl) - converted.Extra["external_url"] = externalURL - addExternalURLCaption(converted.Content, externalURL) - } - parts := []*ConvertedMessagePart{converted} - if att.TitleText != "" || att.CaptionBodyText != "" { - captionContent := &event.MessageEventContent{ - MsgType: event.MsgText, - } - if att.TitleText != "" { - captionContent.Body = trimPostTitle(att.TitleText, int(att.MaxTitleNumOfLines)) - } - if att.CaptionBodyText != "" { - if captionContent.Body != "" { - captionContent.Body += "\n\n" - } - captionContent.Body += att.CaptionBodyText - } - parts = append(parts, &ConvertedMessagePart{ - Type: event.EventMessage, - Content: captionContent, - Extra: map[string]any{ - "com.beeper.meta.full_post_title": att.TitleText, - }, - }) - } - return parts -} - -func (mc *MessageConverter) uploadAttachment(ctx context.Context, data []byte, fileName, mimeType string) (*event.MessageEventContent, error) { - var file *event.EncryptedFileInfo - uploadMime := mimeType - uploadFileName := fileName - if mc.GetData(ctx).Encrypted { - file = &event.EncryptedFileInfo{ - EncryptedFile: *attachment.NewEncryptedFile(), - URL: "", - } - file.EncryptInPlace(data) - uploadMime = "application/octet-stream" - uploadFileName = "" - } - mxc, err := mc.UploadMatrixMedia(ctx, data, uploadFileName, uploadMime) - if err != nil { - return nil, err - } - content := &event.MessageEventContent{ - Body: fileName, - Info: &event.FileInfo{ - MimeType: mimeType, - Size: len(data), - }, - } - if file != nil { - file.URL = mxc - content.File = file - } else { - content.URL = mxc - } - return content, nil -} - -func (mc *MessageConverter) reuploadAttachment( - ctx context.Context, attachmentType table.AttachmentType, - url, fileName, mimeType string, - width, height, duration int, -) (*ConvertedMessagePart, error) { - if url == "" { - return nil, ErrURLNotFound - } - data, err := DownloadMedia(ctx, mimeType, url, mc.MaxFileSize) - if err != nil { - return nil, fmt.Errorf("failed to download attachment: %w", err) - } - if mimeType == "" { - mimeType = http.DetectContentType(data) - } - extra := map[string]any{} - if attachmentType == table.AttachmentTypeAudio && mc.ConvertVoiceMessages && ffmpeg.Supported() { - data, err = ffmpeg.ConvertBytes(ctx, data, ".ogg", []string{}, []string{"-c:a", "libopus"}, mimeType) - if err != nil { - return nil, fmt.Errorf("failed to convert audio to ogg/opus: %w", err) - } - fileName += ".ogg" - mimeType = "audio/ogg" - extra["org.matrix.msc3245.voice"] = map[string]any{} - extra["org.matrix.msc1767.audio"] = map[string]any{ - "duration": duration, - } - } - if (attachmentType == table.AttachmentTypeImage || attachmentType == table.AttachmentTypeEphemeralImage) && (width == 0 || height == 0) { - config, _, err := image.DecodeConfig(bytes.NewReader(data)) - if err == nil { - width, height = config.Width, config.Height - } - } - content, err := mc.uploadAttachment(ctx, data, fileName, mimeType) - if err != nil { - return nil, err - } - content.Info.Duration = duration - content.Info.Width = width - content.Info.Height = height - - if attachmentType == table.AttachmentTypeAnimatedImage && mimeType == "video/mp4" { - extra["info"] = map[string]any{ - "fi.mau.gif": true, - "fi.mau.loop": true, - "fi.mau.autoplay": true, - "fi.mau.hide_controls": true, - "fi.mau.no_audio": true, - } - } - eventType := event.EventMessage - switch attachmentType { - case table.AttachmentTypeSticker: - eventType = event.EventSticker - case table.AttachmentTypeImage, table.AttachmentTypeEphemeralImage: - content.MsgType = event.MsgImage - case table.AttachmentTypeVideo, table.AttachmentTypeEphemeralVideo: - content.MsgType = event.MsgVideo - case table.AttachmentTypeFile: - content.MsgType = event.MsgFile - case table.AttachmentTypeAudio: - content.MsgType = event.MsgAudio - default: - switch strings.Split(mimeType, "/")[0] { - case "image": - content.MsgType = event.MsgImage - case "video": - content.MsgType = event.MsgVideo - case "audio": - content.MsgType = event.MsgAudio - default: - content.MsgType = event.MsgFile - } - } - if content.Body == "" { - content.Body = strings.TrimPrefix(string(content.MsgType), "m.") + exmime.ExtensionFromMimetype(mimeType) - } - return &ConvertedMessagePart{ - Type: eventType, - Content: content, - Extra: extra, - }, nil -} diff --git a/msgconv/from-whatsapp.go b/msgconv/from-whatsapp.go deleted file mode 100644 index 7c2505e..0000000 --- a/msgconv/from-whatsapp.go +++ /dev/null @@ -1,464 +0,0 @@ -// mautrix-meta - A Matrix-Facebook Messenger and Instagram DM puppeting bridge. -// Copyright (C) 2024 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package msgconv - -import ( - "context" - "fmt" - _ "image/gif" - _ "image/jpeg" - _ "image/png" - "slices" - "strings" - - "github.com/rs/zerolog" - "go.mau.fi/util/exmime" - "go.mau.fi/util/ffmpeg" - "go.mau.fi/whatsmeow" - "go.mau.fi/whatsmeow/proto/waArmadilloApplication" - "go.mau.fi/whatsmeow/proto/waArmadilloXMA" - "go.mau.fi/whatsmeow/proto/waCommon" - "go.mau.fi/whatsmeow/proto/waConsumerApplication" - "go.mau.fi/whatsmeow/proto/waMediaTransport" - "go.mau.fi/whatsmeow/types" - "go.mau.fi/whatsmeow/types/events" - _ "golang.org/x/image/webp" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" - - "go.mau.fi/mautrix-meta/config" -) - -func (mc *MessageConverter) WhatsAppTextToMatrix(ctx context.Context, text *waCommon.MessageText) *ConvertedMessagePart { - content := &event.MessageEventContent{ - MsgType: event.MsgText, - Body: text.GetText(), - Mentions: &event.Mentions{}, - } - silent := false - if len(text.Commands) > 0 { - for _, cmd := range text.Commands { - switch cmd.GetCommandType() { - case waCommon.Command_SILENT: - silent = true - content.Mentions.Room = false - case waCommon.Command_EVERYONE: - if !silent { - content.Mentions.Room = true - } - case waCommon.Command_AI: - // TODO ??? - } - } - } - if len(text.GetMentionedJID()) > 0 { - content.Format = event.FormatHTML - content.FormattedBody = event.TextToHTML(content.Body) - for _, jid := range text.GetMentionedJID() { - parsed, err := types.ParseJID(jid) - if err != nil { - zerolog.Ctx(ctx).Err(err).Str("jid", jid).Msg("Failed to parse mentioned JID") - continue - } - mxid := mc.GetUserMXID(ctx, int64(parsed.UserInt())) - if !silent { - content.Mentions.UserIDs = append(content.Mentions.UserIDs, mxid) - } - mentionText := "@" + jid - content.Body = strings.ReplaceAll(content.Body, mentionText, mxid.String()) - content.FormattedBody = strings.ReplaceAll(content.FormattedBody, mentionText, fmt.Sprintf(`%s`, mxid.URI().MatrixToURL(), mxid.String())) - } - } - return &ConvertedMessagePart{ - Type: event.EventMessage, - Content: content, - } -} - -type MediaTransportContainer interface { - GetTransport() *waMediaTransport.WAMediaTransport -} - -type AttachmentTransport[Integral MediaTransportContainer, Ancillary any] interface { - GetIntegral() Integral - GetAncillary() Ancillary -} - -type AttachmentMessage[Integral MediaTransportContainer, Ancillary any, Transport AttachmentTransport[Integral, Ancillary]] interface { - Decode() (Transport, error) -} - -type AttachmentMessageWithCaption[Integral MediaTransportContainer, Ancillary any, Transport AttachmentTransport[Integral, Ancillary]] interface { - GetCaption() *waCommon.MessageText -} - -type convertFunc func(ctx context.Context, data []byte, mimeType string) ([]byte, string, string, error) - -func convertWhatsAppAttachment[ - Transport AttachmentTransport[Integral, Ancillary], - Integral MediaTransportContainer, - Ancillary any, -]( - ctx context.Context, - mc *MessageConverter, - msg AttachmentMessage[Integral, Ancillary, Transport], - mediaType whatsmeow.MediaType, - convert convertFunc, -) (metadata Ancillary, media, caption *ConvertedMessagePart, err error) { - var typedTransport Transport - typedTransport, err = msg.Decode() - if err != nil { - return - } - msgWithCaption, ok := msg.(AttachmentMessageWithCaption[Integral, Ancillary, Transport]) - if ok && len(msgWithCaption.GetCaption().GetText()) > 0 { - caption = mc.WhatsAppTextToMatrix(ctx, msgWithCaption.GetCaption()) - caption.Content.MsgType = event.MsgText - } - metadata = typedTransport.GetAncillary() - transport := typedTransport.GetIntegral().GetTransport() - media, err = mc.reuploadWhatsAppAttachment(ctx, transport, mediaType, convert) - return -} - -func (mc *MessageConverter) reuploadWhatsAppAttachment( - ctx context.Context, - transport *waMediaTransport.WAMediaTransport, - mediaType whatsmeow.MediaType, - convert convertFunc, -) (*ConvertedMessagePart, error) { - data, err := mc.GetE2EEClient(ctx).DownloadFB(transport.GetIntegral(), mediaType) - if err != nil { - return nil, fmt.Errorf("%w: %w", ErrMediaDownloadFailed, err) - } - var fileName string - mimeType := transport.GetAncillary().GetMimetype() - if convert != nil { - data, mimeType, fileName, err = convert(ctx, data, mimeType) - if err != nil { - return nil, err - } - } - content, err := mc.uploadAttachment(ctx, data, fileName, mimeType) - if err != nil { - return nil, fmt.Errorf("%w: %w", ErrMediaUploadFailed, err) - } - return &ConvertedMessagePart{ - Type: event.EventMessage, - Content: content, - Extra: make(map[string]any), - }, nil -} - -func (mc *MessageConverter) convertWhatsAppImage(ctx context.Context, image *waConsumerApplication.ConsumerApplication_ImageMessage) (converted, caption *ConvertedMessagePart, err error) { - metadata, converted, caption, err := convertWhatsAppAttachment[*waMediaTransport.ImageTransport](ctx, mc, image, whatsmeow.MediaImage, func(ctx context.Context, data []byte, mimeType string) ([]byte, string, string, error) { - fileName := "image" + exmime.ExtensionFromMimetype(mimeType) - return data, mimeType, fileName, nil - }) - if converted != nil { - converted.Content.MsgType = event.MsgImage - converted.Content.Info.Width = int(metadata.GetWidth()) - converted.Content.Info.Height = int(metadata.GetHeight()) - } - return -} - -func (mc *MessageConverter) convertWhatsAppSticker(ctx context.Context, sticker *waConsumerApplication.ConsumerApplication_StickerMessage) (converted, caption *ConvertedMessagePart, err error) { - metadata, converted, caption, err := convertWhatsAppAttachment[*waMediaTransport.StickerTransport](ctx, mc, sticker, whatsmeow.MediaImage, func(ctx context.Context, data []byte, mimeType string) ([]byte, string, string, error) { - fileName := "sticker" + exmime.ExtensionFromMimetype(mimeType) - return data, mimeType, fileName, nil - }) - if converted != nil { - converted.Type = event.EventSticker - converted.Content.Info.Width = int(metadata.GetWidth()) - converted.Content.Info.Height = int(metadata.GetHeight()) - } - return -} - -func (mc *MessageConverter) convertWhatsAppDocument(ctx context.Context, document *waConsumerApplication.ConsumerApplication_DocumentMessage) (converted, caption *ConvertedMessagePart, err error) { - _, converted, caption, err = convertWhatsAppAttachment[*waMediaTransport.DocumentTransport](ctx, mc, document, whatsmeow.MediaDocument, func(ctx context.Context, data []byte, mimeType string) ([]byte, string, string, error) { - fileName := document.GetFileName() - if fileName == "" { - fileName = "file" + exmime.ExtensionFromMimetype(mimeType) - } - return data, mimeType, fileName, nil - }) - if converted != nil { - converted.Content.MsgType = event.MsgFile - } - return -} - -func (mc *MessageConverter) convertWhatsAppAudio(ctx context.Context, audio *waConsumerApplication.ConsumerApplication_AudioMessage) (converted, caption *ConvertedMessagePart, err error) { - // Treat all audio messages as voice messages, official clients don't set the flag for some reason - isVoiceMessage := true // audio.GetPTT() - metadata, converted, caption, err := convertWhatsAppAttachment[*waMediaTransport.AudioTransport](ctx, mc, audio, whatsmeow.MediaAudio, func(ctx context.Context, data []byte, mimeType string) ([]byte, string, string, error) { - fileName := "audio" + exmime.ExtensionFromMimetype(mimeType) - if isVoiceMessage && !strings.HasPrefix(mimeType, "audio/ogg") { - data, err = ffmpeg.ConvertBytes(ctx, data, ".ogg", []string{}, []string{"-c:a", "libopus"}, mimeType) - if err != nil { - return data, mimeType, fileName, fmt.Errorf("%w audio to ogg/opus: %w", ErrMediaConvertFailed, err) - } - fileName += ".ogg" - mimeType = "audio/ogg" - } - return data, mimeType, fileName, nil - }) - if converted != nil { - converted.Content.MsgType = event.MsgAudio - converted.Content.Info.Duration = int(metadata.GetSeconds() * 1000) - if isVoiceMessage { - converted.Extra["org.matrix.msc3245.voice"] = map[string]any{} - converted.Extra["org.matrix.msc1767.audio"] = map[string]any{ - "duration": converted.Content.Info.Duration, - } - } - } - return -} - -func (mc *MessageConverter) convertWhatsAppVideo(ctx context.Context, video *waConsumerApplication.ConsumerApplication_VideoMessage) (converted, caption *ConvertedMessagePart, err error) { - metadata, converted, caption, err := convertWhatsAppAttachment[*waMediaTransport.VideoTransport](ctx, mc, video, whatsmeow.MediaVideo, func(ctx context.Context, data []byte, mimeType string) ([]byte, string, string, error) { - fileName := "video" + exmime.ExtensionFromMimetype(mimeType) - return data, mimeType, fileName, nil - }) - if converted != nil { - converted.Content.MsgType = event.MsgVideo - converted.Content.Info.Width = int(metadata.GetWidth()) - converted.Content.Info.Height = int(metadata.GetHeight()) - converted.Content.Info.Duration = int(metadata.GetSeconds() * 1000) - // FB is annoying and sends images in video containers sometimes - if converted.Content.Info.MimeType == "image/gif" { - converted.Content.MsgType = event.MsgImage - } else if metadata.GetGifPlayback() { - converted.Extra["info"] = map[string]any{ - "fi.mau.gif": true, - "fi.mau.loop": true, - "fi.mau.autoplay": true, - "fi.mau.hide_controls": true, - "fi.mau.no_audio": true, - } - } - } - return -} - -func (mc *MessageConverter) convertWhatsAppMedia(ctx context.Context, rawContent *waConsumerApplication.ConsumerApplication_Content) (converted, caption *ConvertedMessagePart, err error) { - switch content := rawContent.GetContent().(type) { - case *waConsumerApplication.ConsumerApplication_Content_ImageMessage: - return mc.convertWhatsAppImage(ctx, content.ImageMessage) - case *waConsumerApplication.ConsumerApplication_Content_StickerMessage: - return mc.convertWhatsAppSticker(ctx, content.StickerMessage) - case *waConsumerApplication.ConsumerApplication_Content_ViewOnceMessage: - switch realContent := content.ViewOnceMessage.GetViewOnceContent().(type) { - case *waConsumerApplication.ConsumerApplication_ViewOnceMessage_ImageMessage: - return mc.convertWhatsAppImage(ctx, realContent.ImageMessage) - case *waConsumerApplication.ConsumerApplication_ViewOnceMessage_VideoMessage: - return mc.convertWhatsAppVideo(ctx, realContent.VideoMessage) - default: - return nil, nil, fmt.Errorf("unrecognized view once message type %T", realContent) - } - case *waConsumerApplication.ConsumerApplication_Content_DocumentMessage: - return mc.convertWhatsAppDocument(ctx, content.DocumentMessage) - case *waConsumerApplication.ConsumerApplication_Content_AudioMessage: - return mc.convertWhatsAppAudio(ctx, content.AudioMessage) - case *waConsumerApplication.ConsumerApplication_Content_VideoMessage: - return mc.convertWhatsAppVideo(ctx, content.VideoMessage) - default: - return nil, nil, fmt.Errorf("unrecognized media message type %T", content) - } -} - -func (mc *MessageConverter) appName() string { - if mc.BridgeMode == config.ModeInstagram { - return "Instagram app" - } else { - return "Messenger app" - } -} - -func (mc *MessageConverter) waConsumerToMatrix(ctx context.Context, rawContent *waConsumerApplication.ConsumerApplication_Content) (parts []*ConvertedMessagePart) { - parts = make([]*ConvertedMessagePart, 0, 2) - switch content := rawContent.GetContent().(type) { - case *waConsumerApplication.ConsumerApplication_Content_MessageText: - parts = append(parts, mc.WhatsAppTextToMatrix(ctx, content.MessageText)) - case *waConsumerApplication.ConsumerApplication_Content_ExtendedTextMessage: - part := mc.WhatsAppTextToMatrix(ctx, content.ExtendedTextMessage.GetText()) - // TODO convert url previews - parts = append(parts, part) - case *waConsumerApplication.ConsumerApplication_Content_ImageMessage, - *waConsumerApplication.ConsumerApplication_Content_StickerMessage, - *waConsumerApplication.ConsumerApplication_Content_ViewOnceMessage, - *waConsumerApplication.ConsumerApplication_Content_DocumentMessage, - *waConsumerApplication.ConsumerApplication_Content_AudioMessage, - *waConsumerApplication.ConsumerApplication_Content_VideoMessage: - converted, caption, err := mc.convertWhatsAppMedia(ctx, rawContent) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to convert media message") - converted = &ConvertedMessagePart{ - Type: event.EventMessage, - Content: &event.MessageEventContent{ - MsgType: event.MsgNotice, - Body: "Failed to transfer media", - }, - } - } - parts = append(parts, converted) - if caption != nil { - parts = append(parts, caption) - } - case *waConsumerApplication.ConsumerApplication_Content_LocationMessage: - parts = append(parts, &ConvertedMessagePart{ - Type: event.EventMessage, - Content: &event.MessageEventContent{ - MsgType: event.MsgLocation, - Body: content.LocationMessage.GetLocation().GetName() + "\n" + content.LocationMessage.GetAddress(), - GeoURI: fmt.Sprintf("geo:%f,%f", content.LocationMessage.GetLocation().GetDegreesLatitude(), content.LocationMessage.GetLocation().GetDegreesLongitude()), - }, - }) - case *waConsumerApplication.ConsumerApplication_Content_LiveLocationMessage: - parts = append(parts, &ConvertedMessagePart{ - Type: event.EventMessage, - Content: &event.MessageEventContent{ - MsgType: event.MsgLocation, - Body: "Live location sharing started\n\nYou can see the location in the " + mc.appName(), - GeoURI: fmt.Sprintf("geo:%f,%f", content.LiveLocationMessage.GetLocation().GetDegreesLatitude(), content.LiveLocationMessage.GetLocation().GetDegreesLongitude()), - }, - }) - case *waConsumerApplication.ConsumerApplication_Content_ContactMessage: - parts = append(parts, &ConvertedMessagePart{ - Type: event.EventMessage, - Content: &event.MessageEventContent{ - MsgType: event.MsgNotice, - Body: "Unsupported message (contact)\n\nPlease open in " + mc.appName(), - }, - }) - case *waConsumerApplication.ConsumerApplication_Content_ContactsArrayMessage: - parts = append(parts, &ConvertedMessagePart{ - Type: event.EventMessage, - Content: &event.MessageEventContent{ - MsgType: event.MsgNotice, - Body: "Unsupported message (contacts array)\n\nPlease open in " + mc.appName(), - }, - }) - default: - zerolog.Ctx(ctx).Warn().Type("content_type", content).Msg("Unrecognized content type") - parts = append(parts, &ConvertedMessagePart{ - Type: event.EventMessage, - Content: &event.MessageEventContent{ - MsgType: event.MsgNotice, - Body: fmt.Sprintf("Unsupported message (%T)\n\nPlease open in %s", content, mc.appName()), - }, - }) - } - return -} - -func (mc *MessageConverter) waExtendedContentMessageToMatrix(ctx context.Context, content *waArmadilloXMA.ExtendedContentMessage) (parts []*ConvertedMessagePart) { - body := content.GetMessageText() - for _, cta := range content.GetCtas() { - if strings.HasPrefix(cta.GetNativeURL(), "https://") && !strings.Contains(body, cta.GetNativeURL()) { - if body == "" { - body = cta.GetNativeURL() - } else { - body = fmt.Sprintf("%s\n\n%s", body, cta.GetNativeURL()) - } - } - } - return []*ConvertedMessagePart{{ - Type: event.EventMessage, - Content: &event.MessageEventContent{ - MsgType: event.MsgText, - Body: body, - }, - Extra: map[string]any{ - "fi.mau.meta.temporary_unsupported_type": "Armadillo ExtendedContentMessage", - }, - }} -} - -func (mc *MessageConverter) waArmadilloToMatrix(ctx context.Context, rawContent *waArmadilloApplication.Armadillo_Content) (parts []*ConvertedMessagePart, replyOverride *waCommon.MessageKey) { - parts = make([]*ConvertedMessagePart, 0, 2) - switch content := rawContent.GetContent().(type) { - case *waArmadilloApplication.Armadillo_Content_ExtendedContentMessage: - return mc.waExtendedContentMessageToMatrix(ctx, content.ExtendedContentMessage), nil - case *waArmadilloApplication.Armadillo_Content_BumpExistingMessage_: - parts = append(parts, &ConvertedMessagePart{ - Type: event.EventMessage, - Content: &event.MessageEventContent{ - MsgType: event.MsgText, - Body: "Bumped a message", - }, - }) - replyOverride = content.BumpExistingMessage.GetKey() - //case *waArmadilloApplication.Armadillo_Content_RavenMessage_: - // // TODO - default: - zerolog.Ctx(ctx).Warn().Type("content_type", content).Msg("Unrecognized armadillo content type") - parts = append(parts, &ConvertedMessagePart{ - Type: event.EventMessage, - Content: &event.MessageEventContent{ - MsgType: event.MsgNotice, - Body: fmt.Sprintf("Unsupported message (%T)\n\nPlease open in %s", content, mc.appName()), - }, - }) - } - return -} - -func (mc *MessageConverter) WhatsAppToMatrix(ctx context.Context, evt *events.FBMessage) *ConvertedMessage { - cm := &ConvertedMessage{} - - var replyOverride *waCommon.MessageKey - switch typedMsg := evt.Message.(type) { - case *waConsumerApplication.ConsumerApplication: - cm.Parts = mc.waConsumerToMatrix(ctx, typedMsg.GetPayload().GetContent()) - case *waArmadilloApplication.Armadillo: - cm.Parts, replyOverride = mc.waArmadilloToMatrix(ctx, typedMsg.GetPayload().GetContent()) - default: - cm.Parts = []*ConvertedMessagePart{{ - Type: event.EventMessage, - Content: &event.MessageEventContent{ - MsgType: event.MsgNotice, - Body: "Unsupported message content type", - }, - }} - } - - var sender id.UserID - var replyTo id.EventID - if qm := evt.Application.GetMetadata().GetQuotedMessage(); qm != nil { - pcp, _ := types.ParseJID(qm.GetParticipant()) - replyTo, sender = mc.GetMatrixReply(ctx, qm.GetStanzaID(), int64(pcp.UserInt())) - } else if replyOverride != nil { - pcp, _ := types.ParseJID(replyOverride.GetParticipant()) - replyTo, sender = mc.GetMatrixReply(ctx, replyOverride.GetID(), int64(pcp.UserInt())) - } - for _, part := range cm.Parts { - if part.Content.Mentions == nil { - part.Content.Mentions = &event.Mentions{} - } - if replyTo != "" { - part.Content.RelatesTo = (&event.RelatesTo{}).SetReplyTo(replyTo) - if !slices.Contains(part.Content.Mentions.UserIDs, sender) { - part.Content.Mentions.UserIDs = append(part.Content.Mentions.UserIDs, sender) - } - } - } - return cm -} diff --git a/msgconv/media.go b/msgconv/media.go deleted file mode 100644 index 14abf1e..0000000 --- a/msgconv/media.go +++ /dev/null @@ -1,239 +0,0 @@ -// mautrix-meta - A Matrix-Facebook Messenger and Instagram DM puppeting bridge. -// Copyright (C) 2024 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package msgconv - -import ( - "context" - "errors" - "fmt" - "io" - "net" - "net/http" - "net/url" - "path" - "strings" - "time" - - "github.com/rs/zerolog" - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/id" - - "go.mau.fi/mautrix-meta/messagix" -) - -var mediaHTTPClient = http.Client{ - Transport: &http.Transport{ - DialContext: (&net.Dialer{Timeout: 10 * time.Second}).DialContext, - TLSHandshakeTimeout: 10 * time.Second, - ResponseHeaderTimeout: 10 * time.Second, - ForceAttemptHTTP2: true, - }, - CheckRedirect: func(req *http.Request, via []*http.Request) error { - if req.URL.Hostname() == "video.xx.fbcdn.net" { - return http.ErrUseLastResponse - } - return nil - }, - Timeout: 120 * time.Second, -} -var MediaReferer string -var BypassOnionForMedia bool - -var ErrTooLargeFile = errors.New("too large file") - -func addDownloadHeaders(hdr http.Header, mime string) { - hdr.Set("Accept", "*/*") - switch strings.Split(mime, "/")[0] { - case "image": - hdr.Set("Accept", "image/avif,image/webp,*/*") - hdr.Set("Sec-Fetch-Dest", "image") - case "video": - hdr.Set("Sec-Fetch-Dest", "video") - case "audio": - hdr.Set("Sec-Fetch-Dest", "audio") - default: - hdr.Set("Sec-Fetch-Dest", "empty") - } - hdr.Set("Sec-Fetch-Mode", "no-cors") - hdr.Set("Sec-Fetch-Site", "cross-site") - // Setting a referer seems to disable redirects for some reason - //hdr.Set("Referer", MediaReferer) - hdr.Set("User-Agent", messagix.UserAgent) - hdr.Set("sec-ch-ua", messagix.SecCHUserAgent) - hdr.Set("sec-ch-ua-platform", messagix.SecCHPlatform) -} - -func downloadChunkedVideo(ctx context.Context, mime, url string, maxSize int64) ([]byte, error) { - log := zerolog.Ctx(ctx) - log.Trace().Str("url", url).Msg("Downloading video in chunks") - req, err := http.NewRequestWithContext(ctx, http.MethodHead, url, nil) - if err != nil { - return nil, fmt.Errorf("failed to prepare request: %w", err) - } - addDownloadHeaders(req.Header, mime) - resp, err := mediaHTTPClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to send HEAD request: %w", err) - } else if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("unexpected status code %d for HEAD request", resp.StatusCode) - } else if resp.Header.Get("Accept-Ranges") != "bytes" { - return nil, fmt.Errorf("server does not support byte range requests") - } else if resp.ContentLength <= 0 { - return nil, fmt.Errorf("server didn't return media size") - } else if resp.ContentLength > maxSize { - return nil, fmt.Errorf("%w (%.2f MiB)", ErrTooLargeFile, float64(resp.ContentLength)/1024/1024) - } - log.Debug().Int64("content_length", resp.ContentLength).Msg("Found video size to download in chunks") - - const chunkSize = 1 * 1024 * 1024 - fullData := make([]byte, resp.ContentLength) - for i := int64(0); i < resp.ContentLength; i += chunkSize { - end := i + chunkSize - 1 - if end > resp.ContentLength { - end = resp.ContentLength - 1 - } - byteRange := fmt.Sprintf("bytes=%d-%d", i, end) - log.Debug().Str("range", byteRange).Msg("Downloading chunk") - _, err = downloadMedia(ctx, mime, url, maxSize, byteRange, false, fullData[i:end+1]) - if err != nil { - return nil, fmt.Errorf("failed to download chunk %d-%d: %w", i, end, err) - } - } - log.Debug().Int("data_length", len(fullData)).Msg("Download complete") - return fullData, nil -} - -func DownloadMedia(ctx context.Context, mime, url string, maxSize int64) ([]byte, error) { - return downloadMedia(ctx, mime, url, maxSize, "", true, nil) -} - -func downloadMedia(ctx context.Context, mime, url string, maxSize int64, byteRange string, switchToChunked bool, readInto []byte) ([]byte, error) { - zerolog.Ctx(ctx).Trace().Str("url", url).Msg("Downloading media") - if BypassOnionForMedia { - url = strings.ReplaceAll(url, "facebookcooa4ldbat4g7iacswl3p2zrf5nuylvnhxn6kqolvojixwid.onion", "fbcdn.net") - } - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) - if err != nil { - return nil, fmt.Errorf("failed to prepare request: %w", err) - } - addDownloadHeaders(req.Header, mime) - if byteRange != "" { - req.Header.Set("Range", byteRange) - } - - resp, err := mediaHTTPClient.Do(req) - defer func() { - if resp != nil && resp.Body != nil { - _ = resp.Body.Close() - } - }() - if err != nil { - return nil, fmt.Errorf("failed to send request: %w", err) - } else if resp.StatusCode >= 300 || resp.StatusCode < 200 { - if resp.StatusCode == 302 && switchToChunked { - loc, _ := resp.Location() - if loc != nil && loc.Hostname() == "video.xx.fbcdn.net" { - return downloadChunkedVideo(ctx, mime, loc.String(), maxSize) - } - } - return nil, fmt.Errorf("unexpected status code %d", resp.StatusCode) - } else if resp.ContentLength > maxSize { - return nil, fmt.Errorf("%w (%.2f MiB)", ErrTooLargeFile, float64(resp.ContentLength)/1024/1024) - } - zerolog.Ctx(ctx).Debug().Int64("content_length", resp.ContentLength).Msg("Got media response, reading data") - if readInto != nil { - if resp.ContentLength != int64(len(readInto)) { - return nil, fmt.Errorf("buffer size (%d) does not match content length (%d)", len(readInto), resp.ContentLength) - } - _, err = io.ReadFull(resp.Body, readInto) - if err != nil { - return nil, fmt.Errorf("failed to read response data into buffer: %w", err) - } - return readInto, nil - } else if respData, err := io.ReadAll(io.LimitReader(resp.Body, maxSize+2)); err != nil { - return nil, fmt.Errorf("failed to read response data: %w", err) - } else if int64(len(respData)) > maxSize { - return nil, ErrTooLargeFile - } else { - zerolog.Ctx(ctx).Debug().Int("data_length", len(respData)).Msg("Media download complete") - return respData, nil - } -} - -func UpdateAvatar( - ctx context.Context, - newAvatarURL string, - avatarID *string, avatarSet *bool, avatarURL *id.ContentURI, - uploadAvatar func(context.Context, []byte, string) (*mautrix.RespMediaUpload, error), - setAvatar func(context.Context, id.ContentURI) error, -) bool { - log := zerolog.Ctx(ctx) - var newAvatarID string - if newAvatarURL != "" { - parsedAvatarURL, _ := url.Parse(newAvatarURL) - newAvatarID = path.Base(parsedAvatarURL.Path) - } - if *avatarID == newAvatarID && (*avatarSet || setAvatar == nil) { - return false - } - *avatarID = newAvatarID - *avatarSet = false - *avatarURL = id.ContentURI{} - if newAvatarID == "" { - if setAvatar == nil { - return true - } - err := setAvatar(ctx, *avatarURL) - if err != nil { - log.Err(err).Msg("Failed to remove avatar") - return true - } - log.Debug().Msg("Avatar removed") - *avatarSet = true - return true - } - avatarData, err := DownloadMedia(ctx, "image/*", newAvatarURL, 5*1024*1024) - if err != nil { - log.Err(err). - Str("avatar_id", newAvatarID). - Msg("Failed to download new avatar") - return true - } - avatarContentType := http.DetectContentType(avatarData) - resp, err := uploadAvatar(ctx, avatarData, avatarContentType) - if err != nil { - log.Err(err). - Str("avatar_id", newAvatarID). - Msg("Failed to upload new avatar") - return true - } - *avatarURL = resp.ContentURI - if setAvatar == nil { - return true - } - err = setAvatar(ctx, *avatarURL) - if err != nil { - log.Err(err).Msg("Failed to update avatar") - return true - } - log.Debug(). - Str("avatar_id", newAvatarID). - Stringer("avatar_mxc", resp.ContentURI). - Msg("Avatar updated successfully") - *avatarSet = true - return true -} diff --git a/msgconv/mentions.go b/msgconv/mentions.go deleted file mode 100644 index bf1bc93..0000000 --- a/msgconv/mentions.go +++ /dev/null @@ -1,95 +0,0 @@ -// mautrix-meta - A Matrix-Facebook Messenger and Instagram DM puppeting bridge. -// Copyright (C) 2024 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package msgconv - -import ( - "context" - "slices" - "strings" - "unicode/utf16" - - "github.com/rs/zerolog" - "maunium.net/go/mautrix/event" - - "go.mau.fi/mautrix-meta/messagix/socket" -) - -type UTF16String []uint16 - -func NewUTF16String(s string) UTF16String { - return utf16.Encode([]rune(s)) -} - -func (u UTF16String) String() string { - return string(utf16.Decode(u)) -} - -func (mc *MessageConverter) metaToMatrixText(ctx context.Context, text string, rawMentions *socket.MentionData) (content *event.MessageEventContent) { - content = &event.MessageEventContent{ - MsgType: event.MsgText, - Body: text, - Mentions: &event.Mentions{}, - } - mentions, err := rawMentions.Parse() - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to parse mentions") - } - if mentions == nil { - return - } - utf16Text := NewUTF16String(text) - prevEnd := 0 - var output strings.Builder - for _, mention := range mentions { - if mention.Offset < prevEnd { - zerolog.Ctx(ctx).Warn().Msg("Ignoring overlapping mentions in message") - continue - } else if mention.Offset >= len(utf16Text) { - zerolog.Ctx(ctx).Warn().Msg("Ignoring mention outside of message") - continue - } - end := mention.Offset + mention.Length - if end > len(utf16Text) { - end = len(utf16Text) - } - var mentionLink string - switch mention.Type { - case socket.MentionTypePerson: - userID := mc.GetUserMXID(ctx, mention.ID) - if !slices.Contains(content.Mentions.UserIDs, userID) { - content.Mentions.UserIDs = append(content.Mentions.UserIDs, userID) - } - mentionLink = userID.URI().MatrixToURL() - case socket.MentionTypeThread: - // TODO: how does one send thread mentions? - } - if mentionLink == "" { - continue - } - output.WriteString(utf16Text[prevEnd:mention.Offset].String()) - output.WriteString(``) - output.WriteString(utf16Text[mention.Offset:end].String()) - output.WriteString(``) - prevEnd = end - } - output.WriteString(utf16Text[prevEnd:].String()) - content.Format = event.FormatHTML - content.FormattedBody = output.String() - return content -} diff --git a/msgconv/msgconv.go b/msgconv/msgconv.go deleted file mode 100644 index acaa29e..0000000 --- a/msgconv/msgconv.go +++ /dev/null @@ -1,58 +0,0 @@ -// mautrix-meta - A Matrix-Facebook Messenger and Instagram DM puppeting bridge. -// Copyright (C) 2024 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package msgconv - -import ( - "context" - - "go.mau.fi/whatsmeow" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" - - "go.mau.fi/mautrix-meta/config" - "go.mau.fi/mautrix-meta/database" - "go.mau.fi/mautrix-meta/messagix" - "go.mau.fi/mautrix-meta/messagix/socket" -) - -type PortalMethods interface { - UploadMatrixMedia(ctx context.Context, data []byte, fileName, contentType string) (id.ContentURIString, error) - DownloadMatrixMedia(ctx context.Context, uri id.ContentURIString) ([]byte, error) - GetMatrixReply(ctx context.Context, messageID string, replyToUser int64) (replyTo id.EventID, replyTargetSender id.UserID) - GetMetaReply(ctx context.Context, content *event.MessageEventContent) *socket.ReplyMetaData - GetUserMXID(ctx context.Context, userID int64) id.UserID - ShouldFetchXMA(ctx context.Context) bool - GetThreadURL(ctx context.Context) (string, string) - - GetClient(ctx context.Context) *messagix.Client - GetE2EEClient(ctx context.Context) *whatsmeow.Client - GetData(ctx context.Context) *database.Portal -} - -type MessageConverter struct { - PortalMethods - - ConvertVoiceMessages bool - ConvertGIFToAPNG bool - MaxFileSize int64 - AsyncFiles bool - BridgeMode config.BridgeMode -} - -func (mc *MessageConverter) IsPrivateChat(ctx context.Context) bool { - return mc.GetData(ctx).IsPrivateChat() -} diff --git a/msgconv/to-whatsapp.go b/msgconv/to-whatsapp.go deleted file mode 100644 index 85aaae1..0000000 --- a/msgconv/to-whatsapp.go +++ /dev/null @@ -1,315 +0,0 @@ -// mautrix-meta - A Matrix-Facebook Messenger and Instagram DM puppeting bridge. -// Copyright (C) 2024 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package msgconv - -import ( - "bytes" - "context" - "fmt" - "image" - "strconv" - "strings" - "time" - - "go.mau.fi/util/ffmpeg" - "go.mau.fi/whatsmeow" - "go.mau.fi/whatsmeow/proto/waCommon" - "go.mau.fi/whatsmeow/proto/waConsumerApplication" - "go.mau.fi/whatsmeow/proto/waMediaTransport" - "go.mau.fi/whatsmeow/proto/waMsgApplication" - "go.mau.fi/whatsmeow/types" - "google.golang.org/protobuf/proto" - "maunium.net/go/mautrix/event" -) - -func (mc *MessageConverter) TextToWhatsApp(content *event.MessageEventContent) *waCommon.MessageText { - // TODO mentions - return &waCommon.MessageText{ - Text: proto.String(content.Body), - } -} - -func (mc *MessageConverter) ToWhatsApp( - ctx context.Context, - evt *event.Event, - content *event.MessageEventContent, - relaybotFormatted bool, -) (*waConsumerApplication.ConsumerApplication, *waMsgApplication.MessageApplication_Metadata, error) { - if evt.Type == event.EventSticker { - content.MsgType = event.MessageType(event.EventSticker.Type) - } - if content.MsgType == event.MsgEmote && !relaybotFormatted { - content.Body = "/me " + content.Body - if content.FormattedBody != "" { - content.FormattedBody = "/me " + content.FormattedBody - } - } - var waContent waConsumerApplication.ConsumerApplication_Content - switch content.MsgType { - case event.MsgText, event.MsgNotice, event.MsgEmote: - waContent.Content = &waConsumerApplication.ConsumerApplication_Content_MessageText{ - MessageText: mc.TextToWhatsApp(content), - } - case event.MsgImage, event.MsgVideo, event.MsgAudio, event.MsgFile, event.MessageType(event.EventSticker.Type): - reuploaded, fileName, err := mc.reuploadMediaToWhatsApp(ctx, evt, content) - if err != nil { - return nil, nil, err - } - var caption *waCommon.MessageText - if content.FileName != "" && content.Body != content.FileName { - caption = mc.TextToWhatsApp(content) - } else { - caption = &waCommon.MessageText{} - } - waContent.Content, err = mc.wrapWhatsAppMedia(evt, content, reuploaded, caption, fileName) - if err != nil { - return nil, nil, err - } - case event.MsgLocation: - lat, long, err := parseGeoURI(content.GeoURI) - if err != nil { - return nil, nil, err - } - // TODO does this actually work with any of the messenger clients? - waContent.Content = &waConsumerApplication.ConsumerApplication_Content_LocationMessage{ - LocationMessage: &waConsumerApplication.ConsumerApplication_LocationMessage{ - Location: &waConsumerApplication.ConsumerApplication_Location{ - DegreesLatitude: proto.Float64(lat), - DegreesLongitude: proto.Float64(long), - Name: proto.String(content.Body), - }, - Address: proto.String("Earth"), - }, - } - default: - return nil, nil, fmt.Errorf("%w %s", ErrUnsupportedMsgType, content.MsgType) - } - var meta waMsgApplication.MessageApplication_Metadata - if replyTo := mc.GetMetaReply(ctx, content); replyTo != nil { - meta.QuotedMessage = &waMsgApplication.MessageApplication_Metadata_QuotedMessage{ - StanzaID: proto.String(replyTo.ReplyMessageId), - // TODO: this is hacky since it hardcodes the server - // TODO 2: should this be included for DMs? - Participant: proto.String(types.JID{User: strconv.FormatInt(replyTo.ReplySender, 10), Server: types.MessengerServer}.String()), - Payload: nil, - } - } - return &waConsumerApplication.ConsumerApplication{ - Payload: &waConsumerApplication.ConsumerApplication_Payload{ - Payload: &waConsumerApplication.ConsumerApplication_Payload_Content{ - Content: &waContent, - }, - }, - Metadata: nil, - }, &meta, nil -} - -func parseGeoURI(uri string) (lat, long float64, err error) { - if !strings.HasPrefix(uri, "geo:") { - err = fmt.Errorf("uri doesn't have geo: prefix") - return - } - // Remove geo: prefix and anything after ; - coordinates := strings.Split(strings.TrimPrefix(uri, "geo:"), ";")[0] - - if splitCoordinates := strings.Split(coordinates, ","); len(splitCoordinates) != 2 { - err = fmt.Errorf("didn't find exactly two numbers separated by a comma") - } else if lat, err = strconv.ParseFloat(splitCoordinates[0], 64); err != nil { - err = fmt.Errorf("latitude is not a number: %w", err) - } else if long, err = strconv.ParseFloat(splitCoordinates[1], 64); err != nil { - err = fmt.Errorf("longitude is not a number: %w", err) - } - return -} - -func clampTo400(w, h int) (int, int) { - if w > 400 { - h = h * 400 / w - w = 400 - } - if h > 400 { - w = w * 400 / h - h = 400 - } - return w, h -} - -func (mc *MessageConverter) reuploadMediaToWhatsApp(ctx context.Context, evt *event.Event, content *event.MessageEventContent) (*waMediaTransport.WAMediaTransport, string, error) { - data, mimeType, fileName, err := mc.downloadMatrixMedia(ctx, content) - if err != nil { - return nil, "", err - } - _, isVoice := evt.Content.Raw["org.matrix.msc3245.voice"] - if isVoice { - data, err = ffmpeg.ConvertBytes(ctx, data, ".m4a", []string{}, []string{"-c:a", "aac"}, mimeType) - if err != nil { - return nil, "", fmt.Errorf("%w voice message to m4a: %w", ErrMediaConvertFailed, err) - } - mimeType = "audio/mp4" - fileName += ".m4a" - } else if mimeType == "image/gif" && content.MsgType == event.MsgImage { - data, err = ffmpeg.ConvertBytes(ctx, data, ".mp4", []string{"-f", "gif"}, []string{ - "-pix_fmt", "yuv420p", "-c:v", "libx264", "-movflags", "+faststart", - "-filter:v", "crop='floor(in_w/2)*2:floor(in_h/2)*2'", - }, mimeType) - if err != nil { - return nil, "", fmt.Errorf("%w gif to mp4: %w", ErrMediaConvertFailed, err) - } - mimeType = "video/mp4" - fileName += ".mp4" - content.MsgType = event.MsgVideo - customInfo, ok := evt.Content.Raw["info"].(map[string]any) - if !ok { - customInfo = make(map[string]any) - evt.Content.Raw["info"] = customInfo - } - customInfo["fi.mau.gif"] = true - } - if content.MsgType == event.MsgImage && content.Info.Width == 0 { - cfg, _, _ := image.DecodeConfig(bytes.NewReader(data)) - content.Info.Width, content.Info.Height = cfg.Width, cfg.Height - } - mediaType := msgToMediaType(content.MsgType) - uploaded, err := mc.GetE2EEClient(ctx).Upload(ctx, data, mediaType) - if err != nil { - return nil, "", fmt.Errorf("%w: %w", ErrMediaUploadFailed, err) - } - w, h := clampTo400(content.Info.Width, content.Info.Height) - if w == 0 && content.MsgType == event.MsgImage { - w, h = 400, 400 - } - mediaTransport := &waMediaTransport.WAMediaTransport{ - Integral: &waMediaTransport.WAMediaTransport_Integral{ - FileSHA256: uploaded.FileSHA256, - MediaKey: uploaded.MediaKey, - FileEncSHA256: uploaded.FileEncSHA256, - DirectPath: &uploaded.DirectPath, - MediaKeyTimestamp: proto.Int64(time.Now().Unix()), - }, - Ancillary: &waMediaTransport.WAMediaTransport_Ancillary{ - FileLength: proto.Uint64(uint64(len(data))), - Mimetype: &mimeType, - // This field is extremely required for some reason. - // Messenger iOS & Android will refuse to display the media if it's not present. - // iOS also requires that width and height are non-empty. - Thumbnail: &waMediaTransport.WAMediaTransport_Ancillary_Thumbnail{ - ThumbnailWidth: proto.Uint32(uint32(w)), - ThumbnailHeight: proto.Uint32(uint32(h)), - }, - ObjectID: &uploaded.ObjectID, - }, - } - fmt.Printf("Uploaded media transport: %+v\n", mediaTransport) - return mediaTransport, fileName, nil -} - -func (mc *MessageConverter) wrapWhatsAppMedia( - evt *event.Event, - content *event.MessageEventContent, - reuploaded *waMediaTransport.WAMediaTransport, - caption *waCommon.MessageText, - fileName string, -) (output waConsumerApplication.ConsumerApplication_Content_Content, err error) { - switch content.MsgType { - case event.MsgImage: - imageMsg := &waConsumerApplication.ConsumerApplication_ImageMessage{ - Caption: caption, - } - err = imageMsg.Set(&waMediaTransport.ImageTransport{ - Integral: &waMediaTransport.ImageTransport_Integral{ - Transport: reuploaded, - }, - Ancillary: &waMediaTransport.ImageTransport_Ancillary{ - Height: proto.Uint32(uint32(content.Info.Height)), - Width: proto.Uint32(uint32(content.Info.Width)), - }, - }) - output = &waConsumerApplication.ConsumerApplication_Content_ImageMessage{ImageMessage: imageMsg} - case event.MessageType(event.EventSticker.Type): - stickerMsg := &waConsumerApplication.ConsumerApplication_StickerMessage{} - err = stickerMsg.Set(&waMediaTransport.StickerTransport{ - Integral: &waMediaTransport.StickerTransport_Integral{ - Transport: reuploaded, - }, - Ancillary: &waMediaTransport.StickerTransport_Ancillary{ - Height: proto.Uint32(uint32(content.Info.Height)), - Width: proto.Uint32(uint32(content.Info.Width)), - }, - }) - output = &waConsumerApplication.ConsumerApplication_Content_StickerMessage{StickerMessage: stickerMsg} - case event.MsgVideo: - videoMsg := &waConsumerApplication.ConsumerApplication_VideoMessage{ - Caption: caption, - } - customInfo, _ := evt.Content.Raw["info"].(map[string]any) - isGif, _ := customInfo["fi.mau.gif"].(bool) - - err = videoMsg.Set(&waMediaTransport.VideoTransport{ - Integral: &waMediaTransport.VideoTransport_Integral{ - Transport: reuploaded, - }, - Ancillary: &waMediaTransport.VideoTransport_Ancillary{ - Height: proto.Uint32(uint32(content.Info.Height)), - Width: proto.Uint32(uint32(content.Info.Width)), - Seconds: proto.Uint32(uint32(content.Info.Duration / 1000)), - GifPlayback: &isGif, - }, - }) - output = &waConsumerApplication.ConsumerApplication_Content_VideoMessage{VideoMessage: videoMsg} - case event.MsgAudio: - _, isVoice := evt.Content.Raw["org.matrix.msc3245.voice"] - audioMsg := &waConsumerApplication.ConsumerApplication_AudioMessage{ - PTT: &isVoice, - } - err = audioMsg.Set(&waMediaTransport.AudioTransport{ - Integral: &waMediaTransport.AudioTransport_Integral{ - Transport: reuploaded, - }, - Ancillary: &waMediaTransport.AudioTransport_Ancillary{ - Seconds: proto.Uint32(uint32(content.Info.Duration / 1000)), - }, - }) - output = &waConsumerApplication.ConsumerApplication_Content_AudioMessage{AudioMessage: audioMsg} - case event.MsgFile: - documentMsg := &waConsumerApplication.ConsumerApplication_DocumentMessage{ - FileName: &fileName, - } - err = documentMsg.Set(&waMediaTransport.DocumentTransport{ - Integral: &waMediaTransport.DocumentTransport_Integral{ - Transport: reuploaded, - }, - Ancillary: &waMediaTransport.DocumentTransport_Ancillary{}, - }) - output = &waConsumerApplication.ConsumerApplication_Content_DocumentMessage{DocumentMessage: documentMsg} - } - return -} - -func msgToMediaType(msgType event.MessageType) whatsmeow.MediaType { - switch msgType { - case event.MsgImage, event.MessageType(event.EventSticker.Type): - return whatsmeow.MediaImage - case event.MsgVideo: - return whatsmeow.MediaVideo - case event.MsgAudio: - return whatsmeow.MediaAudio - case event.MsgFile: - fallthrough - default: - return whatsmeow.MediaDocument - } -} diff --git a/pkg/connector/config.go b/pkg/connector/config.go index 0aba662..3518756 100644 --- a/pkg/connector/config.go +++ b/pkg/connector/config.go @@ -9,15 +9,16 @@ import ( up "go.mau.fi/util/configupgrade" "gopkg.in/yaml.v3" - "go.mau.fi/mautrix-meta/config" + "go.mau.fi/mautrix-meta/messagix/types" ) //go:embed example-config.yaml var ExampleConfig string type Config struct { - Mode config.BridgeMode `yaml:"mode"` - IGE2EE bool `yaml:"ig_e2ee"` + RawMode string `yaml:"mode"` + Mode types.Platform `yaml:"-"` + IGE2EE bool `yaml:"ig_e2ee"` Proxy string `yaml:"proxy"` GetProxyFrom string `yaml:"get_proxy_from"` @@ -44,6 +45,9 @@ func (c *Config) UnmarshalYAML(node *yaml.Node) error { if err != nil { return err } + + c.Mode = types.PlatformFromString(c.RawMode) + return nil } func upgradeConfig(helper up.Helper) { @@ -63,8 +67,8 @@ func (m *MetaConnector) GetConfig() (string, any, up.Upgrader) { } func (m *MetaConnector) ValidateConfig() error { - if !m.Config.Mode.IsValid() { - return fmt.Errorf("invalid mode %q", m.Config.Mode) + if m.Config.Mode == types.Unset && m.Config.RawMode != "" { + return fmt.Errorf("invalid mode %q", m.Config.RawMode) } return nil } diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index c5f3a59..57e60c5 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -7,7 +7,7 @@ import ( waLog "go.mau.fi/whatsmeow/util/log" "maunium.net/go/mautrix/bridgev2" - "go.mau.fi/mautrix-meta/config" + "go.mau.fi/mautrix-meta/messagix/types" "go.mau.fi/mautrix-meta/pkg/msgconv" ) @@ -56,7 +56,7 @@ func (m *MetaConnector) GetCapabilities() *bridgev2.NetworkGeneralCapabilities { func (m *MetaConnector) GetName() bridgev2.BridgeName { switch m.Config.Mode { - case config.ModeFacebook, config.ModeFacebookTor, config.ModeMessenger: + case types.Facebook, types.FacebookTor, types.Messenger: return bridgev2.BridgeName{ DisplayName: "Facebook Messenger", NetworkURL: "https://www.facebook.com/messenger", @@ -65,7 +65,7 @@ func (m *MetaConnector) GetName() bridgev2.BridgeName { BeeperBridgeType: "facebookgo", DefaultPort: 29319, } - case config.ModeInstagram: + case types.Instagram: return bridgev2.BridgeName{ DisplayName: "Instagram", NetworkURL: "https://instagram.com", diff --git a/pkg/connector/login.go b/pkg/connector/login.go index 562334d..f521699 100644 --- a/pkg/connector/login.go +++ b/pkg/connector/login.go @@ -11,9 +11,9 @@ import ( "maunium.net/go/mautrix/bridgev2/database" "maunium.net/go/mautrix/bridgev2/networkid" - "go.mau.fi/mautrix-meta/config" "go.mau.fi/mautrix-meta/messagix" "go.mau.fi/mautrix-meta/messagix/cookies" + "go.mau.fi/mautrix-meta/messagix/types" "go.mau.fi/mautrix-meta/pkg/metaid" ) @@ -27,17 +27,17 @@ const ( ) func (m *MetaConnector) CreateLogin(ctx context.Context, user *bridgev2.User, flowID string) (bridgev2.LoginProcess, error) { - var plat config.BridgeMode + var plat types.Platform switch flowID { case FlowIDFacebookCookies: - plat = config.ModeFacebook - if m.Config.Mode == config.ModeFacebookTor { - plat = config.ModeFacebookTor + plat = types.Facebook + if m.Config.Mode == types.FacebookTor { + plat = types.FacebookTor } case FlowIDMessengerCookies: - plat = config.ModeMessenger + plat = types.Messenger case FlowIDInstagramCookies: - plat = config.ModeInstagram + plat = types.Instagram default: return nil, fmt.Errorf("unknown flow ID %s", flowID) } @@ -69,13 +69,13 @@ var ( func (m *MetaConnector) GetLoginFlows() []bridgev2.LoginFlow { switch m.Config.Mode { - case "": + case types.Unset: return []bridgev2.LoginFlow{loginFlowFacebook, loginFlowMessenger, loginFlowInstagram} - case config.ModeFacebook, config.ModeFacebookTor: + case types.Facebook, types.FacebookTor: return []bridgev2.LoginFlow{loginFlowFacebook} - case config.ModeMessenger: + case types.Messenger: return []bridgev2.LoginFlow{loginFlowMessenger} - case config.ModeInstagram: + case types.Instagram: return []bridgev2.LoginFlow{loginFlowInstagram} default: panic("unknown mode in config") @@ -83,7 +83,7 @@ func (m *MetaConnector) GetLoginFlows() []bridgev2.LoginFlow { } type MetaCookieLogin struct { - Mode config.BridgeMode + Mode types.Platform User *bridgev2.User Main *MetaConnector } @@ -115,13 +115,13 @@ func (m *MetaCookieLogin) Start(ctx context.Context) (*bridgev2.LoginStep, error CookiesParams: &bridgev2.LoginCookiesParams{}, } switch m.Mode { - case config.ModeFacebook, config.ModeFacebookTor: + case types.Facebook, types.FacebookTor: step.CookiesParams.URL = "https://www.facebook.com/" step.CookiesParams.Fields = cookieListToFields(cookies.FBRequiredCookies, "facebook.com") - case config.ModeMessenger: + case types.Messenger: step.CookiesParams.URL = "https://www.messenger.com/" step.CookiesParams.Fields = cookieListToFields(cookies.FBRequiredCookies, "messenger.com") - case config.ModeInstagram: + case types.Instagram: step.CookiesParams.URL = "https://www.instagram.com/" step.CookiesParams.Fields = cookieListToFields(cookies.FBRequiredCookies, "instagram.com") default: @@ -141,7 +141,7 @@ var ( ) func (m *MetaCookieLogin) SubmitCookies(ctx context.Context, strCookies map[string]string) (*bridgev2.LoginStep, error) { - c := &cookies.Cookies{Platform: m.Mode.ToPlatform()} + c := &cookies.Cookies{Platform: m.Mode} c.UpdateValues(strCookies) missingCookies := c.GetMissingCookieNames() diff --git a/pkg/connector/userinfo.go b/pkg/connector/userinfo.go index a4a1f85..ec7afa6 100644 --- a/pkg/connector/userinfo.go +++ b/pkg/connector/userinfo.go @@ -10,8 +10,8 @@ import ( "maunium.net/go/mautrix/bridgev2/networkid" "go.mau.fi/mautrix-meta/messagix/types" - "go.mau.fi/mautrix-meta/msgconv" "go.mau.fi/mautrix-meta/pkg/metaid" + "go.mau.fi/mautrix-meta/pkg/msgconv" ) func (m *MetaClient) GetUserInfo(ctx context.Context, ghost *bridgev2.Ghost) (*bridgev2.UserInfo, error) { diff --git a/pkg/msgconv/from-whatsapp.go b/pkg/msgconv/from-whatsapp.go index 80bbe94..e787b4c 100644 --- a/pkg/msgconv/from-whatsapp.go +++ b/pkg/msgconv/from-whatsapp.go @@ -41,7 +41,7 @@ import ( "maunium.net/go/mautrix/bridgev2/networkid" "maunium.net/go/mautrix/event" - "go.mau.fi/mautrix-meta/config" + metaTypes "go.mau.fi/mautrix-meta/messagix/types" "go.mau.fi/mautrix-meta/pkg/metaid" ) @@ -299,7 +299,7 @@ func (mc *MessageConverter) convertWhatsAppMedia(ctx context.Context, rawContent } func (mc *MessageConverter) appName() string { - if mc.BridgeMode == config.ModeInstagram { + if mc.BridgeMode == metaTypes.Instagram { return "Instagram app" } else { return "Messenger app" diff --git a/pkg/msgconv/msgconv.go b/pkg/msgconv/msgconv.go index 49ddd38..672ca4c 100644 --- a/pkg/msgconv/msgconv.go +++ b/pkg/msgconv/msgconv.go @@ -20,14 +20,14 @@ import ( "maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/format" - "go.mau.fi/mautrix-meta/config" + "go.mau.fi/mautrix-meta/messagix/types" ) type MessageConverter struct { Bridge *bridgev2.Bridge MaxFileSize int64 AsyncFiles bool - BridgeMode config.BridgeMode + BridgeMode types.Platform HTMLParser *format.HTMLParser } diff --git a/portal.go b/portal.go deleted file mode 100644 index 7b525b7..0000000 --- a/portal.go +++ /dev/null @@ -1,2246 +0,0 @@ -// mautrix-meta - A Matrix-Facebook Messenger and Instagram DM puppeting bridge. -// Copyright (C) 2024 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package main - -import ( - "context" - "errors" - "fmt" - "reflect" - "slices" - "strconv" - "sync" - "sync/atomic" - "time" - - "github.com/rs/zerolog" - "go.mau.fi/util/exzerolog" - "go.mau.fi/util/variationselector" - "go.mau.fi/whatsmeow" - "go.mau.fi/whatsmeow/proto/waArmadilloApplication" - "go.mau.fi/whatsmeow/proto/waCommon" - "go.mau.fi/whatsmeow/proto/waConsumerApplication" - "go.mau.fi/whatsmeow/proto/waMsgApplication" - "go.mau.fi/whatsmeow/types" - "go.mau.fi/whatsmeow/types/events" - "google.golang.org/protobuf/proto" - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/appservice" - "maunium.net/go/mautrix/bridge" - "maunium.net/go/mautrix/bridge/bridgeconfig" - "maunium.net/go/mautrix/bridge/status" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/format" - "maunium.net/go/mautrix/id" - - "go.mau.fi/mautrix-meta/config" - "go.mau.fi/mautrix-meta/database" - "go.mau.fi/mautrix-meta/messagix" - "go.mau.fi/mautrix-meta/messagix/socket" - "go.mau.fi/mautrix-meta/messagix/table" - metaTypes "go.mau.fi/mautrix-meta/messagix/types" - "go.mau.fi/mautrix-meta/msgconv" -) - -const MaxMetaSendAttempts = 5 - -func (br *MetaBridge) GetPortalByMXID(mxid id.RoomID) *Portal { - br.portalsLock.Lock() - defer br.portalsLock.Unlock() - - portal, ok := br.portalsByMXID[mxid] - if !ok { - dbPortal, err := br.DB.Portal.GetByMXID(context.TODO(), mxid) - if err != nil { - br.ZLog.Err(err).Msg("Failed to get portal from database") - return nil - } - return br.loadPortal(context.TODO(), dbPortal, nil, table.UNKNOWN_THREAD_TYPE) - } - - return portal -} - -func (br *MetaBridge) GetExistingPortalByThreadID(key database.PortalKey) *Portal { - return br.GetPortalByThreadID(key, table.UNKNOWN_THREAD_TYPE) -} - -func (br *MetaBridge) GetPortalByThreadID(key database.PortalKey, threadType table.ThreadType) *Portal { - br.portalsLock.Lock() - defer br.portalsLock.Unlock() - if threadType != table.UNKNOWN_THREAD_TYPE && !threadType.IsOneToOne() { - key.Receiver = 0 - } - portal, ok := br.portalsByID[key] - if !ok && threadType == table.UNKNOWN_THREAD_TYPE && key.Receiver != 0 { - // If the thread type is unknown and a DM portal wasn't found, try to find a group portal (zeroed receiver) - portal, ok = br.portalsByID[database.PortalKey{ThreadID: key.ThreadID}] - } - if !ok { - dbPortal, err := br.DB.Portal.GetByThreadID(context.TODO(), key) - if err != nil { - br.ZLog.Err(err).Msg("Failed to get portal from database") - return nil - } - return br.loadPortal(context.TODO(), dbPortal, &key, threadType) - } - return portal -} - -func (br *MetaBridge) GetAllPortalsWithMXID() []*Portal { - portals, err := br.dbPortalsToPortals(br.DB.Portal.GetAllWithMXID(context.TODO())) - if err != nil { - br.ZLog.Err(err).Msg("Failed to get all portals with mxid") - return nil - } - return portals -} - -func (br *MetaBridge) FindPrivateChatPortalsWith(userID int64) []*Portal { - portals, err := br.dbPortalsToPortals(br.DB.Portal.FindPrivateChatsWith(context.TODO(), userID)) - if err != nil { - br.ZLog.Err(err).Msg("Failed to get all DM portals with user") - return nil - } - return portals -} - -func (br *MetaBridge) GetAllIPortals() (iportals []bridge.Portal) { - portals, err := br.dbPortalsToPortals(br.DB.Portal.GetAllWithMXID(context.TODO())) - if err != nil { - br.ZLog.Err(err).Msg("Failed to get all portals with mxid") - return nil - } - iportals = make([]bridge.Portal, len(portals)) - for i, portal := range portals { - iportals[i] = portal - } - return iportals -} - -func (br *MetaBridge) loadPortal(ctx context.Context, dbPortal *database.Portal, key *database.PortalKey, threadType table.ThreadType) *Portal { - if dbPortal == nil { - if key == nil || threadType == table.UNKNOWN_THREAD_TYPE { - return nil - } - - dbPortal = br.DB.Portal.New() - dbPortal.PortalKey = *key - dbPortal.ThreadType = threadType - err := dbPortal.Insert(ctx) - if err != nil { - br.ZLog.Err(err).Msg("Failed to insert new portal") - return nil - } - } - - portal := br.NewPortal(dbPortal) - - br.portalsByID[portal.PortalKey] = portal - if portal.MXID != "" { - br.portalsByMXID[portal.MXID] = portal - } - - return portal -} - -func (br *MetaBridge) dbPortalsToPortals(dbPortals []*database.Portal, err error) ([]*Portal, error) { - if err != nil { - return nil, err - } - br.portalsLock.Lock() - defer br.portalsLock.Unlock() - - output := make([]*Portal, len(dbPortals)) - for index, dbPortal := range dbPortals { - if dbPortal == nil { - continue - } - - portal, ok := br.portalsByID[dbPortal.PortalKey] - if !ok { - portal = br.loadPortal(context.TODO(), dbPortal, nil, table.UNKNOWN_THREAD_TYPE) - } - - output[index] = portal - } - - return output, nil -} - -type portalMetaMessage struct { - evt any - user *User -} - -type portalMatrixMessage struct { - evt *event.Event - user *User -} - -type Portal struct { - *database.Portal - - MsgConv *msgconv.MessageConverter - - bridge *MetaBridge - log zerolog.Logger - - roomCreateLock sync.Mutex - encryptLock sync.Mutex - - metaMessages chan portalMetaMessage - matrixMessages chan portalMatrixMessage - - currentlyTyping []id.UserID - currentlyTypingLock sync.Mutex - - pendingMessages map[int64]id.EventID - pendingMessagesLock sync.Mutex - - backfillLock sync.Mutex - backfillCollector *BackfillCollector - - fetchAttempted atomic.Bool - - relayUser *User -} - -func (br *MetaBridge) NewPortal(dbPortal *database.Portal) *Portal { - logWith := br.ZLog.With().Int64("thread_id", dbPortal.ThreadID) - if dbPortal.Receiver != 0 { - logWith = logWith.Int64("thread_receiver", dbPortal.Receiver) - } - if dbPortal.MXID != "" { - logWith = logWith.Stringer("room_id", dbPortal.MXID) - } - - portal := &Portal{ - Portal: dbPortal, - bridge: br, - log: logWith.Logger(), - - metaMessages: make(chan portalMetaMessage, br.Config.Bridge.PortalMessageBuffer), - matrixMessages: make(chan portalMatrixMessage, br.Config.Bridge.PortalMessageBuffer), - - pendingMessages: make(map[int64]id.EventID), - } - portal.MsgConv = &msgconv.MessageConverter{ - PortalMethods: portal, - ConvertVoiceMessages: true, - MaxFileSize: br.MediaConfig.UploadSize, - BridgeMode: br.Config.Meta.Mode, - } - go portal.messageLoop() - - return portal -} - -func init() { - event.TypeMap[event.StateBridge] = reflect.TypeOf(CustomBridgeInfoContent{}) - event.TypeMap[event.StateHalfShotBridge] = reflect.TypeOf(CustomBridgeInfoContent{}) -} - -var ( - _ bridge.Portal = (*Portal)(nil) - _ bridge.ReadReceiptHandlingPortal = (*Portal)(nil) - //_ bridge.TypingPortal = (*Portal)(nil) - //_ bridge.DisappearingPortal = (*Portal)(nil) - //_ bridge.MembershipHandlingPortal = (*Portal)(nil) - //_ bridge.MetaHandlingPortal = (*Portal)(nil) -) - -func (portal *Portal) IsEncrypted() bool { - return portal.Encrypted -} - -func (portal *Portal) MarkEncrypted() { - portal.Encrypted = true - err := portal.Update(context.TODO()) - if err != nil { - portal.log.Err(err).Msg("Failed to update portal in database after marking as encrypted") - } -} - -func (portal *Portal) ReceiveMatrixEvent(user bridge.User, evt *event.Event) { - if user.GetPermissionLevel() >= bridgeconfig.PermissionLevelUser || portal.HasRelaybot() { - portal.matrixMessages <- portalMatrixMessage{user: user.(*User), evt: evt} - } -} - -func (portal *Portal) GetRelayUser() *User { - if !portal.HasRelaybot() { - return nil - } else if portal.relayUser == nil { - portal.relayUser = portal.bridge.GetUserByMXID(portal.RelayUserID) - } - return portal.relayUser -} - -func (portal *Portal) GetDMPuppet() *Puppet { - if !portal.IsPrivateChat() { - return nil - } - return portal.bridge.GetPuppetByID(portal.ThreadID) -} - -func (portal *Portal) MainIntent() *appservice.IntentAPI { - if dmPuppet := portal.GetDMPuppet(); dmPuppet != nil { - return dmPuppet.DefaultIntent() - } - return portal.bridge.Bot -} - -type CustomBridgeInfoContent struct { - event.BridgeEventContent - RoomType string `json:"com.beeper.room_type,omitempty"` -} - -func (portal *Portal) getBridgeInfoStateKey() string { - return fmt.Sprintf("fi.mau.meta://%s/%d", portal.bridge.BeeperNetworkName, portal.ThreadID) -} - -func (portal *Portal) GetThreadURL(_ context.Context) (protocol, channel string) { - switch portal.bridge.Config.Meta.Mode { - case config.ModeInstagram: - protocol = "https://www.instagram.com/" - channel = fmt.Sprintf("https://www.instagram.com/direct/t/%d/", portal.ThreadID) - case config.ModeFacebook, config.ModeFacebookTor: - protocol = "https://www.facebook.com/" - channel = fmt.Sprintf("https://www.facebook.com/messages/t/%d", portal.ThreadID) - case config.ModeMessenger: - protocol = "https://www.messenger.com/" - channel = fmt.Sprintf("https://www.messenger.com/t/%d", portal.ThreadID) - } - if portal.ThreadType.IsWhatsApp() { - // TODO store fb-side thread ID? (the whatsapp chat id is not the same as the fb-side thread id used in urls) - channel = "" - } - return -} - -func (portal *Portal) getBridgeInfo() (string, CustomBridgeInfoContent) { - bridgeInfo := event.BridgeEventContent{ - BridgeBot: portal.bridge.Bot.UserID, - Creator: portal.MainIntent().UserID, - Protocol: event.BridgeInfoSection{ - ID: portal.bridge.BeeperServiceName, - DisplayName: portal.bridge.ProtocolName, - AvatarURL: portal.bridge.Config.AppService.Bot.ParsedAvatar.CUString(), - }, - Channel: event.BridgeInfoSection{ - ID: strconv.FormatInt(portal.ThreadID, 10), - DisplayName: portal.Name, - AvatarURL: portal.AvatarURL.CUString(), - }, - } - bridgeInfo.Protocol.ExternalURL, bridgeInfo.Channel.ExternalURL = portal.GetThreadURL(nil) - var roomType string - if portal.IsPrivateChat() { - roomType = "dm" - } - return portal.getBridgeInfoStateKey(), CustomBridgeInfoContent{bridgeInfo, roomType} -} - -func (portal *Portal) UpdateBridgeInfo(ctx context.Context) { - if len(portal.MXID) == 0 { - portal.log.Debug().Msg("Not updating bridge info: no Matrix room created") - return - } - portal.log.Debug().Msg("Updating bridge info...") - stateKey, content := portal.getBridgeInfo() - _, err := portal.MainIntent().SendStateEvent(ctx, portal.MXID, event.StateBridge, stateKey, content) - if err != nil { - portal.log.Warn().Err(err).Msg("Failed to update m.bridge") - } - // TODO remove this once https://github.com/matrix-org/matrix-doc/pull/2346 is in spec - _, err = portal.MainIntent().SendStateEvent(ctx, portal.MXID, event.StateHalfShotBridge, stateKey, content) - if err != nil { - portal.log.Warn().Err(err).Msg("Failed to update uk.half-shot.bridge") - } -} - -func (portal *Portal) messageLoop() { - for { - select { - case msg := <-portal.matrixMessages: - portal.handleMatrixMessages(msg) - case msg := <-portal.metaMessages: - portal.handleMetaMessage(msg) - } - } -} - -func (portal *Portal) handleMatrixMessages(msg portalMatrixMessage) { - log := portal.log.With(). - Str("action", "handle matrix event"). - Str("event_id", msg.evt.ID.String()). - Str("event_type", msg.evt.Type.String()). - Logger() - ctx := log.WithContext(context.TODO()) - - evtTS := time.UnixMilli(msg.evt.Timestamp) - timings := messageTimings{ - initReceive: msg.evt.Mautrix.ReceivedAt.Sub(evtTS), - decrypt: msg.evt.Mautrix.DecryptionDuration, - totalReceive: time.Since(evtTS), - } - implicitRRStart := time.Now() - if portal.ThreadType.IsWhatsApp() { - portal.handleMatrixReadReceiptForWhatsApp(ctx, msg.user, "", evtTS, false) - } - timings.implicitRR = time.Since(implicitRRStart) - - switch msg.evt.Type { - case event.EventMessage, event.EventSticker: - portal.handleMatrixMessage(ctx, msg.user, msg.evt, timings) - case event.EventRedaction: - portal.handleMatrixRedaction(ctx, msg.user, msg.evt) - case event.EventReaction: - portal.handleMatrixReaction(ctx, msg.user, msg.evt) - default: - log.Warn().Str("type", msg.evt.Type.Type).Msg("Unhandled matrix message type") - } -} - -func (portal *Portal) HandleMatrixReadReceipt(brUser bridge.User, eventID id.EventID, receipt event.ReadReceipt) { - user := brUser.(*User) - log := portal.log.With(). - Str("action", "handle matrix receipt"). - Stringer("event_id", eventID). - Stringer("user_mxid", user.MXID). - Int64("user_meta_id", user.MetaID). - Logger() - ctx := log.WithContext(context.TODO()) - if portal.ThreadType.IsWhatsApp() { - portal.handleMatrixReadReceiptForWhatsApp(ctx, user, eventID, receipt.Timestamp, true) - } else { - portal.handleMatrixReadReceiptForMessenger(ctx, user, eventID, receipt.Timestamp) - } -} - -func (portal *Portal) handleMatrixReadReceiptForMessenger(ctx context.Context, sender *User, eventID id.EventID, receiptTimestamp time.Time) { - log := zerolog.Ctx(ctx) - if !sender.IsLoggedIn() { - log.Debug().Msg("Ignoring read receipt: user is not connected to Meta") - return - } - readWatermark := receiptTimestamp - targetMsg, err := portal.bridge.DB.Message.GetByMXID(ctx, eventID) - if err != nil { - log.Err(err).Msg("Failed to get read receipt target message") - } else if targetMsg != nil { - readWatermark = targetMsg.Timestamp - } - resp, err := sender.Client.ExecuteTasks(&socket.ThreadMarkReadTask{ - ThreadId: portal.ThreadID, - LastReadWatermarkTs: receiptTimestamp.UnixMilli(), - SyncGroup: 1, - }) - log.Trace().Any("response", resp).Msg("Read receipt send response") - if err != nil { - log.Err(err).Time("read_watermark", readWatermark).Msg("Failed to send read receipt") - } else { - log.Debug().Time("read_watermark", readWatermark).Msg("Read receipt sent") - } -} - -func (portal *Portal) handleMatrixReadReceiptForWhatsApp(ctx context.Context, sender *User, eventID id.EventID, receiptTimestamp time.Time, isExplicit bool) { - log := zerolog.Ctx(ctx) - if !sender.IsE2EEConnected() { - if isExplicit { - log.Debug().Msg("Ignoring read receipt: user is not connected to WhatsApp") - } - return - } - - maxTimestamp := receiptTimestamp - // Implicit read receipts don't have an event ID that's already bridged - if isExplicit { - if message, err := portal.bridge.DB.Message.GetByMXID(ctx, eventID); err != nil { - log.Err(err).Msg("Failed to get read receipt target message") - } else if message != nil { - maxTimestamp = message.Timestamp - } - } - - prevTimestamp := sender.GetLastReadTS(ctx, portal.PortalKey) - lastReadIsZero := false - if prevTimestamp.IsZero() { - prevTimestamp = maxTimestamp.Add(-2 * time.Second) - lastReadIsZero = true - } - - messages, err := portal.bridge.DB.Message.GetAllBetweenTimestamps(ctx, portal.PortalKey, prevTimestamp, maxTimestamp) - if err != nil { - log.Err(err).Msg("Failed to get messages for read receipt") - return - } - if len(messages) > 0 { - sender.SetLastReadTS(ctx, portal.PortalKey, messages[len(messages)-1].Timestamp) - } - groupedMessages := make(map[types.JID][]types.MessageID) - for _, msg := range messages { - var key types.JID - if msg.Sender == sender.MetaID || msg.IsUnencrypted() { - // Don't send read receipts for own messages or unencrypted messages - continue - } else if !portal.IsPrivateChat() { - // TODO: this is hacky since it hardcodes the server - key = types.JID{User: strconv.FormatInt(msg.Sender, 10), Server: types.MessengerServer} - } // else: blank key (participant field isn't needed in direct chat read receipts) - groupedMessages[key] = append(groupedMessages[key], msg.ID) - } - // For explicit read receipts, log even if there are no targets. For implicit ones only log when there are targets - if len(groupedMessages) > 0 || isExplicit { - log.Debug(). - Time("last_read", prevTimestamp). - Bool("last_read_was_zero", lastReadIsZero). - Bool("explicit", isExplicit). - Any("receipts", groupedMessages). - Msg("Sending read receipts") - } - for messageSender, ids := range groupedMessages { - err = sender.E2EEClient.MarkRead(ids, receiptTimestamp, portal.JID(), messageSender) - if err != nil { - log.Err(err).Strs("ids", ids).Msg("Failed to mark messages as read") - } - } -} - -const MaxEditCount = 5 -const MaxEditTime = 15 * time.Minute - -func (portal *Portal) handleMatrixMessage(ctx context.Context, sender *User, evt *event.Event, timings messageTimings) { - log := zerolog.Ctx(ctx) - start := time.Now() - - messageAge := timings.totalReceive - ms := metricSender{portal: portal, timings: &timings, ctx: ctx} - log.Debug(). - Str("sender", evt.Sender.String()). - Dur("age", messageAge). - Msg("Received message") - - errorAfter := portal.bridge.Config.Bridge.MessageHandlingTimeout.ErrorAfter - deadline := portal.bridge.Config.Bridge.MessageHandlingTimeout.Deadline - isScheduled, _ := evt.Content.Raw["com.beeper.scheduled"].(bool) - if isScheduled { - log.Debug().Msg("Message is a scheduled message, extending handling timeouts") - errorAfter *= 10 - deadline *= 10 - } - - if errorAfter > 0 { - remainingTime := errorAfter - messageAge - if remainingTime < 0 { - go ms.sendMessageMetrics(evt, errTimeoutBeforeHandling, "Timeout handling", true) - return - } else if remainingTime < 1*time.Second { - log.Warn(). - Dur("remaining_time", remainingTime). - Dur("max_timeout", errorAfter). - Msg("Message was delayed before reaching the bridge") - } - go func() { - time.Sleep(remainingTime) - ms.sendMessageMetrics(evt, errMessageTakingLong, "Timeout handling", false) - }() - } - - if deadline > 0 { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, deadline) - defer cancel() - } - - timings.preproc = time.Since(start) - start = time.Now() - - content, ok := evt.Content.Parsed.(*event.MessageEventContent) - if !ok { - log.Error().Type("content_type", content).Msg("Unexpected parsed content type") - go ms.sendMessageMetrics(evt, fmt.Errorf("%w %T", errUnexpectedParsedContentType, evt.Content.Parsed), "Error converting", true) - return - } - if content.MsgType == event.MsgNotice && !portal.bridge.Config.Bridge.BridgeNotices { - go ms.sendMessageMetrics(evt, errMNoticeDisabled, "Error converting", true) - return - } - - realSenderMXID := sender.MXID - isRelay := false - // TODO check login for correct client (e2ee vs not e2ee) - if !sender.IsLoggedIn() { - sender = portal.GetRelayUser() - if sender == nil { - go ms.sendMessageMetrics(evt, errUserNotLoggedIn, "Ignoring", true) - return - } else if !sender.IsLoggedIn() { - go ms.sendMessageMetrics(evt, errRelaybotNotLoggedIn, "Ignoring", true) - return - } - isRelay = true - } - - if editTarget := content.RelatesTo.GetReplaceID(); editTarget != "" { - portal.handleMatrixEdit(ctx, sender, isRelay, realSenderMXID, &ms, evt, content) - return - } - - relaybotFormatted := isRelay && portal.addRelaybotFormat(ctx, realSenderMXID, evt, content) - var otid int64 - var tasks []socket.Task - var waMsg *waConsumerApplication.ConsumerApplication - var waMeta *waMsgApplication.MessageApplication_Metadata - var err error - if portal.ThreadType.IsWhatsApp() { - ctx = context.WithValue(ctx, msgconvContextKeyE2EEClient, sender.E2EEClient) - waMsg, waMeta, err = portal.MsgConv.ToWhatsApp(ctx, evt, content, relaybotFormatted) - } else { - ctx = context.WithValue(ctx, msgconvContextKeyClient, sender.Client) - tasks, otid, err = portal.MsgConv.ToMeta(ctx, evt, content, relaybotFormatted) - if errors.Is(err, metaTypes.ErrPleaseReloadPage) && sender.canReconnect() { - log.Err(err).Msg("Got please reload page error while converting message, reloading page in background") - go sender.FullReconnect() - err = errReloading - } else if errors.Is(err, messagix.ErrTokenInvalidated) { - go sender.DisconnectFromError(status.BridgeState{ - StateEvent: status.StateBadCredentials, - Error: MetaCookieRemoved, - }) - err = errLoggedOut - } else if errors.Is(err, messagix.ErrChallengeRequired) { - go sender.DisconnectFromError(status.BridgeState{ - StateEvent: status.StateBadCredentials, - Error: IGChallengeRequired, - }) - err = errLoggedOut - } else if errors.Is(err, messagix.ErrAccountSuspended) { - go sender.DisconnectFromError(status.BridgeState{ - StateEvent: status.StateBadCredentials, - Error: IGAccountSuspended, - }) - err = errLoggedOut - } - } - if err != nil { - log.Err(err).Msg("Failed to convert message") - go ms.sendMessageMetrics(evt, err, "Error converting", true) - return - } - - timings.convert = time.Since(start) - start = time.Now() - - if waMsg != nil { - messageID := sender.E2EEClient.GenerateMessageID() - log.UpdateContext(func(c zerolog.Context) zerolog.Context { - return c.Str("message_id", messageID) - }) - log.Debug().Msg("Sending Matrix message to WhatsApp") - var resp whatsmeow.SendResponse - resp, err = sender.E2EEClient.SendFBMessage(ctx, portal.JID(), waMsg, waMeta, whatsmeow.SendRequestExtra{ - ID: messageID, - }) - // TODO save message in db before sending and only update timestamp later - portal.storeMessageInDB(ctx, evt.ID, messageID, 0, sender.MetaID, resp.Timestamp, 0) - } else { - log.UpdateContext(func(c zerolog.Context) zerolog.Context { - return c.Int64("otid", otid) - }) - log.Debug().Msg("Sending Matrix message to Meta") - otidStr := strconv.FormatInt(otid, 10) - portal.pendingMessages[otid] = evt.ID - messageTS := time.Now() - var resp *table.LSTable - - retries := 0 - for retries < MaxMetaSendAttempts { - if err = sender.Client.WaitUntilCanSendMessages(15 * time.Second); err != nil { - log.Err(err).Msg("Error waiting to be able to send messages, retrying") - } else { - resp, err = sender.Client.ExecuteTasks(tasks...) - if err == nil { - break - } - log.Err(err).Msg("Failed to send message to Meta, retrying") - } - retries++ - } - - log.Trace().Any("response", resp).Msg("Meta send response") - var msgID string - if resp != nil && err == nil { - for _, replace := range resp.LSReplaceOptimsiticMessage { - if replace.OfflineThreadingId == otidStr { - msgID = replace.MessageId - } - } - if len(msgID) == 0 { - for _, failed := range resp.LSMarkOptimisticMessageFailed { - if failed.OTID == otidStr { - log.Warn().Str("message", failed.Message).Msg("Sending message failed") - go ms.sendMessageMetrics(evt, fmt.Errorf("%w: %s", errServerRejected, failed.Message), "Error sending", true) - return - } - } - for _, failed := range resp.LSHandleFailedTask { - if failed.OTID == otidStr { - log.Warn().Str("message", failed.Message).Msg("Sending message failed") - go ms.sendMessageMetrics(evt, fmt.Errorf("%w: %s", errServerRejected, failed.Message), "Error sending", true) - return - } - } - log.Warn().Msg("Message send response didn't include message ID") - } - } - if msgID != "" { - portal.pendingMessagesLock.Lock() - _, ok = portal.pendingMessages[otid] - if ok { - portal.storeMessageInDB(ctx, evt.ID, msgID, otid, sender.MetaID, messageTS, 0) - delete(portal.pendingMessages, otid) - } else { - log.Debug().Msg("Not storing message send response: pending message was already removed from map") - } - portal.pendingMessagesLock.Unlock() - } - } - - timings.totalSend = time.Since(start) - go ms.sendMessageMetrics(evt, err, "Error sending", true) -} - -func (portal *Portal) redactFailedEdit(ctx context.Context, evtID id.EventID, reason string) { - _, err := portal.MainIntent().RedactEvent(ctx, portal.MXID, evtID, mautrix.ReqRedact{ - Reason: reason, - }) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to redact failed Matrix edit") - } -} - -func (portal *Portal) handleMatrixEdit(ctx context.Context, sender *User, isRelay bool, realSenderMXID id.UserID, ms *metricSender, evt *event.Event, content *event.MessageEventContent) { - log := zerolog.Ctx(ctx) - editTarget := content.RelatesTo.GetReplaceID() - editTargetMsg, err := portal.bridge.DB.Message.GetByMXID(ctx, editTarget) - if err != nil { - log.Err(err).Stringer("edit_target_mxid", editTarget).Msg("Failed to get edit target message") - go ms.sendMessageMetrics(evt, errFailedToGetEditTarget, "Error converting", true) - return - } else if editTargetMsg == nil { - log.Err(err).Stringer("edit_target_mxid", editTarget).Msg("Edit target message not found") - go ms.sendMessageMetrics(evt, errEditUnknownTarget, "Error converting", true) - return - } else if editTargetMsg.Sender != sender.MetaID { - go ms.sendMessageMetrics(evt, errEditDifferentSender, "Error converting", true) - return - } else if !portal.ThreadType.IsWhatsApp() && editTargetMsg.EditCount >= MaxEditCount { - go ms.sendMessageMetrics(evt, errEditCountExceeded, "Error converting", true) - go portal.redactFailedEdit(ctx, evt.ID, errEditCountExceeded.Error()) - return - } else if !portal.ThreadType.IsWhatsApp() && time.Since(editTargetMsg.Timestamp) > MaxEditTime { - go ms.sendMessageMetrics(evt, errEditTooOld, "Error converting", true) - go portal.redactFailedEdit(ctx, evt.ID, errEditTooOld.Error()) - return - } - if content.NewContent != nil { - content = content.NewContent - evt.Content.Parsed = content - } - - if isRelay { - portal.addRelaybotFormat(ctx, realSenderMXID, evt, content) - } - newEditCount := editTargetMsg.EditCount + 1 - if portal.ThreadType.IsWhatsApp() { - consumerMsg := wrapEdit(&waConsumerApplication.ConsumerApplication_EditMessage{ - Key: portal.buildMessageKey(sender, editTargetMsg), - Message: portal.MsgConv.TextToWhatsApp(content), - TimestampMS: &evt.Timestamp, - }) - var resp whatsmeow.SendResponse - resp, err = sender.E2EEClient.SendFBMessage(ctx, portal.JID(), consumerMsg, nil) - log.Trace().Any("response", resp).Msg("WhatsApp delete response") - } else { - editTask := &socket.EditMessageTask{ - MessageID: editTargetMsg.ID, - Text: content.Body, - } - var resp *table.LSTable - resp, err = sender.Client.ExecuteTasks(editTask) - log.Trace().Any("response", resp).Msg("Meta edit response") - if err == nil { - if len(resp.LSEditMessage) == 0 { - log.Debug().Msg("Edit response didn't contain new edit?") - } else if resp.LSEditMessage[0].MessageID != editTargetMsg.ID { - log.Debug().Msg("Edit response contained different message ID") - } else if resp.LSEditMessage[0].Text != content.Body { - log.Warn().Msg("Server returned edit with different text") - err = errEditReverted - go portal.redactFailedEdit(ctx, evt.ID, err.Error()) - } else if resp.LSEditMessage[0].EditCount != newEditCount { - log.Warn(). - Int64("expected_edit_count", newEditCount). - Int64("actual_edit_count", resp.LSEditMessage[0].EditCount). - Msg("Edit count mismatch") - } - } - } - go ms.sendMessageMetrics(evt, err, "Error sending", true) - if err == nil { - // TODO does the response contain the edit count? - err = editTargetMsg.UpdateEditCount(ctx, newEditCount) - if err != nil { - log.Err(err).Msg("Failed to update edit count") - } - } -} - -func (portal *Portal) handleMatrixRedaction(ctx context.Context, sender *User, evt *event.Event) { - log := zerolog.Ctx(ctx) - dbMessage, err := portal.bridge.DB.Message.GetByMXID(ctx, evt.Redacts) - if err != nil { - log.Err(err).Msg("Failed to get redaction target message") - } - dbReaction, err := portal.bridge.DB.Reaction.GetByMXID(ctx, evt.Redacts) - if err != nil { - log.Err(err).Msg("Failed to get redaction target reaction") - } - - if !sender.IsLoggedIn() { - sender = portal.GetRelayUser() - if sender == nil { - portal.sendMessageStatusCheckpointFailed(ctx, evt, errUserNotLoggedIn) - return - } else if !sender.IsLoggedIn() { - portal.sendMessageStatusCheckpointFailed(ctx, evt, errRelaybotNotLoggedIn) - return - } - } - - if dbMessage != nil { - if dbMessage.Sender != sender.MetaID { - portal.sendMessageStatusCheckpointFailed(ctx, evt, errRedactionTargetSentBySomeoneElse) - return - } - if !dbMessage.IsUnencrypted() { - consumerMsg := wrapRevoke(&waConsumerApplication.ConsumerApplication_RevokeMessage{ - Key: portal.buildMessageKey(sender, dbMessage), - }) - var resp whatsmeow.SendResponse - resp, err = sender.E2EEClient.SendFBMessage(ctx, portal.JID(), consumerMsg, nil) - log.Trace().Any("response", resp).Msg("WhatsApp delete response") - } else { - var resp *table.LSTable - resp, err = sender.Client.ExecuteTasks(&socket.DeleteMessageTask{MessageId: dbMessage.ID}) - // TODO does the response data need to be checked? - log.Trace().Any("response", resp).Msg("Instagram delete response") - } - if err != nil { - portal.sendMessageStatusCheckpointFailed(ctx, evt, err) - log.Err(err).Msg("Failed to send message redaction to Meta") - return - } - err = dbMessage.Delete(ctx) - if err != nil { - log.Err(err).Msg("Failed to delete redacted message from database") - } else if otherParts, err := portal.bridge.DB.Message.GetAllPartsByID(ctx, dbMessage.ID, portal.Receiver); err != nil { - log.Err(err).Msg("Failed to get other parts of redacted message from database") - } else if len(otherParts) > 0 { - // If there are other parts of the message, send a redaction for each of them - for _, otherPart := range otherParts { - _, err = portal.MainIntent().RedactEvent(ctx, portal.MXID, otherPart.MXID, mautrix.ReqRedact{ - Reason: "Other part of redacted message", - TxnID: "mxmeta_partredact_" + otherPart.MXID.String(), - }) - if err != nil { - log.Err(err). - Str("part_event_id", otherPart.MXID.String()). - Int("part_index", otherPart.PartIndex). - Msg("Failed to redact other part of redacted message") - } - err = otherPart.Delete(ctx) - if err != nil { - log.Err(err). - Str("part_event_id", otherPart.MXID.String()). - Int("part_index", otherPart.PartIndex). - Msg("Failed to delete other part of redacted message from database") - } - } - } - portal.sendMessageStatusCheckpointSuccess(ctx, evt) - } else if dbReaction != nil { - if dbReaction.Sender != sender.MetaID { - portal.sendMessageStatusCheckpointFailed(ctx, evt, errUnreactTargetSentBySomeoneElse) - return - } - targetMsg, err := portal.bridge.DB.Message.GetByID(ctx, dbReaction.MessageID, 0, portal.Receiver) - if err != nil { - portal.sendMessageStatusCheckpointFailed(ctx, evt, err) - log.Err(err).Msg("Failed to get removed reaction target message") - return - } else if targetMsg == nil { - portal.sendMessageStatusCheckpointFailed(ctx, evt, errReactionTargetNotFound) - log.Warn().Msg("Reaction target message not found") - return - } - err = portal.sendReaction(ctx, sender, targetMsg, "", evt.Timestamp) - if err != nil { - portal.sendMessageStatusCheckpointFailed(ctx, evt, err) - log.Err(err).Msg("Failed to send reaction redaction to Meta") - return - } - err = dbReaction.Delete(ctx) - if err != nil { - log.Err(err).Msg("Failed to delete redacted reaction from database") - } - portal.sendMessageStatusCheckpointSuccess(ctx, evt) - } else { - portal.sendMessageStatusCheckpointFailed(ctx, evt, errRedactionTargetNotFound) - } -} - -func wrapEdit(message *waConsumerApplication.ConsumerApplication_EditMessage) *waConsumerApplication.ConsumerApplication { - return &waConsumerApplication.ConsumerApplication{ - Payload: &waConsumerApplication.ConsumerApplication_Payload{ - Payload: &waConsumerApplication.ConsumerApplication_Payload_Content{ - Content: &waConsumerApplication.ConsumerApplication_Content{ - Content: &waConsumerApplication.ConsumerApplication_Content_EditMessage{ - EditMessage: message, - }, - }, - }, - }, - } -} - -func wrapRevoke(message *waConsumerApplication.ConsumerApplication_RevokeMessage) *waConsumerApplication.ConsumerApplication { - return &waConsumerApplication.ConsumerApplication{ - Payload: &waConsumerApplication.ConsumerApplication_Payload{ - Payload: &waConsumerApplication.ConsumerApplication_Payload_ApplicationData{ - ApplicationData: &waConsumerApplication.ConsumerApplication_ApplicationData{ - ApplicationContent: &waConsumerApplication.ConsumerApplication_ApplicationData_Revoke{ - Revoke: message, - }, - }, - }, - }, - } -} - -func wrapReaction(message *waConsumerApplication.ConsumerApplication_ReactionMessage) *waConsumerApplication.ConsumerApplication { - return &waConsumerApplication.ConsumerApplication{ - Payload: &waConsumerApplication.ConsumerApplication_Payload{ - Payload: &waConsumerApplication.ConsumerApplication_Payload_Content{ - Content: &waConsumerApplication.ConsumerApplication_Content{ - Content: &waConsumerApplication.ConsumerApplication_Content_ReactionMessage{ - ReactionMessage: message, - }, - }, - }, - }, - } -} - -func (portal *Portal) buildMessageKey(user *User, targetMsg *database.Message) *waCommon.MessageKey { - var messageKeyParticipant *string - if !portal.IsPrivateChat() { - // TODO: this is hacky since it hardcodes the server - messageKeyParticipant = proto.String(types.JID{User: strconv.FormatInt(targetMsg.Sender, 10), Server: types.MessengerServer}.String()) - } - var fromMe *bool - if targetMsg.Sender == user.MetaID { - fromMe = proto.Bool(true) - } - return &waCommon.MessageKey{ - RemoteJID: proto.String(portal.JID().String()), - FromMe: fromMe, - ID: &targetMsg.ID, - Participant: messageKeyParticipant, - } -} - -func (portal *Portal) sendReaction(ctx context.Context, sender *User, targetMsg *database.Message, metaEmoji string, timestamp int64) error { - if !targetMsg.IsUnencrypted() { - consumerMsg := wrapReaction(&waConsumerApplication.ConsumerApplication_ReactionMessage{ - Key: portal.buildMessageKey(sender, targetMsg), - Text: &metaEmoji, - SenderTimestampMS: ×tamp, - }) - resp, err := sender.E2EEClient.SendFBMessage(ctx, portal.JID(), consumerMsg, nil) - zerolog.Ctx(ctx).Trace().Any("response", resp).Msg("WhatsApp reaction response") - return err - } else { - resp, err := sender.Client.ExecuteTasks(&socket.SendReactionTask{ - ThreadKey: portal.ThreadID, - TimestampMs: timestamp, - MessageID: targetMsg.ID, - ActorID: sender.MetaID, - Reaction: metaEmoji, - SyncGroup: 1, - SendAttribution: table.MESSENGER_INBOX_IN_THREAD, - }) - // TODO save the hidden thread message from the response too? - zerolog.Ctx(ctx).Trace().Any("response", resp).Msg("Instagram reaction response") - return err - } -} - -func (portal *Portal) handleMatrixReaction(ctx context.Context, sender *User, evt *event.Event) { - log := zerolog.Ctx(ctx) - if !sender.IsLoggedIn() { - portal.sendMessageStatusCheckpointFailed(ctx, evt, errCantRelayReactions) - return - } - relatedEventID := evt.Content.AsReaction().RelatesTo.EventID - targetMsg, err := portal.bridge.DB.Message.GetByMXID(ctx, relatedEventID) - if err != nil { - portal.sendMessageStatusCheckpointFailed(ctx, evt, err) - log.Err(err).Msg("Failed to get reaction target message") - return - } else if targetMsg == nil { - portal.sendMessageStatusCheckpointFailed(ctx, evt, errReactionTargetNotFound) - log.Warn().Msg("Reaction target message not found") - return - } - emoji := evt.Content.AsReaction().RelatesTo.Key - metaEmoji := variationselector.Remove(emoji) - - err = portal.sendReaction(ctx, sender, targetMsg, metaEmoji, evt.Timestamp) - if err != nil { - portal.sendMessageStatusCheckpointFailed(ctx, evt, err) - log.Err(err).Msg("Failed to send reaction") - return - } - dbReaction, err := portal.bridge.DB.Reaction.GetByID( - ctx, - targetMsg.ID, - portal.Receiver, - sender.MetaID, - ) - if err != nil { - log.Err(err).Msg("Failed to get existing reaction from database") - } else if dbReaction != nil { - log.Debug().Stringer("existing_event_id", dbReaction.MXID).Msg("Redacting existing reaction after sending new one") - _, err = portal.MainIntent().RedactEvent(ctx, portal.MXID, dbReaction.MXID) - if err != nil { - log.Err(err).Msg("Failed to redact existing reaction") - } - } - if dbReaction != nil { - dbReaction.MXID = evt.ID - dbReaction.Emoji = metaEmoji - err = dbReaction.Update(ctx) - if err != nil { - log.Err(err).Msg("Failed to update reaction in database") - } - } else { - dbReaction = portal.bridge.DB.Reaction.New() - dbReaction.MXID = evt.ID - dbReaction.RoomID = portal.MXID - dbReaction.MessageID = targetMsg.ID - dbReaction.ThreadID = portal.ThreadID - dbReaction.ThreadReceiver = portal.Receiver - dbReaction.Sender = sender.MetaID - dbReaction.Emoji = metaEmoji - err = dbReaction.Insert(ctx) - if err != nil { - log.Err(err).Msg("Failed to insert reaction to database") - } - } - - portal.sendMessageStatusCheckpointSuccess(ctx, evt) -} - -func (portal *Portal) sendMessageStatusCheckpointSuccess(ctx context.Context, evt *event.Event) { - portal.sendDeliveryReceipt(ctx, evt.ID) - portal.bridge.SendMessageSuccessCheckpoint(evt, status.MsgStepRemote, 0) - portal.sendStatusEvent(ctx, evt.ID, "", nil, nil) -} - -func (portal *Portal) sendMessageStatusCheckpointFailed(ctx context.Context, evt *event.Event, err error) { - portal.sendDeliveryReceipt(ctx, evt.ID) - portal.bridge.SendMessageErrorCheckpoint(evt, status.MsgStepRemote, err, true, 0) - portal.sendStatusEvent(ctx, evt.ID, "", err, nil) -} - -type msgconvContextKey int - -const ( - msgconvContextKeyIntent msgconvContextKey = iota - msgconvContextKeyClient - msgconvContextKeyE2EEClient - msgconvContextKeyBackfill -) - -type backfillType int - -const ( - backfillTypeForward backfillType = iota + 1 - backfillTypeHistorical -) - -func (portal *Portal) ShouldFetchXMA(ctx context.Context) bool { - xmaDisabled := ctx.Value(msgconvContextKeyBackfill) == backfillTypeHistorical && portal.bridge.Config.Bridge.Backfill.Queue.DontFetchXMA - return !xmaDisabled && !portal.bridge.Config.Bridge.DisableXMA -} - -func (portal *Portal) UploadMatrixMedia(ctx context.Context, data []byte, fileName, contentType string) (id.ContentURIString, error) { - intent := ctx.Value(msgconvContextKeyIntent).(*appservice.IntentAPI) - req := mautrix.ReqUploadMedia{ - ContentBytes: data, - ContentType: contentType, - FileName: fileName, - } - if portal.bridge.Config.Homeserver.AsyncMedia { - uploaded, err := intent.UploadAsync(ctx, req) - if err != nil { - return "", err - } - return uploaded.ContentURI.CUString(), nil - } else { - uploaded, err := intent.UploadMedia(ctx, req) - if err != nil { - return "", err - } - return uploaded.ContentURI.CUString(), nil - } -} - -func (portal *Portal) DownloadMatrixMedia(ctx context.Context, uriString id.ContentURIString) ([]byte, error) { - parsedURI, err := uriString.Parse() - if err != nil { - return nil, fmt.Errorf("malformed content URI: %w", err) - } - return portal.MainIntent().DownloadBytes(ctx, parsedURI) -} - -func (portal *Portal) GetData(ctx context.Context) *database.Portal { - return portal.Portal -} - -func (portal *Portal) GetClient(ctx context.Context) *messagix.Client { - return ctx.Value(msgconvContextKeyClient).(*messagix.Client) -} - -func (portal *Portal) GetE2EEClient(ctx context.Context) *whatsmeow.Client { - return ctx.Value(msgconvContextKeyE2EEClient).(*whatsmeow.Client) -} - -func (portal *Portal) GetMatrixReply(ctx context.Context, replyToID string, replyToUser int64) (replyTo id.EventID, replyTargetSender id.UserID) { - if replyToID == "" { - return - } - log := zerolog.Ctx(ctx).With(). - Str("reply_target_id", replyToID). - Logger() - if message, err := portal.bridge.DB.Message.GetByID(ctx, replyToID, 0, portal.Receiver); err != nil { - log.Err(err).Msg("Failed to get reply target message from database") - } else if message == nil { - if ctx.Value(msgconvContextKeyBackfill) != nil && portal.bridge.Config.Homeserver.Software == bridgeconfig.SoftwareHungry { - replyTo = portal.deterministicEventID(replyToID, 0) - } else { - log.Warn().Msg("Reply target message not found") - return - } - } else { - replyTo = message.MXID - if replyToUser != 0 && message.Sender != replyToUser { - log.Warn(). - Int64("message_sender", message.Sender). - Int64("reply_to_user", replyToUser). - Msg("Mismatching reply to user and found message sender") - } - replyToUser = message.Sender - } - targetUser := portal.bridge.GetUserByMetaID(replyToUser) - if targetUser != nil { - replyTargetSender = targetUser.MXID - } else { - replyTargetSender = portal.bridge.FormatPuppetMXID(replyToUser) - } - return -} - -func (portal *Portal) GetMetaReply(ctx context.Context, content *event.MessageEventContent) *socket.ReplyMetaData { - replyToID := content.RelatesTo.GetReplyTo() - if len(replyToID) == 0 { - return nil - } - replyToMsg, err := portal.bridge.DB.Message.GetByMXID(ctx, replyToID) - if err != nil { - zerolog.Ctx(ctx).Err(err). - Str("reply_to_mxid", replyToID.String()). - Msg("Failed to get reply target message from database") - } else if replyToMsg == nil { - zerolog.Ctx(ctx).Warn(). - Str("reply_to_mxid", replyToID.String()). - Msg("Reply target message not found") - } else { - return &socket.ReplyMetaData{ - ReplyMessageId: replyToMsg.ID, - ReplySourceType: 1, - ReplyType: 0, - ReplySender: replyToMsg.Sender, - } - } - return nil -} - -func (portal *Portal) GetUserMXID(ctx context.Context, userID int64) id.UserID { - user := portal.bridge.GetUserByMetaID(userID) - if user != nil { - return user.MXID - } - return portal.bridge.FormatPuppetMXID(userID) -} - -func (portal *Portal) handleMetaMessage(portalMessage portalMetaMessage) { - switch typedEvt := portalMessage.evt.(type) { - case *events.FBMessage: - portal.handleEncryptedMessage(portalMessage.user, typedEvt) - case *events.Receipt: - portal.handleWhatsAppReceipt(portalMessage.user, typedEvt) - case *table.WrappedMessage: - portal.handleMetaInsertMessage(portalMessage.user, typedEvt) - case *table.UpsertMessages: - portal.handleMetaUpsertMessages(portalMessage.user, typedEvt) - case *table.LSUpdateExistingMessageRange: - portal.handleMetaExistingRange(portalMessage.user, typedEvt) - case *table.LSEditMessage: - portal.handleMetaEditMessage(typedEvt) - case *table.LSDeleteMessage: - portal.handleMetaDelete(typedEvt.MessageId) - case *table.LSDeleteThenInsertMessage: - if typedEvt.IsUnsent { - portal.handleMetaDelete(typedEvt.MessageId) - } else { - portal.log.Warn(). - Str("message_id", typedEvt.MessageId). - Int64("edit_count", typedEvt.EditCount). - Msg("Got unexpected non-unsend DeleteThenInsertMessage command") - } - case *table.LSUpsertReaction: - portal.handleMetaReaction(typedEvt) - case *table.LSDeleteReaction: - portal.handleMetaReactionDelete(typedEvt) - case *table.LSUpdateReadReceipt: - portal.handleMetaReadReceipt(typedEvt) - case *table.LSMarkThreadRead: - portal.handleMetaReadReceipt(&table.LSUpdateReadReceipt{ - ReadWatermarkTimestampMs: typedEvt.LastReadWatermarkTimestampMs, - ContactId: portalMessage.user.MetaID, - ReadActionTimestampMs: time.Now().UnixMilli(), - }) - case *table.LSUpdateTypingIndicator: - portal.handleMetaTypingIndicator(typedEvt) - case *table.LSSyncUpdateThreadName: - portal.handleMetaNameChange(typedEvt) - case *table.LSSetThreadImageURL: - portal.handleMetaAvatarChange(typedEvt) - case *table.LSMoveThreadToE2EECutoverFolder: - if portal.ThreadType == table.ONE_TO_ONE { - portal.log.Debug().Msg("Updating thread type to WA 1:1 after MoveThreadToE2EECutoverFolder event") - portal.ThreadType = table.ENCRYPTED_OVER_WA_ONE_TO_ONE - err := portal.Update(context.TODO()) - if err != nil { - portal.log.Err(err).Msg("Failed to save portal") - } - } - case *table.LSDeleteThread: - portal.log.Info().Msg("Deleting portal due to delete thread event") - ctx := context.TODO() - portal.Delete() - portal.Cleanup(ctx, false) - default: - portal.log.Error(). - Type("data_type", typedEvt). - Msg("Invalid inner event type inside meta message") - } -} - -func (portal *Portal) checkPendingMessage(ctx context.Context, messageID string, otid, sender int64, timestamp time.Time) bool { - if otid == 0 { - return false - } - portal.pendingMessagesLock.Lock() - defer portal.pendingMessagesLock.Unlock() - pendingEventID, ok := portal.pendingMessages[otid] - if !ok { - return false - } - portal.storeMessageInDB(ctx, pendingEventID, messageID, otid, sender, timestamp, 0) - delete(portal.pendingMessages, otid) - zerolog.Ctx(ctx).Debug().Stringer("pending_event_id", pendingEventID).Msg("Saved pending message ID") - return true -} - -func (portal *Portal) handleWhatsAppReceipt(source *User, receipt *events.Receipt) { - if receipt.Type != types.ReceiptTypeRead && receipt.Type != types.ReceiptTypeReadSelf { - return - } - senderID := int64(receipt.Sender.UserInt()) - if senderID == 0 { - return - } - log := portal.log.With(). - Str("action", "handle whatsapp receipt"). - Stringer("chat_jid", receipt.Chat). - Stringer("receipt_sender_jid", receipt.Sender). - Strs("message_ids", receipt.MessageIDs). - Time("receipt_timestamp", receipt.Timestamp). - Logger() - ctx := log.WithContext(context.TODO()) - markAsRead := make([]*database.Message, 0, 1) - var bestTimestamp time.Time - for _, msgID := range receipt.MessageIDs { - msg, err := portal.bridge.DB.Message.GetLastPartByID(ctx, msgID, portal.Receiver) - if err != nil { - log.Err(err).Msg("Failed to get message from database") - } - if msg == nil { - continue - } - if msg.Timestamp.After(bestTimestamp) { - bestTimestamp = msg.Timestamp - markAsRead = append(markAsRead[:0], msg) - } else if msg != nil && msg.Timestamp.Equal(bestTimestamp) { - markAsRead = append(markAsRead, msg) - } - } - if senderID == source.MetaID { - if len(markAsRead) > 0 { - source.SetLastReadTS(ctx, portal.PortalKey, markAsRead[0].Timestamp) - } else { - source.SetLastReadTS(ctx, portal.PortalKey, receipt.Timestamp) - } - } - sender := portal.bridge.GetPuppetByID(senderID) - for _, msg := range markAsRead { - // TODO bridge read-self as m.read.private? - err := portal.SendReadReceipt(ctx, sender, msg.MXID) - if err != nil { - log.Err(err).Stringer("event_id", msg.MXID).Msg("Failed to mark event as read") - } else { - log.Debug().Stringer("event_id", msg.MXID).Msg("Marked event as read") - } - } -} - -func (portal *Portal) handleEncryptedMessage(source *User, evt *events.FBMessage) { - sender := portal.bridge.GetPuppetByID(int64(evt.Info.Sender.UserInt())) - log := portal.log.With(). - Str("action", "handle whatsapp message"). - Stringer("chat_jid", evt.Info.Chat). - Stringer("sender_jid", evt.Info.Sender). - Str("message_id", evt.Info.ID). - Time("message_ts", evt.Info.Timestamp). - Logger() - ctx := log.WithContext(context.TODO()) - sender.FetchAndUpdateInfoIfNecessary(ctx, source) - - switch typedMsg := evt.Message.(type) { - case *waConsumerApplication.ConsumerApplication: - switch payload := typedMsg.GetPayload().GetPayload().(type) { - case *waConsumerApplication.ConsumerApplication_Payload_Content: - switch content := payload.Content.GetContent().(type) { - case *waConsumerApplication.ConsumerApplication_Content_EditMessage: - portal.handleWhatsAppEditMessage(ctx, sender, content.EditMessage) - case *waConsumerApplication.ConsumerApplication_Content_ReactionMessage: - portal.handleMetaOrWhatsAppReaction(ctx, sender, content.ReactionMessage.GetKey().GetID(), content.ReactionMessage.GetText(), content.ReactionMessage.GetSenderTimestampMS()) - default: - portal.handleMetaOrWhatsAppMessage(ctx, source, sender, evt, nil) - } - case *waConsumerApplication.ConsumerApplication_Payload_ApplicationData: - switch applicationContent := payload.ApplicationData.GetApplicationContent().(type) { - case *waConsumerApplication.ConsumerApplication_ApplicationData_Revoke: - portal.handleMetaOrWhatsAppDelete(ctx, sender, applicationContent.Revoke.GetKey().GetID()) - default: - log.Warn().Type("content_type", applicationContent).Msg("Unrecognized application content type") - } - case *waConsumerApplication.ConsumerApplication_Payload_Signal: - log.Warn().Msg("Unsupported consumer signal payload message") - case *waConsumerApplication.ConsumerApplication_Payload_SubProtocol: - log.Warn().Msg("Unsupported consumer subprotocol payload message") - default: - log.Warn().Type("payload_type", payload).Msg("Unrecognized consumer message payload type") - } - case *waArmadilloApplication.Armadillo: - switch payload := typedMsg.GetPayload().GetPayload().(type) { - case *waArmadilloApplication.Armadillo_Payload_Content: - portal.handleMetaOrWhatsAppMessage(ctx, source, sender, evt, nil) - case *waArmadilloApplication.Armadillo_Payload_ApplicationData: - log.Warn().Msg("Unsupported armadillo application data message") - case *waArmadilloApplication.Armadillo_Payload_Signal: - log.Warn().Msg("Unsupported armadillo signal payload message") - case *waArmadilloApplication.Armadillo_Payload_SubProtocol: - log.Warn().Msg("Unsupported armadillo subprotocol payload message") - default: - log.Warn().Type("payload_type", payload).Msg("Unrecognized armadillo message payload type") - } - default: - log.Warn().Type("message_type", evt.Message).Msg("Unrecognized message type") - } -} - -func (portal *Portal) handleMetaInsertMessage(source *User, message *table.WrappedMessage) { - sender := portal.bridge.GetPuppetByID(message.SenderId) - log := portal.log.With(). - Str("action", "insert meta message"). - Int64("sender_id", sender.ID). - Str("message_id", message.MessageId). - Str("otid", message.OfflineThreadingId). - Logger() - ctx := log.WithContext(context.TODO()) - eventIDs := portal.handleMetaOrWhatsAppMessage(ctx, source, sender, nil, message) - log.Debug().Array("event_ids", exzerolog.ArrayOfStringers(eventIDs)).Msg("Finished handling Meta message") -} - -func (portal *Portal) handleMetaOrWhatsAppMessage(ctx context.Context, source *User, sender *Puppet, waMsg *events.FBMessage, metaMsg *table.WrappedMessage) []id.EventID { - log := zerolog.Ctx(ctx) - - if portal.MXID == "" { - log.Debug().Msg("Creating Matrix room from incoming message") - if err := portal.CreateMatrixRoom(ctx, source); err != nil { - log.Error().Err(err).Msg("Failed to create portal room") - return nil - } - } - - var messageID string - var messageTime time.Time - var otidInt int64 - if waMsg != nil { - messageID = waMsg.Info.ID - messageTime = waMsg.Info.Timestamp - } else { - messageID = metaMsg.MessageId - otidInt, _ = strconv.ParseInt(metaMsg.OfflineThreadingId, 10, 64) - messageTime = time.UnixMilli(metaMsg.TimestampMs) - if portal.checkPendingMessage(ctx, metaMsg.MessageId, otidInt, sender.ID, messageTime) { - return nil - } - } - - existingMessage, err := portal.bridge.DB.Message.GetByID(ctx, messageID, 0, portal.Receiver) - if err != nil { - log.Err(err).Msg("Failed to check if message was already bridged") - return nil - } else if existingMessage != nil { - log.Debug().Msg("Ignoring duplicate message") - return nil - } - - intent := sender.IntentFor(portal) - ctx = context.WithValue(ctx, msgconvContextKeyIntent, intent) - var converted *msgconv.ConvertedMessage - if waMsg != nil { - ctx = context.WithValue(ctx, msgconvContextKeyE2EEClient, source.E2EEClient) - converted = portal.MsgConv.WhatsAppToMatrix(ctx, waMsg) - } else { - ctx = context.WithValue(ctx, msgconvContextKeyClient, source.Client) - converted = portal.MsgConv.ToMatrix(ctx, metaMsg) - } - if portal.bridge.Config.Bridge.CaptionInMessage { - converted.MergeCaption() - } - if len(converted.Parts) == 0 { - log.Warn().Msg("Message was empty after conversion") - return nil - } - eventIDs := make([]id.EventID, len(converted.Parts)) - for i, part := range converted.Parts { - resp, err := portal.sendMatrixEvent(ctx, intent, part.Type, part.Content, part.Extra, messageTime.UnixMilli()) - if err != nil { - log.Err(err).Int("part_index", i).Msg("Failed to send message to Matrix") - continue - } - portal.storeMessageInDB(ctx, resp.EventID, messageID, otidInt, sender.ID, messageTime, i) - eventIDs[i] = resp.EventID - } - return eventIDs -} - -func (portal *Portal) handleWhatsAppEditMessage(ctx context.Context, sender *Puppet, edit *waConsumerApplication.ConsumerApplication_EditMessage) { - log := zerolog.Ctx(ctx).With(). - Int64("edit_ts", edit.GetTimestampMS()). - Logger() - ctx = log.WithContext(context.TODO()) - targetMsg, err := portal.bridge.DB.Message.GetAllPartsByID(ctx, edit.GetKey().GetID(), portal.Receiver) - if err != nil { - log.Err(err).Msg("Failed to get edit target message") - return - } else if len(targetMsg) == 0 { - log.Warn().Msg("Edit target message not found") - return - } else if len(targetMsg) > 1 { - log.Warn().Msg("Ignoring edit of multipart message") - return - } else if targetMsg[0].EditTimestamp() >= edit.GetTimestampMS() { - log.Debug().Int64("existing_edit_ts", targetMsg[0].EditTimestamp()).Msg("Ignoring duplicate edit") - return - } - converted := portal.MsgConv.WhatsAppTextToMatrix(ctx, edit.GetMessage()) - converted.Content.SetEdit(targetMsg[0].MXID) - resp, err := portal.sendMatrixEvent(ctx, sender.IntentFor(portal), converted.Type, converted.Content, converted.Extra, edit.GetTimestampMS()) - if err != nil { - log.Err(err).Msg("Failed to send edit to Matrix") - } else if err := targetMsg[0].UpdateEditTimestamp(ctx, edit.GetTimestampMS()); err != nil { - log.Err(err).Stringer("event_id", resp.EventID).Msg("Failed to save message edit count to database") - } else { - log.Debug().Stringer("event_id", resp.EventID).Msg("Handled Meta message edit") - } -} - -func (portal *Portal) handleMetaEditMessage(edit *table.LSEditMessage) { - log := portal.log.With(). - Str("action", "edit meta message"). - Str("message_id", edit.MessageID). - Int64("edit_count", edit.EditCount). - Logger() - ctx := log.WithContext(context.TODO()) - targetMsg, err := portal.bridge.DB.Message.GetAllPartsByID(ctx, edit.MessageID, portal.Receiver) - if err != nil { - log.Err(err).Msg("Failed to get edit target message") - return - } else if len(targetMsg) == 0 { - log.Warn().Msg("Edit target message not found") - return - } else if len(targetMsg) > 1 { - log.Warn().Msg("Ignoring edit of multipart message") - return - } else if targetMsg[0].EditCount >= edit.EditCount { - log.Debug().Int64("existing_edit_count", targetMsg[0].EditCount).Msg("Ignoring duplicate edit") - return - } - sender := portal.bridge.GetPuppetByID(targetMsg[0].Sender) - content := &event.MessageEventContent{ - MsgType: event.MsgText, - Body: edit.Text, - Mentions: &event.Mentions{}, - } - content.SetEdit(targetMsg[0].MXID) - resp, err := portal.sendMatrixEvent(ctx, sender.IntentFor(portal), event.EventMessage, content, map[string]any{}, 0) - if err != nil { - log.Err(err).Msg("Failed to send edit to Matrix") - } else if err := targetMsg[0].UpdateEditCount(ctx, edit.EditCount); err != nil { - log.Err(err).Stringer("event_id", resp.EventID).Msg("Failed to save message edit count to database") - } else { - log.Debug().Stringer("event_id", resp.EventID).Msg("Handled Meta message edit") - } -} - -func (portal *Portal) handleMetaReaction(react *table.LSUpsertReaction) { - sender := portal.bridge.GetPuppetByID(react.ActorId) - ctx := portal.log.With(). - Str("action", "upsert meta reaction"). - Int64("sender_id", sender.ID). - Str("target_msg_id", react.MessageId). - Logger(). - WithContext(context.TODO()) - portal.handleMetaOrWhatsAppReaction(ctx, sender, react.MessageId, react.Reaction, 0) -} - -func (portal *Portal) handleMetaReactionDelete(react *table.LSDeleteReaction) { - sender := portal.bridge.GetPuppetByID(react.ActorId) - log := portal.log.With(). - Str("action", "delete meta reaction"). - Int64("sender_id", sender.ID). - Str("target_msg_id", react.MessageId). - Logger() - ctx := log.WithContext(context.TODO()) - portal.handleMetaOrWhatsAppReaction(ctx, sender, react.MessageId, "", 0) -} - -func (portal *Portal) handleMetaOrWhatsAppReaction(ctx context.Context, sender *Puppet, messageID, reaction string, timestamp int64) { - log := zerolog.Ctx(ctx) - targetMsg, err := portal.bridge.DB.Message.GetByID(ctx, messageID, 0, portal.Receiver) - if err != nil { - log.Err(err).Msg("Failed to get target message from database") - return - } else if targetMsg == nil { - log.Warn().Msg("Target message not found") - return - } - existingReaction, err := portal.bridge.DB.Reaction.GetByID(ctx, targetMsg.ID, portal.Receiver, sender.ID) - if err != nil { - log.Err(err).Msg("Failed to get existing reaction from database") - return - } else if existingReaction != nil && existingReaction.Emoji == reaction { - // TODO should reactions be deduplicated by some ID instead of the emoji? - log.Debug().Msg("Ignoring duplicate reaction") - return - } - intent := sender.IntentFor(portal) - if existingReaction != nil { - _, err = intent.RedactEvent(ctx, portal.MXID, existingReaction.MXID, mautrix.ReqRedact{ - TxnID: "mxmeta_unreact_" + existingReaction.MXID.String(), - }) - if err != nil { - log.Err(err).Msg("Failed to redact reaction") - } - } - if reaction == "" { - if existingReaction == nil { - log.Warn().Msg("Existing reaction to delete not found") - return - } - err = existingReaction.Delete(ctx) - if err != nil { - log.Err(err).Msg("Failed to delete reaction from database") - } - return - } - content := &event.ReactionEventContent{ - RelatesTo: event.RelatesTo{ - Type: event.RelAnnotation, - Key: variationselector.Add(reaction), - EventID: targetMsg.MXID, - }, - } - resp, err := portal.sendMatrixEvent(ctx, intent, event.EventReaction, content, nil, timestamp) - if err != nil { - log.Err(err).Msg("Failed to send reaction") - return - } - if existingReaction == nil { - dbReaction := portal.bridge.DB.Reaction.New() - dbReaction.MXID = resp.EventID - dbReaction.RoomID = portal.MXID - dbReaction.MessageID = targetMsg.ID - dbReaction.ThreadID = portal.ThreadID - dbReaction.ThreadReceiver = portal.Receiver - dbReaction.Sender = sender.ID - dbReaction.Emoji = reaction - // TODO save timestamp? - err = dbReaction.Insert(ctx) - if err != nil { - log.Err(err).Msg("Failed to insert reaction to database") - } - } else { - existingReaction.Emoji = reaction - existingReaction.MXID = resp.EventID - err = existingReaction.Update(ctx) - if err != nil { - log.Err(err).Msg("Failed to update reaction in database") - } - } -} - -func (portal *Portal) handleMetaDelete(messageID string) { - log := portal.log.With(). - Str("action", "delete meta message"). - Str("message_id", messageID). - Logger() - ctx := log.WithContext(context.TODO()) - portal.handleMetaOrWhatsAppDelete(ctx, nil, messageID) -} - -func (portal *Portal) handleMetaOrWhatsAppDelete(ctx context.Context, sender *Puppet, messageID string) { - log := zerolog.Ctx(ctx) - targetMsg, err := portal.bridge.DB.Message.GetAllPartsByID(ctx, messageID, portal.Receiver) - if err != nil { - log.Err(err).Msg("Failed to get target message from database") - return - } else if len(targetMsg) == 0 { - log.Warn().Msg("Target message not found") - return - } - intent := portal.MainIntent() - if sender != nil { - intent = sender.IntentFor(portal) - } - for _, part := range targetMsg { - _, err = intent.RedactEvent(ctx, portal.MXID, part.MXID, mautrix.ReqRedact{ - TxnID: "mxmeta_delete_" + part.MXID.String(), - }) - if err != nil { - log.Err(err). - Int("part_index", part.PartIndex). - Str("event_id", part.MXID.String()). - Msg("Failed to redact message") - } - err = part.Delete(ctx) - if err != nil { - log.Err(err). - Int("part_index", part.PartIndex). - Msg("Failed to delete message from database") - } - } -} - -type customReadReceipt struct { - Timestamp int64 `json:"ts,omitempty"` - DoublePuppetSource string `json:"fi.mau.double_puppet_source,omitempty"` -} - -type customReadMarkers struct { - mautrix.ReqSetReadMarkers - ReadExtra customReadReceipt `json:"com.beeper.read.extra"` - FullyReadExtra customReadReceipt `json:"com.beeper.fully_read.extra"` -} - -func (portal *Portal) SendReadReceipt(ctx context.Context, sender *Puppet, eventID id.EventID) error { - intent := sender.IntentFor(portal) - if intent.IsCustomPuppet { - extra := customReadReceipt{DoublePuppetSource: portal.bridge.Name} - return intent.SetReadMarkers(ctx, portal.MXID, &customReadMarkers{ - ReqSetReadMarkers: mautrix.ReqSetReadMarkers{ - Read: eventID, - FullyRead: eventID, - }, - ReadExtra: extra, - FullyReadExtra: extra, - }) - } else { - return intent.MarkRead(ctx, portal.MXID, eventID) - } -} - -func (portal *Portal) handleMetaReadReceipt(read *table.LSUpdateReadReceipt) { - if portal.MXID == "" { - portal.log.Debug().Msg("Dropping read receipt in chat with no portal") - return - } - sender := portal.bridge.GetPuppetByID(read.ContactId) - log := portal.log.With(). - Str("action", "handle meta read receipt"). - Int64("sender_id", sender.ID). - Int64("read_up_to_ms", read.ReadWatermarkTimestampMs). - Int64("read_at_ms", read.ReadActionTimestampMs). - Logger() - ctx := log.WithContext(context.TODO()) - message, err := portal.bridge.DB.Message.GetLastByTimestamp(ctx, portal.PortalKey, time.UnixMilli(read.ReadWatermarkTimestampMs)) - if err != nil { - log.Err(err).Msg("Failed to get message to mark as read") - } else if message == nil { - log.Warn().Msg("No message found to mark as read") - } else if err = portal.SendReadReceipt(ctx, sender, message.MXID); err != nil { - log.Err(err).Stringer("event_id", message.MXID).Msg("Failed to send read receipt") - } else { - log.Debug().Stringer("event_id", message.MXID).Msg("Sent read receipt to Matrix") - } -} - -// TODO find if this is the correct timeout -const MetaTypingTimeout = 15 * time.Second - -func (portal *Portal) handleMetaTypingIndicator(typing *table.LSUpdateTypingIndicator) { - if portal.MXID == "" { - portal.log.Debug().Msg("Dropping typing message in chat with no portal") - return - } - ctx := context.TODO() - sender := portal.bridge.GetPuppetByID(typing.SenderId) - intent := sender.IntentFor(portal) - // Don't bridge double puppeted typing notifications to avoid echoing - if intent.IsCustomPuppet { - return - } - _, err := intent.UserTyping(ctx, portal.MXID, typing.IsTyping, MetaTypingTimeout) - if err != nil { - portal.log.Err(err). - Int64("user_id", sender.ID). - Msg("Failed to handle Meta typing notification") - } -} - -func (portal *Portal) handleMetaNameChange(typedEvt *table.LSSyncUpdateThreadName) { - log := portal.log.With(). - Str("action", "meta name change"). - Logger() - ctx := log.WithContext(context.TODO()) - if portal.updateName(ctx, typedEvt.ThreadName) { - err := portal.Update(ctx) - if err != nil { - log.Err(err).Msg("Failed to save portal in database after name change") - } - portal.UpdateBridgeInfo(ctx) - } -} - -func (portal *Portal) handleMetaAvatarChange(evt *table.LSSetThreadImageURL) { - log := portal.log.With(). - Str("action", "meta avatar change"). - Logger() - ctx := log.WithContext(context.TODO()) - if portal.updateAvatar(ctx, evt.ImageURL) { - err := portal.Update(ctx) - if err != nil { - log.Err(err).Msg("Failed to save portal in database after avatar change") - } - portal.UpdateBridgeInfo(ctx) - } -} - -func (portal *Portal) storeMessageInDB(ctx context.Context, eventID id.EventID, messageID string, otid, senderID int64, timestamp time.Time, partIndex int) { - dbMessage := portal.bridge.DB.Message.New() - dbMessage.MXID = eventID - dbMessage.RoomID = portal.MXID - dbMessage.ID = messageID - dbMessage.OTID = otid - dbMessage.Sender = senderID - dbMessage.Timestamp = timestamp - dbMessage.PartIndex = partIndex - dbMessage.ThreadID = portal.ThreadID - dbMessage.ThreadReceiver = portal.Receiver - err := dbMessage.Insert(ctx) - if err != nil { - portal.log.Err(err).Msg("Failed to insert message into database") - } -} - -func (portal *Portal) sendMainIntentMessage(ctx context.Context, content *event.MessageEventContent) (*mautrix.RespSendEvent, error) { - return portal.sendMatrixEvent(ctx, portal.MainIntent(), event.EventMessage, content, nil, 0) -} - -func (portal *Portal) encrypt(ctx context.Context, intent *appservice.IntentAPI, content *event.Content, eventType event.Type) (event.Type, error) { - if !portal.Encrypted || portal.bridge.Crypto == nil { - return eventType, nil - } - intent.AddDoublePuppetValue(content) - // TODO maybe the locking should be inside mautrix-go? - portal.encryptLock.Lock() - defer portal.encryptLock.Unlock() - err := portal.bridge.Crypto.Encrypt(ctx, portal.MXID, eventType, content) - if err != nil { - return eventType, fmt.Errorf("failed to encrypt event: %w", err) - } - return event.EventEncrypted, nil -} - -func (portal *Portal) sendMatrixEvent(ctx context.Context, intent *appservice.IntentAPI, eventType event.Type, content any, extraContent map[string]any, timestamp int64) (*mautrix.RespSendEvent, error) { - wrappedContent := event.Content{Parsed: content, Raw: extraContent} - if eventType != event.EventReaction { - var err error - eventType, err = portal.encrypt(ctx, intent, &wrappedContent, eventType) - if err != nil { - return nil, err - } - } - - _, _ = intent.UserTyping(ctx, portal.MXID, false, 0) - return intent.SendMassagedMessageEvent(ctx, portal.MXID, eventType, &wrappedContent, timestamp) -} - -func (portal *Portal) getEncryptionEventContent() (evt *event.EncryptionEventContent) { - evt = &event.EncryptionEventContent{Algorithm: id.AlgorithmMegolmV1} - if rot := portal.bridge.Config.Bridge.Encryption.Rotation; rot.EnableCustom { - evt.RotationPeriodMillis = rot.Milliseconds - evt.RotationPeriodMessages = rot.Messages - } - return -} - -func (portal *Portal) shouldSetDMRoomMetadata() bool { - return !portal.IsPrivateChat() || - portal.bridge.Config.Bridge.PrivateChatPortalMeta == "always" || - (portal.IsEncrypted() && portal.bridge.Config.Bridge.PrivateChatPortalMeta != "never") -} - -func (portal *Portal) ensureUserInvited(ctx context.Context, user *User) bool { - return user.ensureInvited(ctx, portal.MainIntent(), portal.MXID, portal.IsPrivateChat()) -} - -func (portal *Portal) CreateMatrixRoom(ctx context.Context, user *User) error { - portal.roomCreateLock.Lock() - defer portal.roomCreateLock.Unlock() - if portal.MXID != "" { - portal.log.Debug().Msg("Not creating room: already exists") - return nil - } - portal.log.Debug().Msg("Creating matrix room") - - intent := portal.MainIntent() - - if err := intent.EnsureRegistered(ctx); err != nil { - portal.log.Error().Err(err).Msg("failed to ensure registered") - return err - } - - bridgeInfoStateKey, bridgeInfo := portal.getBridgeInfo() - initialState := []*event.Event{{ - Type: event.StateBridge, - Content: event.Content{Parsed: bridgeInfo}, - StateKey: &bridgeInfoStateKey, - }, { - // TODO remove this once https://github.com/matrix-org/matrix-doc/pull/2346 is in spec - Type: event.StateHalfShotBridge, - Content: event.Content{Parsed: bridgeInfo}, - StateKey: &bridgeInfoStateKey, - }} - - creationContent := make(map[string]interface{}) - if !portal.bridge.Config.Bridge.FederateRooms { - creationContent["m.federate"] = false - } - - var invite []id.UserID - autoJoinInvites := portal.bridge.SpecVersions.Supports(mautrix.BeeperFeatureAutojoinInvites) - if autoJoinInvites { - invite = append(invite, user.MXID) - } - var waGroupInfo *types.GroupInfo - var participants []id.UserID - if portal.ThreadType == table.ENCRYPTED_OVER_WA_GROUP { - waGroupInfo, participants = portal.UpdateWAGroupInfo(ctx, user, nil) - invite = append(invite, participants...) - slices.Sort(invite) - invite = slices.Compact(invite) - } - - if portal.bridge.Config.Bridge.Encryption.Default { - initialState = append(initialState, &event.Event{ - Type: event.StateEncryption, - Content: event.Content{ - Parsed: portal.getEncryptionEventContent(), - }, - }) - portal.Encrypted = true - - if portal.IsPrivateChat() { - invite = append(invite, portal.bridge.Bot.UserID) - } - } - dmPuppet := portal.GetDMPuppet() - if dmPuppet != nil { - dmPuppet.FetchAndUpdateInfoIfNecessary(ctx, user) - portal.UpdateInfoFromPuppet(ctx, dmPuppet) - } - if !portal.AvatarURL.IsEmpty() { - initialState = append(initialState, &event.Event{ - Type: event.StateRoomAvatar, - Content: event.Content{Parsed: &event.RoomAvatarEventContent{ - URL: portal.AvatarURL.CUString(), - }}, - }) - } - - req := &mautrix.ReqCreateRoom{ - Visibility: "private", - Name: portal.Name, - Invite: invite, - Preset: "private_chat", - IsDirect: portal.IsPrivateChat(), - InitialState: initialState, - CreationContent: creationContent, - - BeeperAutoJoinInvites: autoJoinInvites, - } - resp, err := intent.CreateRoom(ctx, req) - if err != nil { - portal.log.Warn().Err(err).Msg("failed to create room") - return err - } - portal.log = portal.log.With().Stringer("room_id", resp.RoomID).Logger() - - portal.NameSet = len(req.Name) > 0 - portal.AvatarSet = !portal.AvatarURL.IsEmpty() - portal.MXID = resp.RoomID - portal.MoreToBackfill = true - portal.bridge.portalsLock.Lock() - portal.bridge.portalsByMXID[portal.MXID] = portal - portal.bridge.portalsLock.Unlock() - err = portal.Update(ctx) - if err != nil { - portal.log.Err(err).Msg("Failed to save portal room ID") - return err - } - portal.log.Info().Msg("Created matrix room for portal") - - if !autoJoinInvites { - if portal.Encrypted { - err = portal.bridge.Bot.EnsureJoined(ctx, portal.MXID, appservice.EnsureJoinedParams{BotOverride: portal.MainIntent().Client}) - if err != nil { - portal.log.Error().Err(err).Msg("Failed to ensure bridge bot is joined to private chat portal") - } - } - user.ensureInvited(ctx, portal.MainIntent(), portal.MXID, portal.IsPrivateChat()) - } - go portal.addToPersonalSpace(portal.log.WithContext(context.TODO()), user) - - if portal.IsPrivateChat() { - user.AddDirectChat(ctx, portal.MXID, dmPuppet.MXID) - } - if waGroupInfo != nil && !autoJoinInvites { - portal.SyncWAParticipants(ctx, user, waGroupInfo.Participants) - } - - return nil -} - -func (portal *Portal) UpdateInfoFromPuppet(ctx context.Context, puppet *Puppet) { - if !portal.shouldSetDMRoomMetadata() { - return - } - update := false - update = portal.updateName(ctx, puppet.Name) || update - // Note: DM avatars will also go through the main UpdateInfo route - // (for some reason DMs have thread pictures, but not thread names) - update = portal.updateAvatarWithURL(ctx, puppet.AvatarID, puppet.AvatarURL) || update - if update { - err := portal.Update(ctx) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to save portal in database after updating DM info") - } - portal.UpdateBridgeInfo(ctx) - } -} - -func (portal *Portal) UpdateWAGroupInfo(ctx context.Context, source *User, groupInfo *types.GroupInfo) (*types.GroupInfo, []id.UserID) { - log := zerolog.Ctx(ctx) - if groupInfo == nil { - var err error - groupInfo, err = source.E2EEClient.GetGroupInfo(portal.JID()) - if err != nil { - log.Err(err).Msg("Failed to fetch WhatsApp group info") - return nil, nil - } - } - update := false - update = portal.updateName(ctx, groupInfo.Name) || update - //update = portal.updateTopic(ctx, groupInfo.Topic) || update - //update = portal.updateWAAvatar(ctx) - participants := portal.SyncWAParticipants(ctx, source, groupInfo.Participants) - if update { - err := portal.Update(ctx) - if err != nil { - log.Err(err).Msg("Failed to save portal in database after updating group info") - } - portal.UpdateBridgeInfo(ctx) - } - return groupInfo, participants -} - -func (portal *Portal) SyncWAParticipants(ctx context.Context, source *User, participants []types.GroupParticipant) []id.UserID { - var userIDs []id.UserID - for _, pcp := range participants { - puppet := portal.bridge.GetPuppetByID(int64(pcp.JID.UserInt())) - puppet.FetchAndUpdateInfoIfNecessary(ctx, source) - userIDs = append(userIDs, puppet.IntentFor(portal).UserID) - if portal.MXID != "" { - err := puppet.IntentFor(portal).EnsureJoined(ctx, portal.MXID) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to ensure participant is joined to group") - } - } - } - return userIDs -} - -func (portal *Portal) UpdateInfo(ctx context.Context, info table.ThreadInfo) { - log := zerolog.Ctx(ctx).With(). - Str("function", "UpdateInfo"). - Logger() - ctx = log.WithContext(ctx) - update := false - if portal.ThreadType != info.GetThreadType() && !portal.ThreadType.IsWhatsApp() { - portal.ThreadType = info.GetThreadType() - update = true - } - if !portal.IsPrivateChat() || portal.shouldSetDMRoomMetadata() { - if info.GetThreadName() != "" || !portal.IsPrivateChat() { - update = portal.updateName(ctx, info.GetThreadName()) || update - } - if info.GetThreadPictureUrl() != "" || !portal.IsPrivateChat() { - update = portal.updateAvatar(ctx, info.GetThreadPictureUrl()) || update - } - } - if update { - err := portal.Update(ctx) - if err != nil { - log.Err(err).Msg("Failed to save portal in database after updating group info") - } - portal.UpdateBridgeInfo(ctx) - } - return -} - -func (portal *Portal) updateName(ctx context.Context, newName string) bool { - if portal.Name == newName && (portal.NameSet || portal.MXID == "") { - return false - } - portal.Name = newName - portal.NameSet = false - if portal.MXID != "" { - _, err := portal.MainIntent().SetRoomName(ctx, portal.MXID, portal.Name) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to update room name") - } else { - portal.NameSet = true - } - } - return true -} - -func (portal *Portal) updateAvatarWithURL(ctx context.Context, avatarID string, avatarMXC id.ContentURI) bool { - if portal.AvatarID == avatarID && (portal.AvatarSet || portal.MXID == "") { - return false - } - if (avatarID == "") != avatarMXC.IsEmpty() { - return false - } - portal.AvatarID = avatarID - portal.AvatarURL = avatarMXC - portal.AvatarSet = false - if portal.MXID != "" { - _, err := portal.MainIntent().SetRoomAvatar(ctx, portal.MXID, portal.AvatarURL) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to update room avatar") - } else { - portal.AvatarSet = true - } - } - return true -} - -func (portal *Portal) updateAvatar(ctx context.Context, avatarURL string) bool { - var setAvatar func(context.Context, id.ContentURI) error - if portal.MXID != "" { - setAvatar = func(ctx context.Context, uri id.ContentURI) error { - _, err := portal.MainIntent().SetRoomAvatar(ctx, portal.MXID, uri) - return err - } - } - return msgconv.UpdateAvatar( - ctx, avatarURL, - &portal.AvatarID, &portal.AvatarSet, &portal.AvatarURL, - portal.MainIntent().UploadBytes, setAvatar, - ) -} - -func (portal *Portal) addToPersonalSpace(ctx context.Context, user *User) bool { - spaceID := user.GetSpaceRoom(ctx) - if len(spaceID) == 0 || user.IsInSpace(ctx, portal.PortalKey) { - return false - } - _, err := portal.bridge.Bot.SendStateEvent(ctx, spaceID, event.StateSpaceChild, portal.MXID.String(), &event.SpaceChildEventContent{ - Via: []string{portal.bridge.Config.Homeserver.Domain}, - }) - if err != nil { - zerolog.Ctx(ctx).Err(err). - Str("user_id", user.MXID.String()). - Str("space_id", spaceID.String()). - Msg("Failed to add room to user's personal filtering space") - return false - } else { - zerolog.Ctx(ctx).Debug(). - Str("user_id", user.MXID.String()). - Str("space_id", spaceID.String()). - Msg("Added room to user's personal filtering space") - user.MarkInSpace(ctx, portal.PortalKey) - return true - } -} - -func (portal *Portal) HasRelaybot() bool { - return portal.bridge.Config.Bridge.Relay.Enabled && len(portal.RelayUserID) > 0 -} - -func (portal *Portal) addRelaybotFormat(ctx context.Context, userID id.UserID, evt *event.Event, content *event.MessageEventContent) bool { - member := portal.MainIntent().Member(ctx, portal.MXID, userID) - if member == nil { - member = &event.MemberEventContent{} - } - // Stickers can't have captions, so force them into images when relaying - if evt.Type == event.EventSticker { - content.MsgType = event.MsgImage - evt.Type = event.EventMessage - } - content.EnsureHasHTML() - data, err := portal.bridge.Config.Bridge.Relay.FormatMessage(content, userID, *member) - if err != nil { - portal.log.Err(err).Msg("Failed to apply relaybot format") - } - content.FormattedBody = data - // Force FileName field so the formatted body is used as a caption - if content.FileName == "" { - content.FileName = content.Body - } - content.Body = format.HTMLToText(content.FormattedBody) - return true -} - -func (portal *Portal) Delete() { - err := portal.Portal.Delete(context.TODO()) - if err != nil { - portal.log.Err(err).Msg("Failed to delete portal from db") - } - portal.bridge.portalsLock.Lock() - delete(portal.bridge.portalsByID, portal.PortalKey) - if len(portal.MXID) > 0 { - delete(portal.bridge.portalsByMXID, portal.MXID) - } - if portal.Receiver == 0 { - portal.bridge.usersLock.Lock() - for _, user := range portal.bridge.usersByMetaID { - user.RemoveInSpaceCache(portal.PortalKey) - } - portal.bridge.usersLock.Unlock() - } else { - user := portal.bridge.GetUserByMetaID(portal.Receiver) - if user != nil { - user.RemoveInSpaceCache(portal.PortalKey) - } - } - portal.bridge.portalsLock.Unlock() -} - -func (portal *Portal) Cleanup(ctx context.Context, puppetsOnly bool) { - portal.bridge.CleanupRoom(ctx, &portal.log, portal.MainIntent(), portal.MXID, puppetsOnly) -} - -func (br *MetaBridge) CleanupRoom(ctx context.Context, log *zerolog.Logger, intent *appservice.IntentAPI, mxid id.RoomID, puppetsOnly bool) { - if len(mxid) == 0 { - return - } - if br.SpecVersions.Supports(mautrix.BeeperFeatureRoomYeeting) { - err := intent.BeeperDeleteRoom(ctx, mxid) - if err == nil || errors.Is(err, mautrix.MNotFound) { - return - } - log.Warn().Err(err).Msg("Failed to delete room using beeper yeet endpoint, falling back to normal behavior") - } - members, err := intent.JoinedMembers(ctx, mxid) - if err != nil { - log.Err(err).Msg("Failed to get portal members for cleanup") - return - } - for member := range members.Joined { - if member == intent.UserID { - continue - } - puppet := br.GetPuppetByMXID(member) - if puppet != nil { - _, err = puppet.DefaultIntent().LeaveRoom(ctx, mxid) - if err != nil { - log.Err(err).Msg("Failed to leave as puppet while cleaning up portal") - } - } else if !puppetsOnly { - _, err = intent.KickUser(ctx, mxid, &mautrix.ReqKickUser{UserID: member, Reason: "Deleting portal"}) - if err != nil { - log.Err(err).Msg("Failed to kick user while cleaning up portal") - } - } - } - _, err = intent.LeaveRoom(ctx, mxid) - if err != nil { - log.Err(err).Msg("Failed to leave room while cleaning up portal") - } -} diff --git a/provisioning.go b/provisioning.go deleted file mode 100644 index 77c7ffc..0000000 --- a/provisioning.go +++ /dev/null @@ -1,152 +0,0 @@ -// mautrix-meta - A Matrix-Facebook Messenger and Instagram DM puppeting bridge. -// Copyright (C) 2024 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package main - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "net/http" - _ "net/http/pprof" - "strings" - - "github.com/rs/zerolog" - "github.com/rs/zerolog/hlog" - "go.mau.fi/util/requestlog" - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/id" - - "go.mau.fi/mautrix-meta/database" - "go.mau.fi/mautrix-meta/messagix" - "go.mau.fi/mautrix-meta/messagix/cookies" -) - -type provisioningContextKey int - -const ( - provisioningUserKey provisioningContextKey = iota -) - -type ProvisioningAPI struct { - bridge *MetaBridge - log zerolog.Logger -} - -func (prov *ProvisioningAPI) Init() { - prov.log.Debug().Str("prefix", prov.bridge.Config.Bridge.Provisioning.Prefix).Msg("Enabling provisioning API") - r := prov.bridge.AS.Router.PathPrefix(prov.bridge.Config.Bridge.Provisioning.Prefix).Subrouter() - r.Use(hlog.NewHandler(prov.log)) - r.Use(requestlog.AccessLogger(true)) - r.Use(prov.AuthMiddleware) - r.HandleFunc("/v1/login", prov.Login).Methods(http.MethodPost) - r.HandleFunc("/v1/logout", prov.Logout).Methods(http.MethodPost) - - if prov.bridge.Config.Bridge.Provisioning.DebugEndpoints { - prov.log.Debug().Msg("Enabling debug API at /debug") - r := prov.bridge.AS.Router.PathPrefix("/debug").Subrouter() - r.Use(prov.AuthMiddleware) - r.PathPrefix("/pprof").Handler(http.DefaultServeMux) - } -} - -func jsonResponse(w http.ResponseWriter, status int, response any) { - w.Header().Add("Content-Type", "application/json") - w.WriteHeader(status) - _ = json.NewEncoder(w).Encode(response) -} - -func (prov *ProvisioningAPI) AuthMiddleware(h http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - auth := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") - if auth != prov.bridge.Config.Bridge.Provisioning.SharedSecret { - zerolog.Ctx(r.Context()).Warn().Msg("Authentication token does not match shared secret") - jsonResponse(w, http.StatusForbidden, &mautrix.RespError{ - Err: "Authentication token does not match shared secret", - ErrCode: mautrix.MForbidden.ErrCode, - }) - return - } - userID := r.URL.Query().Get("user_id") - user := prov.bridge.GetUserByMXID(id.UserID(userID)) - h.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), provisioningUserKey, user))) - }) -} - -type Error struct { - Success bool `json:"success"` - Error string `json:"error"` - ErrCode string `json:"errcode"` -} - -type Response struct { - Success bool `json:"success"` - Status string `json:"status"` -} - -func (prov *ProvisioningAPI) Login(w http.ResponseWriter, r *http.Request) { - user := r.Context().Value(provisioningUserKey).(*User) - log := prov.log.With(). - Str("action", "login"). - Str("user_id", user.MXID.String()). - Logger() - ctx := log.WithContext(r.Context()) - var newCookies cookies.Cookies - newCookies.Platform = database.MessagixPlatform - err := json.NewDecoder(r.Body).Decode(&newCookies) - if err != nil { - jsonResponse(w, http.StatusBadRequest, Error{ErrCode: mautrix.MBadJSON.ErrCode, Error: err.Error()}) - return - } - missingRequiredCookies := newCookies.GetMissingCookieNames() - if len(missingRequiredCookies) > 0 { - log.Debug().Any("missing_cookies", missingRequiredCookies).Msg("Missing cookies in login request") - jsonResponse(w, http.StatusBadRequest, Error{ErrCode: mautrix.MBadJSON.ErrCode, Error: fmt.Sprintf("Missing cookies: %v", missingRequiredCookies)}) - return - } - err = user.Login(ctx, &newCookies) - if err != nil { - log.Err(err).Msg("Failed to log in") - if errors.Is(err, messagix.ErrChallengeRequired) { - jsonResponse(w, http.StatusBadRequest, Error{ErrCode: "FI.MAU.META_CHALLENGE_ERROR", Error: "Challenge required, please check the Instagram website and then try again"}) - } else if errors.Is(err, messagix.ErrConsentRequired) { - if prov.bridge.Config.Meta.Mode.IsMessenger() { - jsonResponse(w, http.StatusBadRequest, Error{ErrCode: "FI.MAU.META_CONSENT_ERROR", Error: "Consent required, please check the Facebook website and then try again"}) - } else { - jsonResponse(w, http.StatusBadRequest, Error{ErrCode: "FI.MAU.META_CONSENT_ERROR", Error: "Consent required, please check the Instagram website and then try again"}) - } - } else if errors.Is(err, messagix.ErrTokenInvalidated) { - jsonResponse(w, http.StatusBadRequest, Error{ErrCode: "FI.MAU.META_TOKEN_ERROR", Error: "Got logged out immediately"}) - } else { - jsonResponse(w, http.StatusInternalServerError, Error{ErrCode: "M_UNKNOWN", Error: "Internal error logging in"}) - } - } else { - jsonResponse(w, http.StatusOK, Response{ - Success: true, - Status: "logged_in", - }) - } -} - -func (prov *ProvisioningAPI) Logout(w http.ResponseWriter, r *http.Request) { - user := r.Context().Value(provisioningUserKey).(*User) - user.DeleteSession() - jsonResponse(w, http.StatusOK, Response{ - Success: true, - Status: "logged_out", - }) -} diff --git a/puppet.go b/puppet.go deleted file mode 100644 index 4f6ce95..0000000 --- a/puppet.go +++ /dev/null @@ -1,346 +0,0 @@ -// mautrix-meta - A Matrix-Facebook Messenger and Instagram DM puppeting bridge. -// Copyright (C) 2024 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package main - -import ( - "context" - "fmt" - "regexp" - "strconv" - "sync" - - "github.com/rs/zerolog" - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/appservice" - "maunium.net/go/mautrix/bridge" - "maunium.net/go/mautrix/id" - - "go.mau.fi/mautrix-meta/config" - "go.mau.fi/mautrix-meta/database" - "go.mau.fi/mautrix-meta/messagix/socket" - "go.mau.fi/mautrix-meta/messagix/types" - "go.mau.fi/mautrix-meta/msgconv" -) - -func (br *MetaBridge) GetPuppetByMXID(mxid id.UserID) *Puppet { - userID, ok := br.ParsePuppetMXID(mxid) - if !ok { - return nil - } - - return br.GetPuppetByID(userID) -} - -func (br *MetaBridge) GetPuppetByID(id int64) *Puppet { - br.puppetsLock.Lock() - defer br.puppetsLock.Unlock() - if id == 0 { - panic("User ID not provided") - } - - puppet, ok := br.puppets[id] - if !ok { - dbPuppet, err := br.DB.Puppet.GetByID(context.TODO(), id) - if err != nil { - br.ZLog.Err(err).Msg("Failed to get puppet from database") - return nil - } - return br.loadPuppet(context.TODO(), dbPuppet, &id) - } - return puppet -} - -func (br *MetaBridge) GetPuppetByCustomMXID(mxid id.UserID) *Puppet { - br.puppetsLock.Lock() - defer br.puppetsLock.Unlock() - - puppet, ok := br.puppetsByCustomMXID[mxid] - if !ok { - dbPuppet, err := br.DB.Puppet.GetByCustomMXID(context.TODO(), mxid) - if err != nil { - br.ZLog.Err(err).Msg("Failed to get puppet from database") - return nil - } - return br.loadPuppet(context.TODO(), dbPuppet, nil) - } - return puppet -} - -func (br *MetaBridge) GetAllPuppetsWithCustomMXID() []*Puppet { - puppets, err := br.DB.Puppet.GetAllWithCustomMXID(context.TODO()) - if err != nil { - br.ZLog.Error().Err(err).Msg("Failed to get all puppets with custom MXID") - return nil - } - return br.dbPuppetsToPuppets(puppets) -} - -func (br *MetaBridge) FormatPuppetMXID(userID int64) id.UserID { - return id.NewUserID( - br.Config.Bridge.FormatUsername(strconv.FormatInt(userID, 10)), - br.Config.Homeserver.Domain, - ) -} - -func (br *MetaBridge) loadPuppet(ctx context.Context, dbPuppet *database.Puppet, userID *int64) *Puppet { - if dbPuppet == nil { - if userID == nil { - return nil - } - dbPuppet = br.DB.Puppet.New() - dbPuppet.ID = *userID - err := dbPuppet.Insert(ctx) - if err != nil { - br.ZLog.Error().Err(err).Int64("user_id", *userID).Msg("Failed to insert new puppet") - return nil - } - } - - puppet := br.NewPuppet(dbPuppet) - br.puppets[puppet.ID] = puppet - if puppet.CustomMXID != "" { - br.puppetsByCustomMXID[puppet.CustomMXID] = puppet - } - return puppet -} - -func (br *MetaBridge) dbPuppetsToPuppets(dbPuppets []*database.Puppet) []*Puppet { - br.puppetsLock.Lock() - defer br.puppetsLock.Unlock() - - output := make([]*Puppet, len(dbPuppets)) - for index, dbPuppet := range dbPuppets { - if dbPuppet == nil { - continue - } - puppet, ok := br.puppets[dbPuppet.ID] - if !ok { - puppet = br.loadPuppet(context.TODO(), dbPuppet, nil) - } - output[index] = puppet - } - return output -} - -func (br *MetaBridge) NewPuppet(dbPuppet *database.Puppet) *Puppet { - return &Puppet{ - Puppet: dbPuppet, - bridge: br, - log: br.ZLog.With().Int64("user_id", dbPuppet.ID).Logger(), - - MXID: br.FormatPuppetMXID(dbPuppet.ID), - } -} - -func (br *MetaBridge) ParsePuppetMXID(mxid id.UserID) (int64, bool) { - if userIDRegex == nil { - pattern := fmt.Sprintf( - "^@%s:%s$", - br.Config.Bridge.FormatUsername(`(\d+)`), - br.Config.Homeserver.Domain, - ) - userIDRegex = regexp.MustCompile(pattern) - } - - match := userIDRegex.FindStringSubmatch(string(mxid)) - if len(match) == 2 { - parsed, err := strconv.ParseInt(match[1], 10, 64) - if err != nil { - return 0, false - } - return parsed, true - } - - return 0, false -} - -type Puppet struct { - *database.Puppet - - bridge *MetaBridge - log zerolog.Logger - - MXID id.UserID - - customIntent *appservice.IntentAPI - customUser *User - - syncLock sync.Mutex - - triedFetchingInfo bool -} - -var userIDRegex *regexp.Regexp - -var ( - _ bridge.Ghost = (*Puppet)(nil) - _ bridge.GhostWithProfile = (*Puppet)(nil) -) - -func (puppet *Puppet) GetMXID() id.UserID { - return puppet.MXID -} - -func (puppet *Puppet) DefaultIntent() *appservice.IntentAPI { - return puppet.bridge.AS.Intent(puppet.MXID) -} - -func (puppet *Puppet) CustomIntent() *appservice.IntentAPI { - if puppet == nil { - return nil - } - return puppet.customIntent -} - -func (puppet *Puppet) IntentFor(portal *Portal) *appservice.IntentAPI { - if puppet != nil { - if puppet.customIntent == nil || (portal.IsPrivateChat() && portal.ThreadID == puppet.ID) { - return puppet.DefaultIntent() - } - return puppet.customIntent - } - return nil -} - -func (puppet *Puppet) GetDisplayname() string { - return puppet.Name -} - -func (puppet *Puppet) GetAvatarURL() id.ContentURI { - return puppet.AvatarURL -} - -func (puppet *Puppet) FetchAndUpdateInfoIfNecessary(ctx context.Context, via *User) { - if puppet.triedFetchingInfo || puppet.Name != "" { - return - } - puppet.triedFetchingInfo = true - zerolog.Ctx(ctx).Debug().Int64("via_user_meta_id", via.MetaID).Msg("Fetching and updating info for user") - resp, err := via.Client.ExecuteTasks(&socket.GetContactsFullTask{ - ContactID: puppet.ID, - }) - if err != nil { - zerolog.Ctx(ctx).Err(err).Int64("via_user_meta_id", via.MetaID).Msg("Failed to fetch info") - } else { - var gotInfo bool - for _, info := range resp.LSDeleteThenInsertContact { - if info.Id == puppet.ID { - puppet.UpdateInfo(ctx, info) - gotInfo = true - } else { - zerolog.Ctx(ctx).Warn().Int64("other_meta_id", info.Id).Msg("Got info for wrong user") - } - } - if !gotInfo { - zerolog.Ctx(ctx).Warn().Int64("via_user_meta_id", via.MetaID).Msg("Didn't get info for user") - } else { - zerolog.Ctx(ctx).Debug().Int64("via_user_meta_id", via.MetaID).Msg("Fetched and updated info for user") - } - } -} - -func (puppet *Puppet) UpdateInfo(ctx context.Context, info types.UserInfo) { - log := zerolog.Ctx(ctx).With(). - Str("function", "Puppet.UpdateInfo"). - Int64("user_id", puppet.ID). - Logger() - ctx = log.WithContext(ctx) - var err error - - log.Trace().Msg("Updating puppet info") - - update := false - if info.GetUsername() != "" && puppet.Username != info.GetUsername() { - puppet.Username = info.GetUsername() - update = true - } - update = puppet.updateName(ctx, info.GetName(), puppet.Username) || update - _, isInitialData := info.(*types.CurrentUserInitialData) - if !isInitialData { - update = puppet.updateAvatar(ctx, info.GetAvatarURL()) || update - } - if update { - puppet.ContactInfoSet = false - puppet.UpdateContactInfo(ctx) - err = puppet.Update(ctx) - if err != nil { - log.Err(err).Msg("Failed to save puppet to database after updating") - } - go puppet.updatePortalMeta(ctx) - log.Debug().Msg("Puppet info updated") - } -} - -func (puppet *Puppet) UpdateContactInfo(ctx context.Context) { - if !puppet.bridge.SpecVersions.Supports(mautrix.BeeperFeatureArbitraryProfileMeta) || puppet.ContactInfoSet { - return - } - - identifiers := make([]string, 0, 1) - if puppet.Username != "" { - identifiers = append(identifiers, fmt.Sprintf("%s:%s", puppet.bridge.BeeperNetworkName, puppet.Username)) - } - contactInfo := map[string]any{ - "com.beeper.bridge.identifiers": identifiers, - "com.beeper.bridge.remote_id": puppet.ID, - "com.beeper.bridge.service": puppet.bridge.BeeperServiceName, - "com.beeper.bridge.network": puppet.bridge.BeeperNetworkName, - } - err := puppet.DefaultIntent().BeeperUpdateProfile(ctx, contactInfo) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to store custom contact info in profile") - } else { - puppet.ContactInfoSet = true - } -} - -func (puppet *Puppet) updatePortalMeta(ctx context.Context) { - for _, portal := range puppet.bridge.FindPrivateChatPortalsWith(puppet.ID) { - // Get room create lock to prevent races between receiving contact info and room creation. - portal.roomCreateLock.Lock() - portal.UpdateInfoFromPuppet(ctx, puppet) - portal.roomCreateLock.Unlock() - } -} - -func (puppet *Puppet) updateAvatar(ctx context.Context, avatarURL string) bool { - return msgconv.UpdateAvatar( - ctx, avatarURL, - &puppet.AvatarID, &puppet.AvatarSet, &puppet.AvatarURL, - puppet.DefaultIntent().UploadBytes, puppet.DefaultIntent().SetAvatarURL, - ) -} - -func (puppet *Puppet) updateName(ctx context.Context, name, username string) bool { - newName := puppet.bridge.Config.Bridge.FormatDisplayname(config.DisplaynameParams{ - DisplayName: name, - Username: username, - ID: puppet.ID, - }) - if puppet.NameSet && puppet.Name == newName { - return false - } - puppet.Name = newName - puppet.NameSet = false - err := puppet.DefaultIntent().SetDisplayName(ctx, newName) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to update user displayname") - } else { - puppet.NameSet = true - } - return true -} diff --git a/user.go b/user.go deleted file mode 100644 index abcd54b..0000000 --- a/user.go +++ /dev/null @@ -1,1314 +0,0 @@ -// mautrix-meta - A Matrix-Facebook Messenger and Instagram DM puppeting bridge. -// Copyright (C) 2024 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package main - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "net/http" - "net/url" - "runtime/debug" - "strconv" - "strings" - "sync" - "sync/atomic" - "time" - - "github.com/rs/zerolog" - "go.mau.fi/whatsmeow" - "go.mau.fi/whatsmeow/store" - waTypes "go.mau.fi/whatsmeow/types" - "go.mau.fi/whatsmeow/types/events" - "golang.org/x/exp/maps" - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/appservice" - "maunium.net/go/mautrix/bridge" - "maunium.net/go/mautrix/bridge/bridgeconfig" - "maunium.net/go/mautrix/bridge/commands" - "maunium.net/go/mautrix/bridge/status" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/format" - "maunium.net/go/mautrix/id" - "maunium.net/go/mautrix/pushrules" - - "go.mau.fi/mautrix-meta/database" - "go.mau.fi/mautrix-meta/messagix" - "go.mau.fi/mautrix-meta/messagix/cookies" - "go.mau.fi/mautrix-meta/messagix/socket" - "go.mau.fi/mautrix-meta/messagix/table" - "go.mau.fi/mautrix-meta/messagix/types" -) - -var ( - ErrNotConnected = errors.New("not connected") - ErrNotLoggedIn = errors.New("not logged in") -) - -const setDisconnectStateAfterConnectAttempts = 10 - -func (br *MetaBridge) GetUserByMXID(userID id.UserID) *User { - return br.maybeGetUserByMXID(userID, &userID) -} - -func (br *MetaBridge) GetUserByMXIDIfExists(userID id.UserID) *User { - return br.maybeGetUserByMXID(userID, nil) -} - -func (br *MetaBridge) maybeGetUserByMXID(userID id.UserID, userIDPtr *id.UserID) *User { - if userID == br.Bot.UserID || br.IsGhost(userID) { - return nil - } - br.usersLock.Lock() - defer br.usersLock.Unlock() - - user, ok := br.usersByMXID[userID] - if !ok { - dbUser, err := br.DB.User.GetByMXID(context.TODO(), userID) - if err != nil { - br.ZLog.Err(err).Msg("Failed to get user from database") - return nil - } - return br.loadUser(context.TODO(), dbUser, userIDPtr) - } - return user -} - -func (br *MetaBridge) GetUserByMetaID(id int64) *User { - br.usersLock.Lock() - defer br.usersLock.Unlock() - - user, ok := br.usersByMetaID[id] - if !ok { - dbUser, err := br.DB.User.GetByMetaID(context.TODO(), id) - if err != nil { - br.ZLog.Err(err).Msg("Failed to get user from database") - return nil - } - return br.loadUser(context.TODO(), dbUser, nil) - } - return user -} - -func (br *MetaBridge) GetAllLoggedInUsers() []*User { - br.usersLock.Lock() - defer br.usersLock.Unlock() - - dbUsers, err := br.DB.User.GetAllLoggedIn(context.TODO()) - if err != nil { - br.ZLog.Err(err).Msg("Error getting all logged in users") - return nil - } - users := make([]*User, len(dbUsers)) - - for idx, dbUser := range dbUsers { - user, ok := br.usersByMXID[dbUser.MXID] - if !ok { - user = br.loadUser(context.TODO(), dbUser, nil) - } - users[idx] = user - } - return users -} - -func (br *MetaBridge) loadUser(ctx context.Context, dbUser *database.User, mxid *id.UserID) *User { - if dbUser == nil { - if mxid == nil { - return nil - } - dbUser = br.DB.User.New() - dbUser.MXID = *mxid - err := dbUser.Insert(ctx) - if err != nil { - br.ZLog.Err(err).Msg("Error creating user %s") - return nil - } - } - - user := br.NewUser(dbUser) - br.usersByMXID[user.MXID] = user - if user.MetaID != 0 { - br.usersByMetaID[user.MetaID] = user - } - if user.ManagementRoom != "" { - br.managementRoomsLock.Lock() - br.managementRooms[user.ManagementRoom] = user - br.managementRoomsLock.Unlock() - } - return user -} - -func (br *MetaBridge) NewUser(dbUser *database.User) *User { - user := &User{ - User: dbUser, - bridge: br, - log: br.ZLog.With().Stringer("user_id", dbUser.MXID).Logger(), - - PermissionLevel: br.Config.Bridge.Permissions.Get(dbUser.MXID), - - incomingTables: make(chan *table.LSTable, 8), - } - user.Admin = user.PermissionLevel >= bridgeconfig.PermissionLevelAdmin - user.BridgeState = br.NewBridgeStateQueue(user) - go user.handleTablesLoop() - return user -} - -const ( - WADisconnected status.BridgeStateErrorCode = "wa-transient-disconnect" - WAPermanentError status.BridgeStateErrorCode = "wa-unknown-permanent-error" - WACATError status.BridgeStateErrorCode = "wa-cat-refresh-error" - MetaConnectionUnauthorized status.BridgeStateErrorCode = "meta-connection-unauthorized" - MetaPermanentError status.BridgeStateErrorCode = "meta-unknown-permanent-error" - MetaCookieRemoved status.BridgeStateErrorCode = "meta-cookie-removed" - MetaConnectError status.BridgeStateErrorCode = "meta-connect-error" - MetaTransientDisconnect status.BridgeStateErrorCode = "meta-transient-disconnect" - IGChallengeRequired status.BridgeStateErrorCode = "ig-challenge-required" - IGChallengeRequiredMaybe status.BridgeStateErrorCode = "ig-challenge-required-maybe" - IGAccountSuspended status.BridgeStateErrorCode = "ig-account-suspended" - MetaServerUnavailable status.BridgeStateErrorCode = "meta-server-unavailable" - IGConsentRequired status.BridgeStateErrorCode = "ig-consent-required" - FBConsentRequired status.BridgeStateErrorCode = "fb-consent-required" -) - -func init() { - status.BridgeStateHumanErrors.Update(status.BridgeStateErrorMap{ - WADisconnected: "Disconnected from encrypted chat server. Trying to reconnect.", - MetaTransientDisconnect: "Disconnected from server, trying to reconnect", - MetaConnectionUnauthorized: "Logged out, please relogin to continue", - MetaCookieRemoved: "Logged out, please relogin to continue", - IGAccountSuspended: "Logged out, please check the Instagram website to continue", - IGChallengeRequired: "Challenge required, please check the Instagram website to continue", - IGChallengeRequiredMaybe: "Connection refused, please check the Instagram website to continue", - IGConsentRequired: "Consent required, please check the Instagram website to continue", - FBConsentRequired: "Consent required, please check the Facebook website to continue", - MetaServerUnavailable: "Connection refused by server", - MetaConnectError: "Unknown connection error", - }) -} - -type User struct { - *database.User - - sync.Mutex - - bridge *MetaBridge - log zerolog.Logger - - Admin bool - PermissionLevel bridgeconfig.PermissionLevel - - commandState *commands.CommandState - - Client *messagix.Client - - WADevice *store.Device - E2EEClient *whatsmeow.Client - e2eeConnectLock sync.Mutex - - BridgeState *bridge.BridgeStateQueue - bridgeStateLock sync.Mutex - - waState status.BridgeState - metaState status.BridgeState - - spaceMembershipChecked bool - spaceCreateLock sync.Mutex - mgmtCreateLock sync.Mutex - - stopBackfillTask atomic.Pointer[context.CancelFunc] - - InboxPagesFetched int - - initialTable atomic.Pointer[table.LSTable] - incomingTables chan *table.LSTable - - lastFullReconnect time.Time - forceRefreshTimer *time.Timer -} - -var ( - _ bridge.User = (*User)(nil) - _ status.BridgeStateFiller = (*User)(nil) -) - -func (user *User) GetPermissionLevel() bridgeconfig.PermissionLevel { - return user.PermissionLevel -} - -func (user *User) IsLoggedIn() bool { - user.Lock() - defer user.Unlock() - - return user.Client != nil -} - -func (user *User) IsE2EEConnected() bool { - return user.E2EEClient != nil && user.E2EEClient.IsConnected() -} - -func (user *User) GetManagementRoomID() id.RoomID { - return user.ManagementRoom -} - -func (user *User) SetManagementRoom(roomID id.RoomID) { - user.bridge.managementRoomsLock.Lock() - defer user.bridge.managementRoomsLock.Unlock() - - existing, ok := user.bridge.managementRooms[roomID] - if ok { - existing.ManagementRoom = "" - err := existing.Update(context.TODO()) - if err != nil { - existing.log.Err(err).Msg("Failed to update user when removing management room") - } - } - - user.ManagementRoom = roomID - user.bridge.managementRooms[user.ManagementRoom] = user - err := user.Update(context.TODO()) - if err != nil { - user.log.Error().Err(err).Msg("Error setting management room") - } -} - -func (user *User) GetCommandState() *commands.CommandState { - return user.commandState -} - -func (user *User) SetCommandState(state *commands.CommandState) { - user.commandState = state -} - -func (user *User) GetIDoublePuppet() bridge.DoublePuppet { - p := user.bridge.GetPuppetByCustomMXID(user.MXID) - if p == nil || p.CustomIntent() == nil { - return nil - } - return p -} - -func (user *User) GetIGhost() bridge.Ghost { - p := user.bridge.GetPuppetByID(user.MetaID) - if p == nil { - return nil - } - return p -} - -func (user *User) ensureInvited(ctx context.Context, intent *appservice.IntentAPI, roomID id.RoomID, isDirect bool) (ok bool) { - log := user.log.With().Str("action", "ensure_invited").Stringer("room_id", roomID).Logger() - if user.bridge.StateStore.IsMembership(ctx, roomID, user.MXID, event.MembershipJoin) { - ok = true - return - } - extraContent := make(map[string]interface{}) - if isDirect { - extraContent["is_direct"] = true - } - customPuppet := user.bridge.GetPuppetByCustomMXID(user.MXID) - if customPuppet != nil && customPuppet.CustomIntent() != nil { - log.Debug().Msg("adding will_auto_accept to invite content") - extraContent["fi.mau.will_auto_accept"] = true - } else { - log.Debug().Msg("NOT adding will_auto_accept to invite content") - } - _, err := intent.InviteUser(ctx, roomID, &mautrix.ReqInviteUser{UserID: user.MXID}, extraContent) - var httpErr mautrix.HTTPError - if err != nil && errors.As(err, &httpErr) && httpErr.RespError != nil && strings.Contains(httpErr.RespError.Err, "is already in the room") { - err = user.bridge.StateStore.SetMembership(ctx, roomID, user.MXID, event.MembershipJoin) - if err != nil { - log.Warn().Err(err).Msg("Failed to update membership in state store") - } - ok = true - return - } else if err != nil { - log.Warn().Err(err).Msg("Failed to invite user to room") - } else { - ok = true - } - - if customPuppet != nil && customPuppet.CustomIntent() != nil { - log.Debug().Msg("ensuring custom puppet is joined") - err = customPuppet.CustomIntent().EnsureJoined(ctx, roomID, appservice.EnsureJoinedParams{IgnoreCache: true}) - if err != nil { - log.Warn().Err(err).Msg("Failed to auto-join custom puppet") - ok = false - } else { - ok = true - } - } - return -} - -func (user *User) sendMarkdownBridgeAlert(ctx context.Context, formatString string, args ...interface{}) { - if user.bridge.Config.Bridge.DisableBridgeAlerts { - return - } - notice := fmt.Sprintf(formatString, args...) - content := format.RenderMarkdown(notice, true, false) - _, err := user.bridge.Bot.SendMessageEvent(ctx, user.GetManagementRoom(ctx), event.EventMessage, content) - if err != nil { - user.log.Err(err).Str("notice_text", notice).Msg("Failed to send bridge alert") - } -} - -func (user *User) GetManagementRoom(ctx context.Context) id.RoomID { - if len(user.ManagementRoom) == 0 { - user.mgmtCreateLock.Lock() - defer user.mgmtCreateLock.Unlock() - if len(user.ManagementRoom) > 0 { - return user.ManagementRoom - } - creationContent := make(map[string]interface{}) - if !user.bridge.Config.Bridge.FederateRooms { - creationContent["m.federate"] = false - } - resp, err := user.bridge.Bot.CreateRoom(ctx, &mautrix.ReqCreateRoom{ - Topic: user.bridge.ProtocolName + " bridge notices", - IsDirect: true, - CreationContent: creationContent, - }) - if err != nil { - user.log.Err(err).Msg("Failed to auto-create management room") - } else { - user.SetManagementRoom(resp.RoomID) - } - } - return user.ManagementRoom -} - -func (user *User) GetSpaceRoom(ctx context.Context) id.RoomID { - if !user.bridge.Config.Bridge.PersonalFilteringSpaces { - return "" - } - - if len(user.SpaceRoom) == 0 { - user.spaceCreateLock.Lock() - defer user.spaceCreateLock.Unlock() - if len(user.SpaceRoom) > 0 { - return user.SpaceRoom - } - - resp, err := user.bridge.Bot.CreateRoom(ctx, &mautrix.ReqCreateRoom{ - Visibility: "private", - Name: user.bridge.ProtocolName, - Topic: "Your " + user.bridge.ProtocolName + " bridged chats", - InitialState: []*event.Event{{ - Type: event.StateRoomAvatar, - Content: event.Content{ - Parsed: &event.RoomAvatarEventContent{ - URL: user.bridge.Config.AppService.Bot.ParsedAvatar.CUString(), - }, - }, - }}, - CreationContent: map[string]interface{}{ - "type": event.RoomTypeSpace, - }, - PowerLevelOverride: &event.PowerLevelsEventContent{ - Users: map[id.UserID]int{ - user.bridge.Bot.UserID: 9001, - user.MXID: 50, - }, - }, - }) - - if err != nil { - user.log.Err(err).Msg("Failed to auto-create space room") - } else { - user.SpaceRoom = resp.RoomID - err = user.Update(context.TODO()) - if err != nil { - user.log.Err(err).Msg("Failed to save user in database after creating space room") - } - user.ensureInvited(ctx, user.bridge.Bot, user.SpaceRoom, false) - } - } else if !user.spaceMembershipChecked { - user.ensureInvited(ctx, user.bridge.Bot, user.SpaceRoom, false) - } - user.spaceMembershipChecked = true - - return user.SpaceRoom -} - -func (user *User) updateChatMute(ctx context.Context, portal *Portal, mutedUntil int64, isNew bool) { - if portal == nil || len(portal.MXID) == 0 || user.bridge.Config.Bridge.MuteBridging == "never" { - // If the chat isn't bridged or the mute bridging option is never, don't do anything - return - } else if !isNew && user.bridge.Config.Bridge.MuteBridging != "always" { - // If the chat isn't new and the mute bridging option is on-create, don't do anything - return - } else if mutedUntil == 0 && isNew { - // No need to unmute new chats - return - } - doublePuppet := user.bridge.GetPuppetByCustomMXID(user.MXID) - if doublePuppet == nil || doublePuppet.CustomIntent() == nil { - return - } - log := user.log.With(). - Str("action", "update chat mute"). - Int64("thread_id", portal.ThreadID). - Stringer("portal_mxid", portal.MXID). - Int64("muted_until", mutedUntil). - Logger() - intent := doublePuppet.CustomIntent() - var err error - now := time.Now().UnixMilli() - if mutedUntil >= 0 && mutedUntil < now { - log.Debug().Msg("Unmuting chat") - err = intent.DeletePushRule(ctx, "global", pushrules.RoomRule, string(portal.MXID)) - } else { - log.Debug().Msg("Muting chat") - err = intent.PutPushRule(ctx, "global", pushrules.RoomRule, string(portal.MXID), &mautrix.ReqPutPushRule{ - Actions: []pushrules.PushActionType{pushrules.ActionDontNotify}, - }) - } - if err != nil && !errors.Is(err, mautrix.MNotFound) { - log.Err(err).Msg("Failed to update push rule through double puppet") - } -} - -func (user *User) GetMXID() id.UserID { - return user.MXID -} - -func (user *User) Connect() { - user.Lock() - defer user.Unlock() - user.unlockedConnect() -} - -func (user *User) unlockedConnect() { - user.BridgeState.Send(status.BridgeState{StateEvent: status.StateConnecting}) - err := user.unlockedConnectWithCookies(user.Cookies) - if err != nil { - user.log.Error().Err(err).Msg("Failed to connect") - if errors.Is(err, messagix.ErrTokenInvalidated) { - user.BridgeState.Send(status.BridgeState{ - StateEvent: status.StateBadCredentials, - Error: MetaCookieRemoved, - }) - // TODO clear cookies? - } else if errors.Is(err, messagix.ErrChallengeRequired) { - user.BridgeState.Send(status.BridgeState{ - StateEvent: status.StateBadCredentials, - Error: IGChallengeRequired, - }) - } else if errors.Is(err, messagix.ErrAccountSuspended) { - user.BridgeState.Send(status.BridgeState{ - StateEvent: status.StateBadCredentials, - Error: IGAccountSuspended, - }) - } else if errors.Is(err, messagix.ErrConsentRequired) { - code := IGConsentRequired - if user.bridge.Config.Meta.Mode.IsMessenger() { - code = FBConsentRequired - } - user.BridgeState.Send(status.BridgeState{ - StateEvent: status.StateBadCredentials, - Error: code, - }) - } else if lsErr := (&types.ErrorResponse{}); errors.As(err, &lsErr) { - stateEvt := status.StateUnknownError - if lsErr.ErrorCode == 1357053 { - stateEvt = status.StateBadCredentials - } - user.BridgeState.Send(status.BridgeState{ - StateEvent: stateEvt, - Error: status.BridgeStateErrorCode(fmt.Sprintf("meta-lserror-%d", lsErr.ErrorCode)), - Message: lsErr.Error(), - }) - } else { - user.BridgeState.Send(status.BridgeState{ - StateEvent: status.StateUnknownError, - Error: MetaConnectError, - }) - } - go user.sendMarkdownBridgeAlert(context.TODO(), "Failed to connect to %s: %v", user.bridge.ProtocolName, err) - return - } - - refreshInterval := time.Duration(user.bridge.Config.Meta.ForceRefreshIntervalSeconds) * time.Second - if refreshInterval > 0 { - go func() { - user.log.Debug().Time("next_refresh", time.Now().Add(refreshInterval)).Msg("Setting force refresh timer") - user.forceRefreshTimer = time.NewTimer(refreshInterval) - - <-user.forceRefreshTimer.C - user.log.Info().Msg("Refreshing connection") - user.FullReconnect() - }() - } -} - -func (user *User) Login(ctx context.Context, cookies *cookies.Cookies) error { - user.Lock() - defer user.Unlock() - - err := cookies.GeneratePushKeys() - if err != nil { - return err - } - - err = user.unlockedConnectWithCookies(cookies) - if err != nil { - return err - } - user.Cookies = cookies - err = user.Update(ctx) - if err != nil { - user.log.Err(err).Msg("Failed to update user") - return err - } - return nil -} - -type respGetProxy struct { - ProxyURL string `json:"proxy_url"` -} - -func (user *User) getProxy(reason string) (string, error) { - if user.bridge.Config.Meta.GetProxyFrom == "" { - return user.bridge.Config.Meta.Proxy, nil - } - parsed, err := url.Parse(user.bridge.Config.Meta.GetProxyFrom) - if err != nil { - return "", fmt.Errorf("failed to parse address: %w", err) - } - q := parsed.Query() - q.Set("reason", reason) - parsed.RawQuery = q.Encode() - req, err := http.NewRequest(http.MethodGet, parsed.String(), nil) - if err != nil { - return "", fmt.Errorf("failed to prepare request: %w", err) - } - req.Header.Set("User-Agent", mautrix.DefaultUserAgent) - resp, err := http.DefaultClient.Do(req) - if err != nil { - return "", fmt.Errorf("failed to send request: %w", err) - } else if resp.StatusCode >= 300 || resp.StatusCode < 200 { - return "", fmt.Errorf("unexpected status code %d", resp.StatusCode) - } - var respData respGetProxy - err = json.NewDecoder(resp.Body).Decode(&respData) - if err != nil { - return "", fmt.Errorf("failed to decode response: %w", err) - } - return respData.ProxyURL, nil -} - -func (user *User) unlockedConnectWithCookies(cookies *cookies.Cookies) error { - if cookies == nil { - return fmt.Errorf("no cookies provided") - } - - log := user.log.With().Str("component", "messagix").Logger() - user.log.Debug().Msg("Connecting to Meta") - // TODO set proxy for media client? - cli := messagix.NewClient(cookies, log) - if user.bridge.Config.Meta.GetProxyFrom != "" || user.bridge.Config.Meta.Proxy != "" { - cli.GetNewProxy = user.getProxy - if !cli.UpdateProxy("connect") { - return fmt.Errorf("failed to update proxy") - } - } - currentUser, initialTable, err := cli.LoadMessagesPage() - if err != nil { - return err - } - user.saveInitialTable(currentUser, initialTable) - cli.SetEventHandler(user.eventHandler) - // This needs to be set before Event_Ready is handled in eventHandler - user.Client = cli - err = cli.Connect() - if err != nil { - user.Client = nil - return fmt.Errorf("failed to connect: %w", err) - } - user.lastFullReconnect = time.Now() - return nil -} - -func (br *MetaBridge) StartUsers() { - br.ZLog.Debug().Msg("Starting users") - - usersWithToken := br.GetAllLoggedInUsers() - for _, u := range usersWithToken { - go u.Connect() - } - if len(usersWithToken) == 0 { - br.SendGlobalBridgeState(status.BridgeState{StateEvent: status.StateUnconfigured}.Fill(nil)) - } - - br.ZLog.Debug().Msg("Starting custom puppets") - for _, customPuppet := range br.GetAllPuppetsWithCustomMXID() { - go func(puppet *Puppet) { - br.ZLog.Debug().Stringer("user_id", puppet.CustomMXID).Msg("Starting custom puppet") - - if err := puppet.StartCustomMXID(true); err != nil { - puppet.log.Error().Err(err).Msg("Failed to start custom puppet") - } - }(customPuppet) - } -} - -func (user *User) handleTablesLoop() { - for tbl := range user.incomingTables { - if tbl == nil { - user.log.Debug().Msg("Received nil table, stopping table handling") - return - } - user.handleTable(tbl) - } -} - -func (user *User) handleTable(tbl *table.LSTable) { - defer func() { - if err := recover(); err != nil { - user.log.Error(). - Str("action", "handle table"). - Bytes(zerolog.ErrorStackFieldName, debug.Stack()). - Any(zerolog.ErrorFieldName, err). - Msg("Panic in Meta table handler") - } - }() - log := user.log.With().Str("action", "handle table").Logger() - ctx := log.WithContext(context.TODO()) - for _, contact := range tbl.LSDeleteThenInsertContact { - user.bridge.GetPuppetByID(contact.Id).UpdateInfo(ctx, contact) - } - for _, contact := range tbl.LSVerifyContactRowExists { - user.bridge.GetPuppetByID(contact.ContactId).UpdateInfo(ctx, contact) - } - for _, thread := range tbl.LSDeleteThenInsertThread { - // TODO handle last read watermark in here? - portal := user.GetPortalByThreadID(thread.ThreadKey, thread.ThreadType) - portal.UpdateInfo(ctx, thread) - if portal.MXID == "" { - err := portal.CreateMatrixRoom(ctx, user) - if err != nil { - log.Err(err).Int64("thread_id", thread.ThreadKey).Msg("Failed to create matrix room") - } - go user.updateChatMute(ctx, portal, thread.MuteExpireTimeMs, true) - } else { - portal.ensureUserInvited(ctx, user) - go portal.addToPersonalSpace(portal.log.WithContext(context.TODO()), user) - go user.updateChatMute(ctx, portal, thread.MuteExpireTimeMs, false) - } - } - for _, participant := range tbl.LSAddParticipantIdToGroupThread { - portal := user.GetExistingPortalByThreadID(participant.ThreadKey) - if portal != nil && portal.MXID != "" && !portal.IsPrivateChat() { - puppet := user.bridge.GetPuppetByID(participant.ContactId) - err := puppet.IntentFor(portal).EnsureJoined(ctx, portal.MXID) - if err != nil { - log.Err(err). - Int64("thread_id", participant.ThreadKey). - Int64("contact_id", participant.ContactId). - Msg("Failed to ensure user is joined to thread") - } - } - } - for _, participant := range tbl.LSRemoveParticipantFromThread { - portal := user.GetExistingPortalByThreadID(participant.ThreadKey) - if portal != nil && portal.MXID != "" { - puppet := user.bridge.GetPuppetByID(participant.ParticipantId) - _, err := puppet.IntentFor(portal).LeaveRoom(ctx, portal.MXID) - if err != nil { - log.Err(err). - Int64("thread_id", participant.ThreadKey). - Int64("contact_id", participant.ParticipantId). - Msg("Failed to leave user from thread") - } - } - } - for _, thread := range tbl.LSVerifyThreadExists { - portal := user.GetPortalByThreadID(thread.ThreadKey, thread.ThreadType) - if portal.MXID != "" { - portal.ensureUserInvited(ctx, user) - go portal.addToPersonalSpace(ctx, user) - } else if !portal.fetchAttempted.Swap(true) { - log.Debug().Int64("thread_id", thread.ThreadKey).Msg("Sending create thread request for unknown thread in verifyThreadExists") - go func(thread *table.LSVerifyThreadExists) { - resp, err := user.Client.ExecuteTasks( - &socket.CreateThreadTask{ - ThreadFBID: thread.ThreadKey, - ForceUpsert: 0, - UseOpenMessengerTransport: 0, - SyncGroup: 1, - MetadataOnly: 0, - PreviewOnly: 0, - }, - ) - if err != nil { - log.Err(err).Int64("thread_id", thread.ThreadKey).Msg("Failed to execute create thread task for verifyThreadExists of unknown thread") - } else { - log.Debug().Int64("thread_id", thread.ThreadKey).Msg("Sent create thread request for unknown thread in verifyThreadExists") - log.Trace().Any("resp_data", resp).Int64("thread_id", thread.ThreadKey).Msg("Create thread response") - } - }(thread) - } else { - log.Warn().Int64("thread_id", thread.ThreadKey).Msg("Portal doesn't exist in verifyThreadExists, but fetch was already attempted") - } - } - for _, mute := range tbl.LSUpdateThreadMuteSetting { - portal := user.GetExistingPortalByThreadID(mute.ThreadKey) - go user.updateChatMute(ctx, portal, mute.MuteExpireTimeMS, false) - } - upsert, insert := tbl.WrapMessages() - handlePortalEvents(user, maps.Values(upsert)) - handlePortalEvents(user, tbl.LSUpdateExistingMessageRange) - handlePortalEvents(user, insert) - for _, msg := range tbl.LSEditMessage { - user.handleEditEvent(ctx, msg) - } - handlePortalEvents(user, tbl.LSSyncUpdateThreadName) - handlePortalEvents(user, tbl.LSSetThreadImageURL) - handlePortalEvents(user, tbl.LSUpdateReadReceipt) - handlePortalEvents(user, tbl.LSMarkThreadRead) - handlePortalEvents(user, tbl.LSUpdateTypingIndicator) - handlePortalEvents(user, tbl.LSDeleteMessage) - handlePortalEvents(user, tbl.LSDeleteThenInsertMessage) - handlePortalEvents(user, tbl.LSUpsertReaction) - handlePortalEvents(user, tbl.LSDeleteReaction) - handlePortalEvents(user, tbl.LSMoveThreadToE2EECutoverFolder) - handlePortalEvents(user, tbl.LSDeleteThread) - user.requestMoreInbox(ctx, tbl.LSUpsertInboxThreadsRange) -} - -func (user *User) requestMoreInbox(ctx context.Context, itrs []*table.LSUpsertInboxThreadsRange) { - if !user.bridge.Config.Bridge.Backfill.Enabled { - return - } - maxInboxPages := user.bridge.Config.Bridge.Backfill.InboxFetchPages - if len(itrs) == 0 || user.InboxFetched || maxInboxPages == 0 { - return - } - log := zerolog.Ctx(ctx) - if len(itrs) > 1 { - log.Warn().Any("thread_ranges", itrs).Msg("Got multiple thread ranges in upsertInboxThreadsRange") - } - itr := itrs[0] - user.InboxPagesFetched++ - reachedPageLimit := maxInboxPages > 0 && user.InboxPagesFetched > maxInboxPages - reachingOnePageLimit := maxInboxPages == 1 && user.InboxPagesFetched >= 1 - logEvt := log.Debug(). - Int("fetched_pages", user.InboxPagesFetched). - Bool("has_more_before", itr.HasMoreBefore). - Bool("reached_page_limit", reachedPageLimit). - Int64("min_thread_key", itr.MinThreadKey). - Int64("min_last_activity_timestamp_ms", itr.MinLastActivityTimestampMs) - shouldFetchMore := itr.HasMoreBefore && !reachedPageLimit - if !shouldFetchMore || reachingOnePageLimit { - if !shouldFetchMore { - logEvt.Msg("Finished fetching threads") - } else { - log.Debug().Msg("Marking inbox as fetched before requesting extra page of threads") - } - user.InboxFetched = true - err := user.Update(ctx) - if err != nil { - log.Err(err).Msg("Failed to save user after marking inbox as fetched") - } - } - if !shouldFetchMore { - return - } - logEvt.Msg("Requesting more threads") - resp, err := user.Client.ExecuteTasks(&socket.FetchThreadsTask{ - ReferenceThreadKey: itr.MinThreadKey, - ReferenceActivityTimestamp: itr.MinLastActivityTimestampMs, - Cursor: user.Client.SyncManager.GetCursor(1), - SyncGroup: 1, - }) - log.Trace().Any("resp", resp).Msg("Fetch threads response data") - if err != nil { - log.Err(err).Msg("Failed to fetch more threads") - } else { - log.Debug().Msg("Sent more threads request") - } -} - -type ThreadKeyable interface { - GetThreadKey() int64 -} - -func handlePortalEvents[T ThreadKeyable](user *User, msgs []T) { - for _, msg := range msgs { - user.handlePortalEvent(msg.GetThreadKey(), msg) - } -} - -func (user *User) handleEditEvent(ctx context.Context, evt *table.LSEditMessage) { - log := zerolog.Ctx(ctx).With().Str("message_id", evt.MessageID).Logger() - portalKey, err := user.bridge.DB.Message.FindEditTargetPortal(ctx, evt.MessageID, user.MetaID) - if err != nil { - log.Err(err).Msg("Failed to get portal of edited message") - return - } else if portalKey.ThreadID == 0 { - log.Warn().Msg("Edit target message not found") - return - } - portal := user.bridge.GetExistingPortalByThreadID(portalKey) - if portal == nil { - log.Warn().Int64("thread_id", portalKey.ThreadID).Msg("Portal for edit target message not found") - return - } - portal.metaMessages <- portalMetaMessage{user: user, evt: evt} -} - -func (user *User) handlePortalEvent(threadKey int64, evt any) { - portal := user.GetExistingPortalByThreadID(threadKey) - if portal != nil { - portal.metaMessages <- portalMetaMessage{user: user, evt: evt} - } else { - user.log.Warn(). - Int64("thread_id", threadKey). - Type("evt_type", evt). - Msg("Received event for unknown thread") - } -} - -func (user *User) GetRemoteID() string { - return strconv.FormatInt(user.MetaID, 10) -} - -func (user *User) GetRemoteName() string { - if user.MetaID != 0 { - puppet := user.bridge.GetPuppetByID(user.MetaID) - if puppet != nil { - return puppet.Name - } - return user.GetRemoteID() - } - return "" -} - -func (user *User) FillBridgeState(state status.BridgeState) status.BridgeState { - if state.StateEvent == status.StateConnected { - var copyFrom *status.BridgeState - if user.waState.StateEvent != "" && user.waState.StateEvent != status.StateConnected { - copyFrom = &user.waState - } - if user.metaState.StateEvent != "" && user.metaState.StateEvent != status.StateConnected { - copyFrom = &user.metaState - } - if copyFrom != nil { - state.StateEvent = copyFrom.StateEvent - state.Error = copyFrom.Error - state.Message = copyFrom.Message - } - } - return state -} - -func (user *User) connectE2EE() error { - user.e2eeConnectLock.Lock() - defer user.e2eeConnectLock.Unlock() - if user.E2EEClient != nil { - return fmt.Errorf("already connected to e2ee") - } - var err error - if user.WADevice == nil && user.WADeviceID != 0 { - user.WADevice, err = user.bridge.DeviceStore.GetDevice(waTypes.JID{User: strconv.FormatInt(user.MetaID, 10), Device: user.WADeviceID, Server: waTypes.MessengerServer}) - if err != nil { - return fmt.Errorf("failed to get whatsmeow device: %w", err) - } else if user.WADevice == nil { - user.log.Warn().Uint16("device_id", user.WADeviceID).Msg("Existing device not found in store") - } - } - isNew := false - if user.WADevice == nil { - isNew = true - user.WADevice = user.bridge.DeviceStore.NewDevice() - } - user.Client.SetDevice(user.WADevice) - - ctx := user.log.With().Str("component", "e2ee").Logger().WithContext(context.TODO()) - if isNew { - user.log.Info().Msg("Registering new e2ee device") - err = user.Client.RegisterE2EE(ctx, user.MetaID) - if err != nil { - return fmt.Errorf("failed to register e2ee device: %w", err) - } - user.WADeviceID = user.WADevice.ID.Device - err = user.WADevice.Save() - if err != nil { - return fmt.Errorf("failed to save whatsmeow device store: %w", err) - } - err = user.Update(ctx) - if err != nil { - return fmt.Errorf("failed to save device ID to user: %w", err) - } - } - user.E2EEClient = user.Client.PrepareE2EEClient() - user.E2EEClient.AddEventHandler(user.e2eeEventHandler) - err = user.E2EEClient.Connect() - if err != nil { - return fmt.Errorf("failed to connect to e2ee socket: %w", err) - } - return nil -} - -func (user *User) e2eeEventHandler(rawEvt any) { - switch evt := rawEvt.(type) { - case *events.FBMessage: - log := user.log.With(). - Str("action", "handle whatsapp message"). - Stringer("chat_jid", evt.Info.Chat). - Stringer("sender_jid", evt.Info.Sender). - Str("message_id", evt.Info.ID). - Logger() - ctx := log.WithContext(context.TODO()) - threadID := int64(evt.Info.Chat.UserInt()) - if threadID == 0 { - log.Warn().Msg("Ignoring encrypted message with unsupported jid") - return - } - var expectedType table.ThreadType - switch evt.Info.Chat.Server { - case waTypes.GroupServer: - expectedType = table.ENCRYPTED_OVER_WA_GROUP - case waTypes.MessengerServer, waTypes.DefaultUserServer: - expectedType = table.ENCRYPTED_OVER_WA_ONE_TO_ONE - default: - log.Warn().Msg("Unexpected chat server in encrypted message") - return - } - portal := user.GetPortalByThreadID(threadID, expectedType) - changed := false - if portal.ThreadType != expectedType { - log.Info(). - Int64("old_thread_type", int64(portal.ThreadType)). - Int64("new_thread_type", int64(expectedType)). - Msg("Updating thread type") - portal.ThreadType = expectedType - changed = true - } - if portal.WhatsAppServer != evt.Info.Chat.Server { - log.Info(). - Str("old_server", portal.WhatsAppServer). - Str("new_server", evt.Info.Chat.Server). - Msg("Updating WhatsApp server") - portal.WhatsAppServer = evt.Info.Chat.Server - changed = true - } - if changed { - err := portal.Update(ctx) - if err != nil { - log.Err(err).Msg("Failed to update portal") - } - } - portal.metaMessages <- portalMetaMessage{user: user, evt: evt} - case *events.Receipt: - portal := user.GetExistingPortalByThreadID(int64(evt.Chat.UserInt())) - if portal != nil { - portal.metaMessages <- portalMetaMessage{user: user, evt: evt} - } - case *events.Connected: - user.log.Debug().Msg("Connected to WhatsApp socket") - user.waState = status.BridgeState{StateEvent: status.StateConnected} - user.BridgeState.Send(user.waState) - case *events.Disconnected: - user.log.Debug().Msg("Disconnected from WhatsApp socket") - user.waState = status.BridgeState{ - StateEvent: status.StateTransientDisconnect, - Error: WADisconnected, - } - user.BridgeState.Send(user.waState) - case *events.CATRefreshError: - if errors.Is(evt.Error, types.ErrPleaseReloadPage) && user.canReconnect() { - user.log.Err(evt.Error).Msg("Got CATRefreshError, reloading page") - go user.FullReconnect() - return - } - user.waState = status.BridgeState{ - StateEvent: status.StateUnknownError, - Error: WACATError, - Message: evt.PermanentDisconnectDescription(), - } - user.BridgeState.Send(user.waState) - go user.sendMarkdownBridgeAlert(context.TODO(), "Error in WhatsApp connection: %s", evt.PermanentDisconnectDescription()) - case events.PermanentDisconnect: - stateEvent := status.StateUnknownError - - switch e := evt.(type) { - case *events.LoggedOut: - stateEvent = status.StateBadCredentials - if e.Reason == events.ConnectFailureLoggedOut && !e.OnConnect && user.canReconnect() { - user.resetWADevice() - user.log.Debug().Msg("Doing full reconnect after WhatsApp 401 error") - go user.FullReconnect() - } - case *events.ConnectFailure: - if e.Reason == events.ConnectFailureNotFound { - if user.E2EEClient != nil { - user.E2EEClient.Disconnect() - user.WADevice.Delete() - user.resetWADevice() - user.E2EEClient = nil - } - user.log.Debug().Msg("Reconnecting e2ee client after WhatsApp 415 error") - go user.connectE2EE() - } else if e.Reason == events.ConnectFailureReason(418) { - // WA 418 appears to indicate logout/bad credentials - stateEvent = status.StateBadCredentials - user.resetWADevice() - user.log.Debug().Msg("Doing full reconnect after WhatsApp 418 error") - go user.FullReconnect() - } - } - - user.waState = status.BridgeState{ - StateEvent: stateEvent, - Error: WAPermanentError, - Message: evt.PermanentDisconnectDescription(), - } - user.BridgeState.Send(user.waState) - go user.sendMarkdownBridgeAlert(context.TODO(), "Error in WhatsApp connection: %s", evt.PermanentDisconnectDescription()) - default: - user.log.Debug().Type("event_type", rawEvt).Msg("Unhandled WhatsApp event") - } -} - -func (user *User) resetWADevice() { - user.WADevice = nil - user.WADeviceID = 0 -} - -func (user *User) saveInitialTable(currentUser types.UserInfo, tbl *table.LSTable) { - var newFBID int64 - // TODO figure out why the contact IDs for self is different than the fbid in the ready event - for _, row := range tbl.LSVerifyContactRowExists { - if row.IsSelf && row.ContactId != newFBID { - if newFBID != 0 { - // Hopefully this won't happen - user.log.Warn().Int64("prev_fbid", newFBID).Int64("new_fbid", row.ContactId).Msg("Got multiple fbids for self") - } else { - user.log.Debug().Int64("fbid", row.ContactId).Msg("Found own fbid") - } - newFBID = row.ContactId - } - } - if newFBID == 0 { - newFBID = currentUser.GetFBID() - user.log.Warn().Int64("fbid", newFBID).Msg("Own contact entry not found, falling back to fbid in current user object") - } - if user.MetaID != newFBID { - user.bridge.usersLock.Lock() - user.MetaID = newFBID - // TODO check if there's another user? - user.bridge.usersByMetaID[user.MetaID] = user - user.bridge.usersLock.Unlock() - err := user.Update(context.TODO()) - if err != nil { - user.log.Err(err).Msg("Failed to save user after getting meta ID") - } - } - puppet := user.bridge.GetPuppetByID(user.MetaID) - puppet.UpdateInfo(context.TODO(), currentUser) - user.tryAutomaticDoublePuppeting() - user.initialTable.Store(tbl) -} - -func (user *User) eventHandler(rawEvt any) { - switch evt := rawEvt.(type) { - case *messagix.Event_PublishResponse: - user.log.Trace().Any("table", &evt.Table).Msg("Got new event") - select { - case user.incomingTables <- evt.Table: - default: - user.log.Warn().Msg("Incoming tables channel full, event order not guaranteed") - go func() { - user.incomingTables <- evt.Table - }() - } - case *messagix.Event_Ready: - user.log.Debug().Msg("Initial connect to Meta socket completed") - user.metaState = status.BridgeState{StateEvent: status.StateConnected} - user.BridgeState.Send(user.metaState) - if initTable := user.initialTable.Swap(nil); initTable != nil { - user.log.Debug().Msg("Sending cached initial table to handler") - user.incomingTables <- initTable - } - if user.bridge.Config.Meta.Mode.IsMessenger() || user.bridge.Config.Meta.IGE2EE { - go func() { - err := user.connectE2EE() - if err != nil { - user.log.Err(err).Msg("Error connecting to e2ee") - } - }() - } - go user.BackfillLoop() - case *messagix.Event_SocketError: - user.log.Debug().Err(evt.Err).Msg("Disconnected from Meta socket") - user.metaState = status.BridgeState{ - StateEvent: status.StateTransientDisconnect, - Error: MetaTransientDisconnect, - } - if evt.ConnectionAttempts > setDisconnectStateAfterConnectAttempts { - user.BridgeState.Send(user.metaState) - } - case *messagix.Event_Reconnected: - user.log.Debug().Msg("Reconnected to Meta socket") - user.metaState = status.BridgeState{StateEvent: status.StateConnected} - user.BridgeState.Send(user.metaState) - case *messagix.Event_PermanentError: - if errors.Is(evt.Err, messagix.CONNECTION_REFUSED_UNAUTHORIZED) { - user.metaState = status.BridgeState{ - StateEvent: status.StateBadCredentials, - Error: MetaConnectionUnauthorized, - } - } else if errors.Is(evt.Err, messagix.CONNECTION_REFUSED_SERVER_UNAVAILABLE) { - if user.bridge.Config.Meta.Mode.IsMessenger() { - user.metaState = status.BridgeState{ - StateEvent: status.StateUnknownError, - Error: MetaServerUnavailable, - } - if user.canReconnect() { - user.log.Debug().Msg("Doing full reconnect after server unavailable error") - go user.FullReconnect() - } - } else { - user.metaState = status.BridgeState{ - StateEvent: status.StateBadCredentials, - Error: IGChallengeRequiredMaybe, - } - } - } else { - user.metaState = status.BridgeState{ - StateEvent: status.StateUnknownError, - Error: MetaPermanentError, - Message: evt.Err.Error(), - } - } - user.BridgeState.Send(user.metaState) - go user.sendMarkdownBridgeAlert(context.TODO(), "Error in %s connection: %v", user.bridge.ProtocolName, evt.Err) - user.StopBackfillLoop() - if user.forceRefreshTimer != nil { - user.forceRefreshTimer.Stop() - } - default: - user.log.Warn().Type("event_type", evt).Msg("Unrecognized event type from messagix") - } -} - -func (user *User) GetExistingPortalByThreadID(threadID int64) *Portal { - return user.GetPortalByThreadID(threadID, table.UNKNOWN_THREAD_TYPE) -} - -func (user *User) GetPortalByThreadID(threadID int64, threadType table.ThreadType) *Portal { - return user.bridge.GetPortalByThreadID(database.PortalKey{ - ThreadID: threadID, - Receiver: user.MetaID, - }, threadType) -} - -func (user *User) unlockedDisconnect() { - if user.Client != nil { - user.Client.Disconnect() - } - if user.E2EEClient != nil { - user.E2EEClient.Disconnect() - } - user.StopBackfillLoop() - user.Client = nil - user.E2EEClient = nil - user.waState = status.BridgeState{} - user.metaState = status.BridgeState{} -} - -func (user *User) canReconnect() bool { - return time.Since(user.lastFullReconnect) > time.Duration(user.bridge.Config.Meta.MinFullReconnectIntervalSeconds)*time.Second -} - -func (user *User) FullReconnect() { - user.Lock() - defer user.Unlock() - if !user.canReconnect() { - return - } - user.unlockedDisconnect() - user.lastFullReconnect = time.Now() - user.unlockedConnect() -} - -func (user *User) DisconnectFromError(stat status.BridgeState) { - user.BridgeState.Send(stat) - user.Disconnect() -} - -func (user *User) Disconnect() { - user.Lock() - defer user.Unlock() - user.unlockedDisconnect() -} - -func (user *User) DeleteSession() { - user.Lock() - defer user.Unlock() - user.unlockedDisconnect() - if user.WADevice != nil { - err := user.WADevice.Delete() - if err != nil { - user.log.Err(err).Msg("Failed to delete whatsmeow device") - } - } - user.Cookies = nil - user.MetaID = 0 - user.lastFullReconnect = time.Time{} - doublePuppet := user.bridge.GetPuppetByCustomMXID(user.MXID) - if doublePuppet != nil { - doublePuppet.ClearCustomMXID() - } - err := user.Update(context.TODO()) - if err != nil { - user.log.Err(err).Msg("Failed to delete session") - } -} - -func (user *User) AddDirectChat(ctx context.Context, roomID id.RoomID, userID id.UserID) { - if !user.bridge.Config.Bridge.SyncDirectChatList { - return - } - - puppet := user.bridge.GetPuppetByMXID(user.MXID) - if puppet == nil { - return - } - - intent := puppet.CustomIntent() - if intent == nil { - return - } - - user.log.Debug().Msg("Updating m.direct list on homeserver") - chats := map[id.UserID][]id.RoomID{} - err := intent.GetAccountData(ctx, event.AccountDataDirectChats.Type, &chats) - if err != nil { - user.log.Warn().Err(err).Msg("Failed to get m.direct event to update it") - return - } - chats[userID] = []id.RoomID{roomID} - - err = intent.SetAccountData(ctx, event.AccountDataDirectChats.Type, &chats) - if err != nil { - user.log.Warn().Err(err).Msg("Failed to update m.direct event") - } -}