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")
- }
-}