all: delete legacy bridge

This commit is contained in:
Tulir Asokan 2024-09-27 16:19:39 +03:00
parent fc2db39bcb
commit ea2d8ba07d
73 changed files with 53 additions and 16276 deletions

View file

@ -1,6 +1,3 @@
include:
- project: 'mautrix/ci'
file: '/gov2.yml'
variables:
BINARY_NAME_V2: mautrix-whatsapp
file: '/gov2-as-default.yml'

View file

@ -17,21 +17,10 @@ repos:
- "maunium.net/go/mautrix-whatsapp"
- "-w"
- id: go-vet-repo-mod
# TODO switch to standard staticcheck after deleting old bridge
#- id: go-staticcheck-repo-mod
- repo: local
hooks:
- id: go-staticcheck-custom
name: go-staticcheck-custom
language: system
types: [go]
pass_filenames: false
entry: sh -c 'staticcheck $(go list ./cmd/... ./pkg/...)'
- id: go-staticcheck-repo-mod
- repo: https://github.com/beeper/pre-commit-go
rev: v0.3.1
hooks:
- id: zerolog-ban-msgf
# TODO enable after deleting old bridge
#- id: zerolog-use-stringer
- id: zerolog-use-stringer

View file

@ -1,3 +1,14 @@
# v0.11.0 (unreleased)
* Bumped minimum Go version to 1.22.
* Dropped support for unauthenticated media on Matrix.
* Rewrote bridge using bridgev2 architecture.
* It is recommended to check the config file after upgrading. If you have
prevented the bridge from writing to the config, you should update it
manually.
* Group management features and commands are not yet available.
* Polls are not yet supported.
# v0.10.9 (2024-07-16)
* Added support for receiving Meta AI messages.

View file

@ -1,20 +1,19 @@
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
COPY . /build
WORKDIR /build
RUN go build -o /usr/bin/mautrix-whatsapp
RUN ./build.sh
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
COPY --from=builder /usr/bin/mautrix-whatsapp /usr/bin/mautrix-whatsapp
COPY --from=builder /build/example-config.yaml /opt/mautrix-whatsapp/example-config.yaml
COPY --from=builder /build/mautrix-whatsapp /usr/bin/mautrix-whatsapp
COPY --from=builder /build/docker-run.sh /docker-run.sh
VOLUME /data

View file

@ -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-whatsapp
COPY $EXECUTABLE /usr/bin/mautrix-whatsapp
COPY ./example-config.yaml /opt/mautrix-whatsapp/example-config.yaml
COPY ./docker-run.sh /docker-run.sh
ENV BRIDGEV2=1
VOLUME /data
WORKDIR /data
CMD ["/docker-run.sh"]

View file

@ -1,13 +0,0 @@
FROM golang:1-alpine3.18
RUN apk add --no-cache git ca-certificates build-base su-exec olm-dev bash jq yq curl
COPY . /build
WORKDIR /build
RUN go build -o /whatsapp
# Setup development stack using gow
RUN go install github.com/mitranim/gow@latest
RUN echo 'gow run /build $@' > /usr/bin/mautrix-whatsapp \
&& chmod +x /usr/bin/mautrix-whatsapp
VOLUME /data

View file

@ -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-whatsapp
COPY $EXECUTABLE /usr/bin/mautrix-whatsapp
COPY ./docker-run.sh /docker-run.sh
ENV BRIDGEV2=1
VOLUME /data
WORKDIR /data
CMD ["/docker-run.sh"]

View file

@ -10,6 +10,3 @@ The mautrix-whatsapp developers grant the following special exceptions:
All exceptions are only valid under the condition that any modifications to
the source code of mautrix-whatsapp remain publicly available under the terms
of the GNU AGPL version 3 or later.
These exceptions are only valid for the rewritten bridge under the `pkg` and
`cmd` directories, not the old bridge at the top level of the repository.

View file

@ -46,19 +46,19 @@
* [x] Typing notifications
* [x] Read receipts
* [x] Admin/superadmin status
* [ ] Membership actions
* [ ] Invite
* [ ] Join
* [ ] Leave
* [ ] Kick
* [ ] Group metadata changes
* [ ] Title
* [ ] Avatar
* [ ] Description
* [ ] Initial group metadata
* [ ] User metadata changes
* [ ] Display name
* [ ] Avatar
* [x] Membership actions
* [x] Invite
* [x] Join
* [x] Leave
* [x] Kick
* [x] Group metadata changes
* [x] Title
* [x] Avatar
* [x] Description
* [x] Initial group metadata
* [x] User metadata changes
* [x] Display name
* [x] Avatar
* [x] Initial user metadata
* [x] Display name
* [x] Avatar

View file

@ -1,96 +0,0 @@
// mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge.
// Copyright (C) 2022 Tulir Asokan, Sumner Evans
//
// 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 <https://www.gnu.org/licenses/>.
package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"github.com/rs/zerolog"
"maunium.net/go/mautrix/id"
)
type AnalyticsClient struct {
url string
key string
userID string
log zerolog.Logger
client http.Client
}
var Analytics AnalyticsClient
func (sc *AnalyticsClient) trackSync(userID id.UserID, event string, properties map[string]interface{}) error {
var buf bytes.Buffer
var analyticsUserID string
if Analytics.userID != "" {
analyticsUserID = Analytics.userID
} else {
analyticsUserID = userID.String()
}
err := json.NewEncoder(&buf).Encode(map[string]interface{}{
"userId": analyticsUserID,
"event": event,
"properties": properties,
})
if err != nil {
return err
}
req, err := http.NewRequest(http.MethodPost, sc.url, &buf)
if err != nil {
return err
}
req.SetBasicAuth(sc.key, "")
resp, err := sc.client.Do(req)
if err != nil {
return err
}
_ = resp.Body.Close()
if resp.StatusCode != 200 {
return fmt.Errorf("unexpected status code %d", resp.StatusCode)
}
return nil
}
func (sc *AnalyticsClient) IsEnabled() bool {
return len(sc.key) > 0
}
func (sc *AnalyticsClient) Track(userID id.UserID, event string, properties ...map[string]interface{}) {
if !sc.IsEnabled() {
return
} else if len(properties) > 1 {
panic("Track should be called with at most one property map")
}
go func() {
props := map[string]interface{}{}
if len(properties) > 0 {
props = properties[0]
}
props["bridge"] = "whatsapp"
err := sc.trackSync(userID, event, props)
if err != nil {
sc.log.Err(err).Str("event", event).Msg("Error tracking event")
} else {
sc.log.Debug().Str("event", event).Msg("Tracked event")
}
}()
}

View file

@ -1,136 +0,0 @@
// mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge.
// Copyright (C) 2024 Tulir Asokan, Sumner Evans
//
// 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 <https://www.gnu.org/licenses/>.
package main
import (
"context"
"time"
"github.com/rs/zerolog"
"maunium.net/go/mautrix/id"
"maunium.net/go/mautrix-whatsapp/database"
)
type BackfillQueue struct {
BackfillQuery *database.BackfillTaskQuery
reCheckChannels []chan bool
}
func (bq *BackfillQueue) ReCheck() {
for _, channel := range bq.reCheckChannels {
go func(c chan bool) {
c <- true
}(channel)
}
}
func (bq *BackfillQueue) GetNextBackfill(ctx context.Context, userID id.UserID, backfillTypes []database.BackfillType, waitForBackfillTypes []database.BackfillType, reCheckChannel chan bool) *database.BackfillTask {
for {
if !bq.BackfillQuery.HasUnstartedOrInFlightOfType(ctx, userID, waitForBackfillTypes) {
// check for immediate when dealing with deferred
if backfill, err := bq.BackfillQuery.GetNext(ctx, userID, backfillTypes); err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to get next backfill task")
} else if backfill != nil {
err = backfill.MarkDispatched(ctx)
if err != nil {
zerolog.Ctx(ctx).Warn().Err(err).
Int("queue_id", backfill.QueueID).
Msg("Failed to mark backfill task as dispatched")
}
return backfill
}
}
select {
case <-reCheckChannel:
case <-time.After(time.Minute):
}
}
}
func (user *User) HandleBackfillRequestsLoop(backfillTypes []database.BackfillType, waitForBackfillTypes []database.BackfillType) {
log := user.zlog.With().
Str("action", "backfill request loop").
Any("types", backfillTypes).
Logger()
ctx := log.WithContext(context.TODO())
reCheckChannel := make(chan bool)
user.BackfillQueue.reCheckChannels = append(user.BackfillQueue.reCheckChannels, reCheckChannel)
for {
req := user.BackfillQueue.GetNextBackfill(ctx, user.MXID, backfillTypes, waitForBackfillTypes, reCheckChannel)
log.Info().Any("backfill_request", req).Msg("Handling backfill request")
log := log.With().
Int("queue_id", req.QueueID).
Stringer("portal_jid", req.Portal.JID).
Logger()
ctx := log.WithContext(ctx)
conv, err := user.bridge.DB.HistorySync.GetConversation(ctx, user.MXID, req.Portal)
if err != nil {
log.Err(err).Msg("Failed to get conversation data for backfill request")
continue
} else if conv == nil {
log.Debug().Msg("Couldn't find conversation data for backfill request")
err = req.MarkDone(ctx)
if err != nil {
log.Err(err).Msg("Failed to mark backfill request as done after data was not found")
}
continue
}
portal := user.GetPortalByJID(conv.PortalKey.JID)
// Update the client store with basic chat settings.
if conv.MuteEndTime.After(time.Now()) {
err = user.Client.Store.ChatSettings.PutMutedUntil(conv.PortalKey.JID, conv.MuteEndTime)
if err != nil {
log.Err(err).Msg("Failed to save muted until time from conversation data")
}
}
if conv.Archived {
err = user.Client.Store.ChatSettings.PutArchived(conv.PortalKey.JID, true)
if err != nil {
log.Err(err).Msg("Failed to save archived state from conversation data")
}
}
if conv.Pinned > 0 {
err = user.Client.Store.ChatSettings.PutPinned(conv.PortalKey.JID, true)
if err != nil {
log.Err(err).Msg("Failed to save pinned state from conversation data")
}
}
if conv.EphemeralExpiration != nil && portal.ExpirationTime != *conv.EphemeralExpiration {
log.Debug().
Uint32("old_time", portal.ExpirationTime).
Uint32("new_time", *conv.EphemeralExpiration).
Msg("Updating portal ephemeral expiration time")
portal.ExpirationTime = *conv.EphemeralExpiration
err = portal.Update(ctx)
if err != nil {
log.Err(err).Msg("Failed to save portal after updating expiration time")
}
}
user.backfillInChunks(ctx, req, conv, portal)
err = req.MarkDone(ctx)
if err != nil {
log.Err(err).Msg("Failed to mark backfill request as done after backfilling")
}
}
}

View file

@ -1,101 +0,0 @@
// mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge.
// Copyright (C) 2021 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 <https://www.gnu.org/licenses/>.
package main
import (
"fmt"
"net/http"
"maunium.net/go/mautrix/bridge/status"
"maunium.net/go/mautrix/id"
)
const (
WALoggedOut status.BridgeStateErrorCode = "wa-logged-out"
WAMainDeviceGone status.BridgeStateErrorCode = "wa-main-device-gone"
WAUnknownLogout status.BridgeStateErrorCode = "wa-unknown-logout"
WANotConnected status.BridgeStateErrorCode = "wa-not-connected"
WAConnecting status.BridgeStateErrorCode = "wa-connecting"
WAKeepaliveTimeout status.BridgeStateErrorCode = "wa-keepalive-timeout"
WAPhoneOffline status.BridgeStateErrorCode = "wa-phone-offline"
WAConnectionFailed status.BridgeStateErrorCode = "wa-connection-failed"
WADisconnected status.BridgeStateErrorCode = "wa-transient-disconnect"
)
func init() {
status.BridgeStateHumanErrors.Update(status.BridgeStateErrorMap{
WALoggedOut: "You were logged out from another device. Relogin to continue using the bridge.",
WAMainDeviceGone: "Your phone was logged out from WhatsApp. Relogin to continue using the bridge.",
WAUnknownLogout: "You were logged out for an unknown reason. Relogin to continue using the bridge.",
WANotConnected: "You're not connected to WhatsApp",
WAConnecting: "Reconnecting to WhatsApp...",
WAKeepaliveTimeout: "The WhatsApp web servers are not responding. The bridge will try to reconnect.",
WAPhoneOffline: "Your phone hasn't been seen in over 12 days. The bridge is currently connected, but will get disconnected if you don't open the app soon.",
WAConnectionFailed: "Connecting to the WhatsApp web servers failed.",
WADisconnected: "Disconnected from WhatsApp. Trying to reconnect.",
})
}
func (user *User) GetRemoteID() string {
if user == nil || user.JID.IsEmpty() {
return ""
}
return user.JID.User
}
func (user *User) GetRemoteName() string {
if user == nil || user.JID.IsEmpty() {
return ""
}
return fmt.Sprintf("+%s", user.JID.User)
}
func (prov *ProvisioningAPI) BridgeStatePing(w http.ResponseWriter, r *http.Request) {
if !prov.bridge.AS.CheckServerToken(w, r) {
return
}
userID := r.URL.Query().Get("user_id")
user := prov.bridge.GetUserByMXID(id.UserID(userID))
var global status.BridgeState
global.StateEvent = status.StateRunning
var remote status.BridgeState
if user.IsConnected() {
if user.Client.IsLoggedIn() {
remote.StateEvent = status.StateConnected
} else if user.Session != nil {
remote.StateEvent = status.StateConnecting
remote.Error = WAConnecting
} // else: unconfigured
} else if user.Session != nil {
remote.StateEvent = status.StateBadCredentials
remote.Error = WANotConnected
} // else: unconfigured
global = global.Fill(nil)
resp := status.GlobalBridgeState{
BridgeState: global,
RemoteStates: map[string]status.BridgeState{},
}
if len(remote.StateEvent) > 0 {
remote = remote.Fill(user)
resp.RemoteStates[remote.RemoteID] = remote
}
user.zlog.Debug().Any("response_data", &resp).Msg("Responding bridge state in bridge status endpoint")
jsonResponse(w, http.StatusOK, &resp)
if len(resp.RemoteStates) > 0 {
user.BridgeState.SetPrev(remote)
}
}

View file

@ -1,4 +0,0 @@
#!/bin/sh
MAUTRIX_VERSION=$(cat go.mod | grep 'maunium.net/go/mautrix ' | awk '{ print $2 }')
GO_LDFLAGS="-s -w -X main.Tag=$(git describe --exact-match --tags 2>/dev/null) -X main.Commit=$(git rev-parse HEAD) -X 'main.BuildTime=`date -Iseconds`' -X 'maunium.net/go/mautrix.GoModVersion=$MAUTRIX_VERSION'"
go build -ldflags="$GO_LDFLAGS" ./cmd/mautrix-whatsapp "$@"

View file

@ -1,2 +1,4 @@
#!/bin/sh
go build -ldflags "-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'`'" "$@"
MAUTRIX_VERSION=$(cat go.mod | grep 'maunium.net/go/mautrix ' | awk '{ print $2 }')
GO_LDFLAGS="-s -w -X main.Tag=$(git describe --exact-match --tags 2>/dev/null) -X main.Commit=$(git rev-parse HEAD) -X 'main.BuildTime=`date -Iseconds`' -X 'maunium.net/go/mautrix.GoModVersion=$MAUTRIX_VERSION'"
go build -ldflags="$GO_LDFLAGS" ./cmd/mautrix-whatsapp "$@"

File diff suppressed because it is too large Load diff

View file

@ -1,337 +0,0 @@
// mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge.
// Copyright (C) 2023 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 <https://www.gnu.org/licenses/>.
package config
import (
"errors"
"fmt"
"strings"
"text/template"
"time"
"go.mau.fi/whatsmeow/types"
"maunium.net/go/mautrix/bridge/bridgeconfig"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
)
type DeferredConfig struct {
StartDaysAgo int `yaml:"start_days_ago"`
MaxBatchEvents int `yaml:"max_batch_events"`
BatchDelay int `yaml:"batch_delay"`
}
type MediaRequestMethod string
const (
MediaRequestMethodImmediate MediaRequestMethod = "immediate"
MediaRequestMethodLocalTime = "local_time"
)
type BridgeConfig struct {
UsernameTemplate string `yaml:"username_template"`
DisplaynameTemplate string `yaml:"displayname_template"`
PersonalFilteringSpaces bool `yaml:"personal_filtering_spaces"`
DeliveryReceipts bool `yaml:"delivery_receipts"`
MessageStatusEvents bool `yaml:"message_status_events"`
MessageErrorNotices bool `yaml:"message_error_notices"`
PortalMessageBuffer int `yaml:"portal_message_buffer"`
CallStartNotices bool `yaml:"call_start_notices"`
IdentityChangeNotices bool `yaml:"identity_change_notices"`
HistorySync struct {
Backfill bool `yaml:"backfill"`
RequestFullSync bool `yaml:"request_full_sync"`
FullSyncConfig struct {
DaysLimit uint32 `yaml:"days_limit"`
SizeLimit uint32 `yaml:"size_mb_limit"`
StorageQuota uint32 `yaml:"storage_quota_mb"`
}
MaxInitialConversations int `yaml:"max_initial_conversations"`
MessageCount int `yaml:"message_count"`
UnreadHoursThreshold int `yaml:"unread_hours_threshold"`
Immediate struct {
WorkerCount int `yaml:"worker_count"`
MaxEvents int `yaml:"max_events"`
} `yaml:"immediate"`
MediaRequests struct {
AutoRequestMedia bool `yaml:"auto_request_media"`
RequestMethod MediaRequestMethod `yaml:"request_method"`
RequestLocalTime int `yaml:"request_local_time"`
MaxAsyncHandle int64 `yaml:"max_async_handle"`
} `yaml:"media_requests"`
Deferred []DeferredConfig `yaml:"deferred"`
} `yaml:"history_sync"`
UserAvatarSync bool `yaml:"user_avatar_sync"`
BridgeMatrixLeave bool `yaml:"bridge_matrix_leave"`
SyncDirectChatList bool `yaml:"sync_direct_chat_list"`
SyncManualMarkedUnread bool `yaml:"sync_manual_marked_unread"`
DefaultBridgePresence bool `yaml:"default_bridge_presence"`
SendPresenceOnTyping bool `yaml:"send_presence_on_typing"`
ForceActiveDeliveryReceipts bool `yaml:"force_active_delivery_receipts"`
DoublePuppetConfig bridgeconfig.DoublePuppetConfig `yaml:",inline"`
PrivateChatPortalMeta string `yaml:"private_chat_portal_meta"`
ParallelMemberSync bool `yaml:"parallel_member_sync"`
BridgeNotices bool `yaml:"bridge_notices"`
ResendBridgeInfo bool `yaml:"resend_bridge_info"`
MuteBridging bool `yaml:"mute_bridging"`
ArchiveTag event.RoomTag `yaml:"archive_tag"`
PinnedTag event.RoomTag `yaml:"pinned_tag"`
TagOnlyOnCreate bool `yaml:"tag_only_on_create"`
MarkReadOnlyOnCreate bool `yaml:"mark_read_only_on_create"`
EnableStatusBroadcast bool `yaml:"enable_status_broadcast"`
MuteStatusBroadcast bool `yaml:"mute_status_broadcast"`
StatusBroadcastTag event.RoomTag `yaml:"status_broadcast_tag"`
WhatsappThumbnail bool `yaml:"whatsapp_thumbnail"`
AllowUserInvite bool `yaml:"allow_user_invite"`
FederateRooms bool `yaml:"federate_rooms"`
URLPreviews bool `yaml:"url_previews"`
CaptionInMessage bool `yaml:"caption_in_message"`
BeeperGalleries bool `yaml:"beeper_galleries"`
ExtEvPolls bool `yaml:"extev_polls"`
CrossRoomReplies bool `yaml:"cross_room_replies"`
DisableReplyFallbacks bool `yaml:"disable_reply_fallbacks"`
MessageHandlingTimeout struct {
ErrorAfterStr string `yaml:"error_after"`
DeadlineStr string `yaml:"deadline"`
ErrorAfter time.Duration `yaml:"-"`
Deadline time.Duration `yaml:"-"`
} `yaml:"message_handling_timeout"`
DisableStatusBroadcastSend bool `yaml:"disable_status_broadcast_send"`
DisableBridgeAlerts bool `yaml:"disable_bridge_alerts"`
CrashOnStreamReplaced bool `yaml:"crash_on_stream_replaced"`
CommandPrefix string `yaml:"command_prefix"`
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"`
ParsedUsernameTemplate *template.Template `yaml:"-"`
displaynameTemplate *template.Template `yaml:"-"`
}
func (bc BridgeConfig) GetDoublePuppetConfig() bridgeconfig.DoublePuppetConfig {
return bc.DoublePuppetConfig
}
func (bc BridgeConfig) GetEncryptionConfig() bridgeconfig.EncryptionConfig {
return bc.Encryption
}
func (bc BridgeConfig) EnableMessageStatusEvents() bool {
return bc.MessageStatusEvents
}
func (bc BridgeConfig) EnableMessageErrorNotices() bool {
return bc.MessageErrorNotices
}
func (bc BridgeConfig) GetCommandPrefix() string {
return bc.CommandPrefix
}
func (bc BridgeConfig) GetManagementRoomTexts() bridgeconfig.ManagementRoomTexts {
return bc.ManagementRoomText
}
func (bc BridgeConfig) GetResendBridgeInfo() bool {
return bc.ResendBridgeInfo
}
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.ParsedUsernameTemplate, 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
}
if bc.MessageHandlingTimeout.ErrorAfterStr != "" {
bc.MessageHandlingTimeout.ErrorAfter, err = time.ParseDuration(bc.MessageHandlingTimeout.ErrorAfterStr)
if err != nil {
return err
}
}
if bc.MessageHandlingTimeout.DeadlineStr != "" {
bc.MessageHandlingTimeout.Deadline, err = time.ParseDuration(bc.MessageHandlingTimeout.DeadlineStr)
if err != nil {
return err
}
}
return nil
}
type UsernameTemplateArgs struct {
UserID id.UserID
}
type legacyContactInfo struct {
types.ContactInfo
Phone string
Notify string
VName string
Name string
Short string
JID string
}
const (
NameQualityPush = 3
NameQualityContact = 2
NameQualityPhone = 1
)
func (bc BridgeConfig) FormatDisplayname(jid types.JID, contact types.ContactInfo) (string, int8) {
var buf strings.Builder
_ = bc.displaynameTemplate.Execute(&buf, legacyContactInfo{
ContactInfo: contact,
Notify: contact.PushName,
VName: contact.BusinessName,
Name: contact.FullName,
Short: contact.FirstName,
Phone: "+" + jid.User,
JID: "+" + jid.User,
})
var quality int8
switch {
case len(contact.PushName) > 0 || len(contact.BusinessName) > 0:
quality = NameQualityPush
case len(contact.FullName) > 0 || len(contact.FirstName) > 0:
quality = NameQualityContact
default:
quality = NameQualityPhone
}
return buf.String(), quality
}
func (bc BridgeConfig) FormatUsername(username string) string {
var buf strings.Builder
_ = bc.ParsedUsernameTemplate.Execute(&buf, username)
return buf.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
}

View file

@ -1,54 +0,0 @@
// mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge.
// Copyright (C) 2022 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 <https://www.gnu.org/licenses/>.
package config
import (
"maunium.net/go/mautrix/bridge/bridgeconfig"
"maunium.net/go/mautrix/id"
)
type Config struct {
*bridgeconfig.BaseConfig `yaml:",inline"`
Analytics struct {
Host string `yaml:"host"`
Token string `yaml:"token"`
UserID string `yaml:"user_id"`
}
Metrics struct {
Enabled bool `yaml:"enabled"`
Listen string `yaml:"listen"`
} `yaml:"metrics"`
WhatsApp struct {
OSName string `yaml:"os_name"`
BrowserName string `yaml:"browser_name"`
Proxy string `yaml:"proxy"`
GetProxyURL string `yaml:"get_proxy_url"`
ProxyOnlyLogin bool `yaml:"proxy_only_login"`
} `yaml:"whatsapp"`
Bridge BridgeConfig `yaml:"bridge"`
}
func (config *Config) CanAutoDoublePuppet(userID id.UserID) bool {
_, homeserver, _ := userID.Parse()
_, hasSecret := config.Bridge.DoublePuppetConfig.SharedSecretMap[homeserver]
return hasSecret
}

View file

@ -1,200 +0,0 @@
// mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge.
// Copyright (C) 2022 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 <https://www.gnu.org/licenses/>.
package config
import (
"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)
helper.Copy(up.Str|up.Null, "analytics", "host")
helper.Copy(up.Str|up.Null, "analytics", "token")
helper.Copy(up.Str|up.Null, "analytics", "user_id")
helper.Copy(up.Bool, "metrics", "enabled")
helper.Copy(up.Str, "metrics", "listen")
helper.Copy(up.Str, "whatsapp", "os_name")
helper.Copy(up.Str, "whatsapp", "browser_name")
helper.Copy(up.Str|up.Null, "whatsapp", "proxy")
helper.Copy(up.Str|up.Null, "whatsapp", "get_proxy_url")
helper.Copy(up.Bool, "whatsapp", "proxy_only_login")
helper.Copy(up.Str, "bridge", "username_template")
helper.Copy(up.Str, "bridge", "displayname_template")
helper.Copy(up.Bool, "bridge", "personal_filtering_spaces")
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.Int, "bridge", "portal_message_buffer")
helper.Copy(up.Bool, "bridge", "call_start_notices")
helper.Copy(up.Bool, "bridge", "identity_change_notices")
helper.Copy(up.Bool, "bridge", "history_sync", "backfill")
helper.Copy(up.Bool, "bridge", "history_sync", "request_full_sync")
helper.Copy(up.Int|up.Null, "bridge", "history_sync", "full_sync_config", "days_limit")
helper.Copy(up.Int|up.Null, "bridge", "history_sync", "full_sync_config", "size_mb_limit")
helper.Copy(up.Int|up.Null, "bridge", "history_sync", "full_sync_config", "storage_quota_mb")
helper.Copy(up.Bool, "bridge", "history_sync", "media_requests", "auto_request_media")
helper.Copy(up.Str, "bridge", "history_sync", "media_requests", "request_method")
helper.Copy(up.Int, "bridge", "history_sync", "media_requests", "request_local_time")
helper.Copy(up.Int, "bridge", "history_sync", "media_requests", "max_async_handle")
helper.Copy(up.Int, "bridge", "history_sync", "max_initial_conversations")
helper.Copy(up.Int, "bridge", "history_sync", "message_count")
helper.Copy(up.Int, "bridge", "history_sync", "unread_hours_threshold")
helper.Copy(up.Int, "bridge", "history_sync", "immediate", "worker_count")
helper.Copy(up.Int, "bridge", "history_sync", "immediate", "max_events")
helper.Copy(up.List, "bridge", "history_sync", "deferred")
helper.Copy(up.Bool, "bridge", "user_avatar_sync")
helper.Copy(up.Bool, "bridge", "bridge_matrix_leave")
helper.Copy(up.Bool, "bridge", "sync_direct_chat_list")
helper.Copy(up.Bool, "bridge", "default_bridge_presence")
helper.Copy(up.Bool, "bridge", "send_presence_on_typing")
helper.Copy(up.Bool, "bridge", "force_active_delivery_receipts")
helper.Copy(up.Map, "bridge", "double_puppet_server_map")
helper.Copy(up.Bool, "bridge", "double_puppet_allow_discovery")
if legacySecret, ok := helper.Get(up.Str, "bridge", "login_shared_secret"); ok && len(legacySecret) > 0 {
baseNode := helper.GetBaseNode("bridge", "login_shared_secret_map")
baseNode.Map[helper.GetBase("homeserver", "domain")] = up.StringNode(legacySecret)
baseNode.UpdateContent()
} else {
helper.Copy(up.Map, "bridge", "login_shared_secret_map")
}
if legacyPrivateChatPortalMeta, ok := helper.Get(up.Bool, "bridge", "private_chat_portal_meta"); ok {
updatedPrivateChatPortalMeta := "default"
if legacyPrivateChatPortalMeta == "true" {
updatedPrivateChatPortalMeta = "always"
}
helper.Set(up.Str, updatedPrivateChatPortalMeta, "bridge", "private_chat_portal_meta")
} else {
helper.Copy(up.Str, "bridge", "private_chat_portal_meta")
}
helper.Copy(up.Bool, "bridge", "parallel_member_sync")
helper.Copy(up.Bool, "bridge", "bridge_notices")
helper.Copy(up.Bool, "bridge", "resend_bridge_info")
helper.Copy(up.Bool, "bridge", "mute_bridging")
helper.Copy(up.Str|up.Null, "bridge", "archive_tag")
helper.Copy(up.Str|up.Null, "bridge", "pinned_tag")
helper.Copy(up.Bool, "bridge", "tag_only_on_create")
helper.Copy(up.Bool, "bridge", "enable_status_broadcast")
helper.Copy(up.Bool, "bridge", "disable_status_broadcast_send")
helper.Copy(up.Bool, "bridge", "mute_status_broadcast")
helper.Copy(up.Str|up.Null, "bridge", "status_broadcast_tag")
helper.Copy(up.Bool, "bridge", "whatsapp_thumbnail")
helper.Copy(up.Bool, "bridge", "allow_user_invite")
helper.Copy(up.Str, "bridge", "command_prefix")
helper.Copy(up.Bool, "bridge", "federate_rooms")
helper.Copy(up.Bool, "bridge", "disable_bridge_alerts")
helper.Copy(up.Bool, "bridge", "crash_on_stream_replaced")
helper.Copy(up.Bool, "bridge", "url_previews")
helper.Copy(up.Bool, "bridge", "caption_in_message")
helper.Copy(up.Bool, "bridge", "beeper_galleries")
if intPolls, ok := helper.Get(up.Int, "bridge", "extev_polls"); ok {
val := "false"
if intPolls != "0" {
val = "true"
}
helper.Set(up.Bool, val, "bridge", "extev_polls")
} else {
helper.Copy(up.Bool, "bridge", "extev_polls")
}
helper.Copy(up.Bool, "bridge", "cross_room_replies")
helper.Copy(up.Bool, "bridge", "disable_reply_fallbacks")
helper.Copy(up.Str|up.Null, "bridge", "message_handling_timeout", "error_after")
helper.Copy(up.Str|up.Null, "bridge", "message_handling_timeout", "deadline")
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", "plaintext_mentions")
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")
legacyKeyShareAllow, ok := helper.Get(up.Bool, "bridge", "encryption", "key_sharing", "allow")
if ok {
helper.Set(up.Bool, legacyKeyShareAllow, "bridge", "encryption", "allow_key_sharing")
legacyKeyShareRequireCS, legacyOK1 := helper.Get(up.Bool, "bridge", "encryption", "key_sharing", "require_cross_signing")
legacyKeyShareRequireVerification, legacyOK2 := helper.Get(up.Bool, "bridge", "encryption", "key_sharing", "require_verification")
if legacyOK1 && legacyOK2 && legacyKeyShareRequireVerification == "false" && legacyKeyShareRequireCS == "false" {
helper.Set(up.Str, "unverified", "bridge", "encryption", "verification_levels", "share")
}
} else {
helper.Copy(up.Bool, "bridge", "encryption", "allow_key_sharing")
}
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")
if prefix, ok := helper.Get(up.Str, "appservice", "provisioning", "prefix"); ok {
helper.Set(up.Str, strings.TrimSuffix(prefix, "/v1"), "bridge", "provisioning", "prefix")
} else {
helper.Copy(up.Str, "bridge", "provisioning", "prefix")
}
helper.Copy(up.Bool, "bridge", "provisioning", "debug_endpoints")
if secret, ok := helper.Get(up.Str, "appservice", "provisioning", "shared_secret"); ok && secret != "generate" {
helper.Set(up.Str, secret, "bridge", "provisioning", "shared_secret")
} else 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.Map, "bridge", "permissions")
helper.Copy(up.Bool, "bridge", "relay", "enabled")
helper.Copy(up.Bool, "bridge", "relay", "admin_only")
helper.Copy(up.Map, "bridge", "relay", "message_formats")
}
var SpacedBlocks = [][]string{
{"homeserver", "software"},
{"appservice"},
{"appservice", "hostname"},
{"appservice", "database"},
{"appservice", "id"},
{"appservice", "as_token"},
{"analytics"},
{"metrics"},
{"whatsapp"},
{"bridge"},
{"bridge", "command_prefix"},
{"bridge", "management_room_text"},
{"bridge", "encryption"},
{"bridge", "provisioning"},
{"bridge", "permissions"},
{"bridge", "relay"},
{"logging"},
}

View file

@ -1,99 +0,0 @@
// mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge.
// Copyright (C) 2021 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 <https://www.gnu.org/licenses/>.
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
puppet.EnablePresence = puppet.bridge.Config.Bridge.DefaultBridgePresence
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.zlog.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.zlog.Debug().Msg("Checking if double puppeting needs to be enabled")
puppet := user.bridge.GetPuppetByJID(user.JID)
if len(puppet.CustomMXID) > 0 {
user.zlog.Debug().Msg("User already has double-puppeting enabled")
// Custom puppet already enabled
return
}
puppet.CustomMXID = user.MXID
puppet.EnablePresence = puppet.bridge.Config.Bridge.DefaultBridgePresence
err := puppet.StartCustomMXID(true)
if err != nil {
user.zlog.Warn().Err(err).Msg("Failed to login with shared secret")
} else {
// TODO leave rooms with default puppet
user.zlog.Debug().Msg("Successfully automatically enabled custom puppet")
}
}

View file

@ -1,253 +0,0 @@
// mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge.
// Copyright (C) 2024 Tulir Asokan, Sumner Evans
//
// 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 <https://www.gnu.org/licenses/>.
package database
import (
"context"
"database/sql"
"errors"
"fmt"
"strconv"
"strings"
"time"
"github.com/rs/zerolog"
"go.mau.fi/util/dbutil"
"maunium.net/go/mautrix/id"
)
type BackfillType int
const (
BackfillImmediate BackfillType = 0
BackfillForward BackfillType = 100
BackfillDeferred BackfillType = 200
)
func (bt BackfillType) String() string {
switch bt {
case BackfillImmediate:
return "IMMEDIATE"
case BackfillForward:
return "FORWARD"
case BackfillDeferred:
return "DEFERRED"
}
return "UNKNOWN"
}
type BackfillTaskQuery struct {
*dbutil.QueryHelper[*BackfillTask]
//backfillQueryLock sync.Mutex
}
func newBackfillTask(qh *dbutil.QueryHelper[*BackfillTask]) *BackfillTask {
return &BackfillTask{qh: qh}
}
func (bq *BackfillTaskQuery) NewWithValues(userID id.UserID, backfillType BackfillType, priority int, portal PortalKey, timeStart *time.Time, maxBatchEvents, maxTotalEvents, batchDelay int) *BackfillTask {
return &BackfillTask{
qh: bq.QueryHelper,
UserID: userID,
BackfillType: backfillType,
Priority: priority,
Portal: portal,
TimeStart: timeStart,
MaxBatchEvents: maxBatchEvents,
MaxTotalEvents: maxTotalEvents,
BatchDelay: batchDelay,
}
}
const (
getNextBackfillTaskQueryTemplate = `
SELECT queue_id, user_mxid, type, priority, portal_jid, portal_receiver, time_start, max_batch_events, max_total_events, batch_delay
FROM backfill_queue
WHERE user_mxid=$1
AND type IN (%s)
AND (
dispatch_time IS NULL
OR (
dispatch_time < $2
AND completed_at IS NULL
)
)
ORDER BY type, priority, queue_id
LIMIT 1
`
getUnstartedOrInFlightBackfillTaskQueryTemplate = `
SELECT 1
FROM backfill_queue
WHERE user_mxid=$1
AND type IN (%s)
AND (dispatch_time IS NULL OR completed_at IS NULL)
LIMIT 1
`
deleteBackfillQueueForUserQuery = "DELETE FROM backfill_queue WHERE user_mxid=$1"
deleteBackfillQueueForPortalQuery = `
DELETE FROM backfill_queue
WHERE user_mxid=$1
AND portal_jid=$2
AND portal_receiver=$3
`
insertBackfillTaskQuery = `
INSERT INTO backfill_queue (
user_mxid, type, priority, portal_jid, portal_receiver, time_start,
max_batch_events, max_total_events, batch_delay, dispatch_time, completed_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING queue_id
`
markBackfillTaskDispatchedQuery = "UPDATE backfill_queue SET dispatch_time=$1 WHERE queue_id=$2"
markBackfillTaskDoneQuery = "UPDATE backfill_queue SET completed_at=$1 WHERE queue_id=$2"
)
func typesToString(backfillTypes []BackfillType) string {
types := make([]string, len(backfillTypes))
for i, backfillType := range backfillTypes {
types[i] = strconv.Itoa(int(backfillType))
}
return strings.Join(types, ",")
}
// GetNext returns the next backfill to perform
func (bq *BackfillTaskQuery) GetNext(ctx context.Context, userID id.UserID, backfillTypes []BackfillType) (*BackfillTask, error) {
if len(backfillTypes) == 0 {
return nil, nil
}
//bq.backfillQueryLock.Lock()
//defer bq.backfillQueryLock.Unlock()
query := fmt.Sprintf(getNextBackfillTaskQueryTemplate, typesToString(backfillTypes))
return bq.QueryOne(ctx, query, userID, time.Now().Add(-15*time.Minute))
}
func (bq *BackfillTaskQuery) HasUnstartedOrInFlightOfType(ctx context.Context, userID id.UserID, backfillTypes []BackfillType) (has bool) {
if len(backfillTypes) == 0 {
return false
}
//bq.backfillQueryLock.Lock()
//defer bq.backfillQueryLock.Unlock()
query := fmt.Sprintf(getUnstartedOrInFlightBackfillTaskQueryTemplate, typesToString(backfillTypes))
err := bq.GetDB().QueryRow(ctx, query, userID).Scan(&has)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
zerolog.Ctx(ctx).Err(err).Msg("Failed to check if backfill queue has jobs")
}
return
}
func (bq *BackfillTaskQuery) DeleteAll(ctx context.Context, userID id.UserID) error {
//bq.backfillQueryLock.Lock()
//defer bq.backfillQueryLock.Unlock()
return bq.Exec(ctx, deleteBackfillQueueForUserQuery, userID)
}
func (bq *BackfillTaskQuery) DeleteAllForPortal(ctx context.Context, userID id.UserID, portalKey PortalKey) error {
//bq.backfillQueryLock.Lock()
//defer bq.backfillQueryLock.Unlock()
return bq.Exec(ctx, deleteBackfillQueueForPortalQuery, userID, portalKey.JID, portalKey.Receiver)
}
type BackfillTask struct {
qh *dbutil.QueryHelper[*BackfillTask]
QueueID int
UserID id.UserID
BackfillType BackfillType
Priority int
Portal PortalKey
TimeStart *time.Time
MaxBatchEvents int
MaxTotalEvents int
BatchDelay int
DispatchTime *time.Time
CompletedAt *time.Time
}
func (b *BackfillTask) MarshalZerologObject(evt *zerolog.Event) {
evt.Int("queue_id", b.QueueID).
Stringer("user_id", b.UserID).
Stringer("backfill_type", b.BackfillType).
Int("priority", b.Priority).
Stringer("portal_jid", b.Portal.JID).
Any("time_start", b.TimeStart).
Int("max_batch_events", b.MaxBatchEvents).
Int("max_total_events", b.MaxTotalEvents).
Int("batch_delay", b.BatchDelay).
Any("dispatch_time", b.DispatchTime).
Any("completed_at", b.CompletedAt)
}
func (b *BackfillTask) String() string {
return fmt.Sprintf(
"BackfillTask{QueueID: %d, UserID: %s, BackfillType: %s, Priority: %d, Portal: %s, TimeStart: %s, MaxBatchEvents: %d, MaxTotalEvents: %d, BatchDelay: %d, DispatchTime: %s, CompletedAt: %s}",
b.QueueID, b.UserID, b.BackfillType, b.Priority, b.Portal, b.TimeStart, b.MaxBatchEvents, b.MaxTotalEvents, b.BatchDelay, b.CompletedAt, b.DispatchTime,
)
}
func (b *BackfillTask) Scan(row dbutil.Scannable) (*BackfillTask, error) {
var maxTotalEvents, batchDelay sql.NullInt32
err := row.Scan(
&b.QueueID, &b.UserID, &b.BackfillType, &b.Priority, &b.Portal.JID, &b.Portal.Receiver, &b.TimeStart,
&b.MaxBatchEvents, &maxTotalEvents, &batchDelay,
)
if err != nil {
return nil, err
}
b.MaxTotalEvents = int(maxTotalEvents.Int32)
b.BatchDelay = int(batchDelay.Int32)
return b, nil
}
func (b *BackfillTask) sqlVariables() []any {
return []any{
b.UserID, b.BackfillType, b.Priority, b.Portal.JID, b.Portal.Receiver, b.TimeStart,
b.MaxBatchEvents, b.MaxTotalEvents, b.BatchDelay, b.DispatchTime, b.CompletedAt,
}
}
func (b *BackfillTask) Insert(ctx context.Context) error {
//b.db.Backfill.backfillQueryLock.Lock()
//defer b.db.Backfill.backfillQueryLock.Unlock()
return b.qh.GetDB().QueryRow(ctx, insertBackfillTaskQuery, b.sqlVariables()...).Scan(&b.QueueID)
}
func (b *BackfillTask) MarkDispatched(ctx context.Context) error {
//b.db.Backfill.backfillQueryLock.Lock()
//defer b.db.Backfill.backfillQueryLock.Unlock()
if b.QueueID == 0 {
return fmt.Errorf("can't mark backfill as dispatched without queue_id")
}
return b.qh.Exec(ctx, markBackfillTaskDispatchedQuery, time.Now(), b.QueueID)
}
func (b *BackfillTask) MarkDone(ctx context.Context) error {
//b.db.Backfill.backfillQueryLock.Lock()
//defer b.db.Backfill.backfillQueryLock.Unlock()
if b.QueueID == 0 {
return fmt.Errorf("can't mark backfill as dispatched without queue_id")
}
return b.qh.Exec(ctx, markBackfillTaskDoneQuery, time.Now(), b.QueueID)
}

View file

@ -1,94 +0,0 @@
// mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge.
// Copyright (C) 2024 Tulir Asokan, Sumner Evans
//
// 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 <https://www.gnu.org/licenses/>.
package database
import (
"context"
"go.mau.fi/util/dbutil"
"maunium.net/go/mautrix/id"
)
type BackfillStateQuery struct {
*dbutil.QueryHelper[*BackfillState]
}
func newBackfillState(qh *dbutil.QueryHelper[*BackfillState]) *BackfillState {
return &BackfillState{qh: qh}
}
func (bq *BackfillStateQuery) NewBackfillState(userID id.UserID, portalKey PortalKey) *BackfillState {
return &BackfillState{
qh: bq.QueryHelper,
UserID: userID,
Portal: portalKey,
}
}
const (
getBackfillStateQuery = `
SELECT user_mxid, portal_jid, portal_receiver, processing_batch, backfill_complete, first_expected_ts
FROM backfill_state
WHERE user_mxid=$1
AND portal_jid=$2
AND portal_receiver=$3
`
upsertBackfillStateQuery = `
INSERT INTO backfill_state
(user_mxid, portal_jid, portal_receiver, processing_batch, backfill_complete, first_expected_ts)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (user_mxid, portal_jid, portal_receiver)
DO UPDATE SET
processing_batch=EXCLUDED.processing_batch,
backfill_complete=EXCLUDED.backfill_complete,
first_expected_ts=EXCLUDED.first_expected_ts
`
)
func (bq *BackfillStateQuery) GetBackfillState(ctx context.Context, userID id.UserID, portalKey PortalKey) (*BackfillState, error) {
return bq.QueryOne(ctx, getBackfillStateQuery, userID, portalKey.JID, portalKey.Receiver)
}
type BackfillState struct {
qh *dbutil.QueryHelper[*BackfillState]
UserID id.UserID
Portal PortalKey
ProcessingBatch bool
BackfillComplete bool
FirstExpectedTimestamp uint64
}
func (b *BackfillState) Scan(row dbutil.Scannable) (*BackfillState, error) {
return dbutil.ValueOrErr(b, row.Scan(
&b.UserID, &b.Portal.JID, &b.Portal.Receiver, &b.ProcessingBatch, &b.BackfillComplete, &b.FirstExpectedTimestamp,
))
}
func (b *BackfillState) sqlVariables() []any {
return []any{b.UserID, b.Portal.JID, b.Portal.Receiver, b.ProcessingBatch, b.BackfillComplete, b.FirstExpectedTimestamp}
}
func (b *BackfillState) Upsert(ctx context.Context) error {
return b.qh.Exec(ctx, upsertBackfillStateQuery, b.sqlVariables()...)
}
func (b *BackfillState) SetProcessingBatch(ctx context.Context, processing bool) error {
b.ProcessingBatch = processing
return b.Upsert(ctx)
}

View file

@ -1,94 +0,0 @@
// mautrix-whatsapp - A Matrix-WhatsApp 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 <https://www.gnu.org/licenses/>.
package database
import (
"errors"
"net"
"time"
"github.com/lib/pq"
_ "github.com/mattn/go-sqlite3"
"go.mau.fi/util/dbutil"
"go.mau.fi/whatsmeow/store"
"go.mau.fi/whatsmeow/store/sqlstore"
"maunium.net/go/mautrix-whatsapp/database/upgrades"
)
func init() {
sqlstore.PostgresArrayWrapper = pq.Array
}
type Database struct {
*dbutil.Database
User *UserQuery
Portal *PortalQuery
Puppet *PuppetQuery
Message *MessageQuery
Reaction *ReactionQuery
DisappearingMessage *DisappearingMessageQuery
BackfillQueue *BackfillTaskQuery
BackfillState *BackfillStateQuery
HistorySync *HistorySyncQuery
MediaBackfillRequest *MediaBackfillRequestQuery
}
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)},
DisappearingMessage: &DisappearingMessageQuery{dbutil.MakeQueryHelper(db, newDisappearingMessage)},
BackfillQueue: &BackfillTaskQuery{dbutil.MakeQueryHelper(db, newBackfillTask)},
BackfillState: &BackfillStateQuery{dbutil.MakeQueryHelper(db, newBackfillState)},
HistorySync: &HistorySyncQuery{dbutil.MakeQueryHelper(db, newHistorySyncConversation)},
MediaBackfillRequest: &MediaBackfillRequestQuery{dbutil.MakeQueryHelper(db, newMediaBackfillRequest)},
}
}
func isRetryableError(err error) bool {
if pqError := (&pq.Error{}); errors.As(err, &pqError) {
switch pqError.Code.Class() {
case "08", // Connection Exception
"53", // Insufficient Resources (e.g. too many connections)
"57": // Operator Intervention (e.g. server restart)
return true
}
} else if netError := (&net.OpError{}); errors.As(err, &netError) {
return true
}
return false
}
func (db *Database) HandleSignalStoreError(device *store.Device, action string, attemptIndex int, err error) (retry bool) {
if db.Dialect != dbutil.SQLite && isRetryableError(err) {
sleepTime := time.Duration(attemptIndex*2) * time.Second
device.Log.Warnf("Failed to %s (attempt #%d): %v - retrying in %v", action, attemptIndex+1, err, sleepTime)
time.Sleep(sleepTime)
return true
}
device.Log.Errorf("Failed to %s: %v", action, err)
return false
}

View file

@ -1,102 +0,0 @@
// mautrix-whatsapp - A Matrix-WhatsApp 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 <https://www.gnu.org/licenses/>.
package database
import (
"context"
"database/sql"
"time"
"maunium.net/go/mautrix/id"
"go.mau.fi/util/dbutil"
)
type DisappearingMessageQuery struct {
*dbutil.QueryHelper[*DisappearingMessage]
}
func newDisappearingMessage(qh *dbutil.QueryHelper[*DisappearingMessage]) *DisappearingMessage {
return &DisappearingMessage{
qh: qh,
}
}
func (dmq *DisappearingMessageQuery) NewWithValues(roomID id.RoomID, eventID id.EventID, expireIn time.Duration, expireAt time.Time) *DisappearingMessage {
dm := &DisappearingMessage{
qh: dmq.QueryHelper,
RoomID: roomID,
EventID: eventID,
ExpireIn: expireIn,
ExpireAt: expireAt,
}
return dm
}
const (
getAllScheduledDisappearingMessagesQuery = `
SELECT room_id, event_id, expire_in, expire_at FROM disappearing_message WHERE expire_at IS NOT NULL AND expire_at <= $1
`
insertDisappearingMessageQuery = `INSERT INTO disappearing_message (room_id, event_id, expire_in, expire_at) VALUES ($1, $2, $3, $4)`
updateDisappearingMessageExpiryQuery = "UPDATE disappearing_message SET expire_at=$1 WHERE room_id=$2 AND event_id=$3"
deleteDisappearingMessageQuery = "DELETE FROM disappearing_message WHERE room_id=$1 AND event_id=$2"
)
func (dmq *DisappearingMessageQuery) GetUpcomingScheduled(ctx context.Context, duration time.Duration) ([]*DisappearingMessage, error) {
return dmq.QueryMany(ctx, getAllScheduledDisappearingMessagesQuery, time.Now().Add(duration).UnixMilli())
}
type DisappearingMessage struct {
qh *dbutil.QueryHelper[*DisappearingMessage]
RoomID id.RoomID
EventID id.EventID
ExpireIn time.Duration
ExpireAt time.Time
}
func (msg *DisappearingMessage) Scan(row dbutil.Scannable) (*DisappearingMessage, error) {
var expireIn int64
var expireAt sql.NullInt64
err := row.Scan(&msg.RoomID, &msg.EventID, &expireIn, &expireAt)
if err != nil {
return nil, err
}
msg.ExpireIn = time.Duration(expireIn) * time.Millisecond
if expireAt.Valid {
msg.ExpireAt = time.UnixMilli(expireAt.Int64)
}
return msg, nil
}
func (msg *DisappearingMessage) sqlVariables() []any {
return []any{msg.RoomID, msg.EventID, msg.ExpireIn.Milliseconds(), dbutil.UnixMilliPtr(msg.ExpireAt)}
}
func (msg *DisappearingMessage) Insert(ctx context.Context) error {
return msg.qh.Exec(ctx, insertDisappearingMessageQuery, msg.sqlVariables()...)
}
func (msg *DisappearingMessage) StartTimer(ctx context.Context) error {
msg.ExpireAt = time.Now().Add(msg.ExpireIn * time.Second)
return msg.qh.Exec(ctx, updateDisappearingMessageExpiryQuery, msg.ExpireAt.Unix(), msg.RoomID, msg.EventID)
}
func (msg *DisappearingMessage) Delete(ctx context.Context) error {
return msg.qh.Exec(ctx, deleteDisappearingMessageQuery, msg.RoomID, msg.EventID)
}

View file

@ -1,302 +0,0 @@
// mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge.
// Copyright (C) 2024 Tulir Asokan, Sumner Evans
//
// 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 <https://www.gnu.org/licenses/>.
package database
import (
"context"
"fmt"
"time"
_ "github.com/mattn/go-sqlite3"
"go.mau.fi/util/dbutil"
waProto "go.mau.fi/whatsmeow/binary/proto"
"google.golang.org/protobuf/proto"
"maunium.net/go/mautrix/id"
)
type HistorySyncQuery struct {
*dbutil.QueryHelper[*HistorySyncConversation]
}
type HistorySyncConversation struct {
qh *dbutil.QueryHelper[*HistorySyncConversation]
UserID id.UserID
ConversationID string
PortalKey PortalKey
LastMessageTimestamp time.Time
MuteEndTime time.Time
Archived bool
Pinned uint32
DisappearingMode waProto.DisappearingMode_Initiator
EndOfHistoryTransferType waProto.Conversation_EndOfHistoryTransferType
EphemeralExpiration *uint32
MarkedAsUnread bool
UnreadCount uint32
}
func newHistorySyncConversation(qh *dbutil.QueryHelper[*HistorySyncConversation]) *HistorySyncConversation {
return &HistorySyncConversation{
qh: qh,
}
}
func (hsq *HistorySyncQuery) NewConversationWithValues(
userID id.UserID,
conversationID string,
portalKey PortalKey,
lastMessageTimestamp,
muteEndTime uint64,
archived bool,
pinned uint32,
disappearingMode waProto.DisappearingMode_Initiator,
endOfHistoryTransferType waProto.Conversation_EndOfHistoryTransferType,
ephemeralExpiration *uint32,
markedAsUnread bool,
unreadCount uint32,
) *HistorySyncConversation {
return &HistorySyncConversation{
qh: hsq.QueryHelper,
UserID: userID,
ConversationID: conversationID,
PortalKey: portalKey,
LastMessageTimestamp: time.Unix(int64(lastMessageTimestamp), 0).UTC(),
MuteEndTime: time.Unix(int64(muteEndTime), 0).UTC(),
Archived: archived,
Pinned: pinned,
DisappearingMode: disappearingMode,
EndOfHistoryTransferType: endOfHistoryTransferType,
EphemeralExpiration: ephemeralExpiration,
MarkedAsUnread: markedAsUnread,
UnreadCount: unreadCount,
}
}
const (
upsertHistorySyncConversationQuery = `
INSERT INTO history_sync_conversation (user_mxid, conversation_id, portal_jid, portal_receiver, last_message_timestamp, archived, pinned, mute_end_time, disappearing_mode, end_of_history_transfer_type, ephemeral_expiration, marked_as_unread, unread_count)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
ON CONFLICT (user_mxid, conversation_id)
DO UPDATE SET
last_message_timestamp=CASE
WHEN EXCLUDED.last_message_timestamp > history_sync_conversation.last_message_timestamp THEN EXCLUDED.last_message_timestamp
ELSE history_sync_conversation.last_message_timestamp
END,
end_of_history_transfer_type=EXCLUDED.end_of_history_transfer_type
`
getNMostRecentConversations = `
SELECT user_mxid, conversation_id, portal_jid, portal_receiver, last_message_timestamp, archived, pinned, mute_end_time, disappearing_mode, end_of_history_transfer_type, ephemeral_expiration, marked_as_unread, unread_count
FROM history_sync_conversation
WHERE user_mxid=$1
ORDER BY last_message_timestamp DESC
LIMIT $2
`
getConversationByPortal = `
SELECT user_mxid, conversation_id, portal_jid, portal_receiver, last_message_timestamp, archived, pinned, mute_end_time, disappearing_mode, end_of_history_transfer_type, ephemeral_expiration, marked_as_unread, unread_count
FROM history_sync_conversation
WHERE user_mxid=$1
AND portal_jid=$2
AND portal_receiver=$3
`
deleteAllConversationsQuery = "DELETE FROM history_sync_conversation WHERE user_mxid=$1"
deleteHistorySyncConversationQuery = `
DELETE FROM history_sync_conversation
WHERE user_mxid=$1 AND conversation_id=$2
`
)
func (hsc *HistorySyncConversation) sqlVariables() []any {
return []any{
hsc.UserID,
hsc.ConversationID,
hsc.PortalKey.JID,
hsc.PortalKey.Receiver,
hsc.LastMessageTimestamp,
hsc.Archived,
hsc.Pinned,
hsc.MuteEndTime,
hsc.DisappearingMode,
hsc.EndOfHistoryTransferType,
hsc.EphemeralExpiration,
hsc.MarkedAsUnread,
hsc.UnreadCount,
}
}
func (hsc *HistorySyncConversation) Upsert(ctx context.Context) error {
return hsc.qh.Exec(ctx, upsertHistorySyncConversationQuery, hsc.sqlVariables()...)
}
func (hsc *HistorySyncConversation) Scan(row dbutil.Scannable) (*HistorySyncConversation, error) {
return dbutil.ValueOrErr(hsc, row.Scan(
&hsc.UserID,
&hsc.ConversationID,
&hsc.PortalKey.JID,
&hsc.PortalKey.Receiver,
&hsc.LastMessageTimestamp,
&hsc.Archived,
&hsc.Pinned,
&hsc.MuteEndTime,
&hsc.DisappearingMode,
&hsc.EndOfHistoryTransferType,
&hsc.EphemeralExpiration,
&hsc.MarkedAsUnread,
&hsc.UnreadCount,
))
}
func (hsq *HistorySyncQuery) GetRecentConversations(ctx context.Context, userID id.UserID, n int) ([]*HistorySyncConversation, error) {
nPtr := &n
// Negative limit on SQLite means unlimited, but Postgres prefers a NULL limit.
if n < 0 && hsq.GetDB().Dialect == dbutil.Postgres {
nPtr = nil
}
return hsq.QueryMany(ctx, getNMostRecentConversations, userID, nPtr)
}
func (hsq *HistorySyncQuery) GetConversation(ctx context.Context, userID id.UserID, portalKey PortalKey) (*HistorySyncConversation, error) {
return hsq.QueryOne(ctx, getConversationByPortal, userID, portalKey.JID, portalKey.Receiver)
}
func (hsq *HistorySyncQuery) DeleteAllConversations(ctx context.Context, userID id.UserID) error {
return hsq.Exec(ctx, deleteAllConversationsQuery, userID)
}
const (
insertHistorySyncMessageQuery = `
INSERT INTO history_sync_message (user_mxid, conversation_id, message_id, timestamp, data, inserted_time)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (user_mxid, conversation_id, message_id) DO NOTHING
`
getHistorySyncMessagesBetweenQueryTemplate = `
SELECT data FROM history_sync_message
WHERE user_mxid=$1 AND conversation_id=$2
%s
ORDER BY timestamp DESC
%s
`
deleteHistorySyncMessagesBetweenExclusiveQuery = `
DELETE FROM history_sync_message
WHERE user_mxid=$1 AND conversation_id=$2 AND timestamp<$3 AND timestamp>$4
`
deleteAllHistorySyncMessagesQuery = "DELETE FROM history_sync_message WHERE user_mxid=$1"
deleteHistorySyncMessagesForPortalQuery = `
DELETE FROM history_sync_message
WHERE user_mxid=$1 AND conversation_id=$2
`
conversationHasHistorySyncMessagesQuery = `
SELECT EXISTS(
SELECT 1 FROM history_sync_message
WHERE user_mxid=$1 AND conversation_id=$2
)
`
)
type HistorySyncMessage struct {
hsq *HistorySyncQuery
UserID id.UserID
ConversationID string
MessageID string
Timestamp time.Time
Data []byte
}
func (hsq *HistorySyncQuery) NewMessageWithValues(userID id.UserID, conversationID, messageID string, message *waProto.HistorySyncMsg) (*HistorySyncMessage, error) {
msgData, err := proto.Marshal(message)
if err != nil {
return nil, err
}
return &HistorySyncMessage{
hsq: hsq,
UserID: userID,
ConversationID: conversationID,
MessageID: messageID,
Timestamp: time.Unix(int64(message.Message.GetMessageTimestamp()), 0),
Data: msgData,
}, nil
}
func (hsm *HistorySyncMessage) Insert(ctx context.Context) error {
return hsm.hsq.Exec(ctx, insertHistorySyncMessageQuery, hsm.UserID, hsm.ConversationID, hsm.MessageID, hsm.Timestamp, hsm.Data, time.Now())
}
func scanWebMessageInfo(rows dbutil.Scannable) (*waProto.WebMessageInfo, error) {
var msgData []byte
err := rows.Scan(&msgData)
if err != nil {
return nil, err
}
var historySyncMsg waProto.HistorySyncMsg
err = proto.Unmarshal(msgData, &historySyncMsg)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal message: %w", err)
}
return historySyncMsg.GetMessage(), nil
}
func (hsq *HistorySyncQuery) GetMessagesBetween(ctx context.Context, userID id.UserID, conversationID string, startTime, endTime *time.Time, limit int) ([]*waProto.WebMessageInfo, error) {
whereClauses := ""
args := []any{userID, conversationID}
argNum := 3
if startTime != nil {
whereClauses += fmt.Sprintf(" AND timestamp >= $%d", argNum)
args = append(args, startTime)
argNum++
}
if endTime != nil {
whereClauses += fmt.Sprintf(" AND timestamp <= $%d", argNum)
args = append(args, endTime)
}
limitClause := ""
if limit > 0 {
limitClause = fmt.Sprintf("LIMIT %d", limit)
}
query := fmt.Sprintf(getHistorySyncMessagesBetweenQueryTemplate, whereClauses, limitClause)
return dbutil.ConvertRowFn[*waProto.WebMessageInfo](scanWebMessageInfo).
NewRowIter(hsq.GetDB().Query(ctx, query, args...)).
AsList()
}
func (hsq *HistorySyncQuery) DeleteMessages(ctx context.Context, userID id.UserID, conversationID string, messages []*waProto.WebMessageInfo) error {
newest := messages[0]
beforeTS := time.Unix(int64(newest.GetMessageTimestamp())+1, 0)
oldest := messages[len(messages)-1]
afterTS := time.Unix(int64(oldest.GetMessageTimestamp())-1, 0)
return hsq.Exec(ctx, deleteHistorySyncMessagesBetweenExclusiveQuery, userID, conversationID, beforeTS, afterTS)
}
func (hsq *HistorySyncQuery) DeleteAllMessages(ctx context.Context, userID id.UserID) error {
return hsq.Exec(ctx, deleteAllHistorySyncMessagesQuery, userID)
}
func (hsq *HistorySyncQuery) DeleteAllMessagesForPortal(ctx context.Context, userID id.UserID, portalKey PortalKey) error {
return hsq.Exec(ctx, deleteHistorySyncMessagesForPortalQuery, userID, portalKey.JID)
}
func (hsq *HistorySyncQuery) ConversationHasMessages(ctx context.Context, userID id.UserID, portalKey PortalKey) (exists bool, err error) {
err = hsq.GetDB().QueryRow(ctx, conversationHasHistorySyncMessagesQuery, userID, portalKey.JID).Scan(&exists)
return
}
func (hsq *HistorySyncQuery) DeleteConversation(ctx context.Context, userID id.UserID, jid string) error {
// This will also clear history_sync_message as there's a foreign key constraint
return hsq.Exec(ctx, deleteHistorySyncConversationQuery, userID, jid)
}

View file

@ -1,106 +0,0 @@
// mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge.
// Copyright (C) 2024 Tulir Asokan, Sumner Evans
//
// 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 <https://www.gnu.org/licenses/>.
package database
import (
"context"
_ "github.com/mattn/go-sqlite3"
"maunium.net/go/mautrix/id"
"go.mau.fi/util/dbutil"
)
type MediaBackfillRequestStatus int
const (
MediaBackfillRequestStatusNotRequested MediaBackfillRequestStatus = iota
MediaBackfillRequestStatusRequested
MediaBackfillRequestStatusRequestFailed
)
type MediaBackfillRequestQuery struct {
*dbutil.QueryHelper[*MediaBackfillRequest]
}
const (
getAllMediaBackfillRequestsForUserQuery = `
SELECT user_mxid, portal_jid, portal_receiver, event_id, media_key, status, error
FROM media_backfill_requests
WHERE user_mxid=$1
AND status=0
`
deleteAllMediaBackfillRequestsForUserQuery = "DELETE FROM media_backfill_requests WHERE user_mxid=$1"
upsertMediaBackfillRequestQuery = `
INSERT INTO media_backfill_requests (user_mxid, portal_jid, portal_receiver, event_id, media_key, status, error)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (user_mxid, portal_jid, portal_receiver, event_id)
DO UPDATE SET
media_key=excluded.media_key,
status=excluded.status,
error=excluded.error
`
)
func (mbrq *MediaBackfillRequestQuery) GetMediaBackfillRequestsForUser(ctx context.Context, userID id.UserID) ([]*MediaBackfillRequest, error) {
return mbrq.QueryMany(ctx, getAllMediaBackfillRequestsForUserQuery, userID)
}
func (mbrq *MediaBackfillRequestQuery) DeleteAllMediaBackfillRequests(ctx context.Context, userID id.UserID) error {
return mbrq.Exec(ctx, deleteAllMediaBackfillRequestsForUserQuery, userID)
}
func newMediaBackfillRequest(qh *dbutil.QueryHelper[*MediaBackfillRequest]) *MediaBackfillRequest {
return &MediaBackfillRequest{
qh: qh,
}
}
func (mbrq *MediaBackfillRequestQuery) NewMediaBackfillRequestWithValues(userID id.UserID, portalKey PortalKey, eventID id.EventID, mediaKey []byte) *MediaBackfillRequest {
return &MediaBackfillRequest{
qh: mbrq.QueryHelper,
UserID: userID,
PortalKey: portalKey,
EventID: eventID,
MediaKey: mediaKey,
Status: MediaBackfillRequestStatusNotRequested,
}
}
type MediaBackfillRequest struct {
qh *dbutil.QueryHelper[*MediaBackfillRequest]
UserID id.UserID
PortalKey PortalKey
EventID id.EventID
MediaKey []byte
Status MediaBackfillRequestStatus
Error string
}
func (mbr *MediaBackfillRequest) Scan(row dbutil.Scannable) (*MediaBackfillRequest, error) {
return dbutil.ValueOrErr(mbr, row.Scan(&mbr.UserID, &mbr.PortalKey.JID, &mbr.PortalKey.Receiver, &mbr.EventID, &mbr.MediaKey, &mbr.Status, &mbr.Error))
}
func (mbr *MediaBackfillRequest) sqlVariables() []any {
return []any{mbr.UserID, mbr.PortalKey.JID, mbr.PortalKey.Receiver, mbr.EventID, mbr.MediaKey, mbr.Status, mbr.Error}
}
func (mbr *MediaBackfillRequest) Upsert(ctx context.Context) error {
return mbr.qh.Exec(ctx, upsertMediaBackfillRequestQuery, mbr.sqlVariables()...)
}

View file

@ -1,199 +0,0 @@
// mautrix-whatsapp - A Matrix-WhatsApp 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 <https://www.gnu.org/licenses/>.
package database
import (
"context"
"fmt"
"strings"
"time"
"go.mau.fi/util/dbutil"
"go.mau.fi/whatsmeow/types"
"maunium.net/go/mautrix/id"
)
type MessageQuery struct {
*dbutil.QueryHelper[*Message]
}
func newMessage(qh *dbutil.QueryHelper[*Message]) *Message {
return &Message{qh: qh}
}
const (
getAllMessagesQuery = `
SELECT chat_jid, chat_receiver, jid, mxid, sender, sender_mxid, timestamp, sent, type, error, broadcast_list_jid FROM message
WHERE chat_jid=$1 AND chat_receiver=$2
`
getMessageByJIDQuery = `
SELECT chat_jid, chat_receiver, jid, mxid, sender, sender_mxid, timestamp, sent, type, error, broadcast_list_jid FROM message
WHERE chat_jid=$1 AND chat_receiver=$2 AND jid=$3
`
getMessageByMXIDQuery = `
SELECT chat_jid, chat_receiver, jid, mxid, sender, sender_mxid, timestamp, sent, type, error, broadcast_list_jid FROM message
WHERE mxid=$1
`
getLastMessageInChatQuery = `
SELECT chat_jid, chat_receiver, jid, mxid, sender, sender_mxid, timestamp, sent, type, error, broadcast_list_jid FROM message
WHERE chat_jid=$1 AND chat_receiver=$2 AND timestamp<=$3 AND sent=true ORDER BY timestamp DESC LIMIT 1
`
getFirstMessageInChatQuery = `
SELECT chat_jid, chat_receiver, jid, mxid, sender, sender_mxid, timestamp, sent, type, error, broadcast_list_jid FROM message
WHERE chat_jid=$1 AND chat_receiver=$2 AND sent=true ORDER BY timestamp ASC LIMIT 1
`
getMessagesBetweenQuery = `
SELECT chat_jid, chat_receiver, jid, mxid, sender, sender_mxid, timestamp, sent, type, error, broadcast_list_jid FROM message
WHERE chat_jid=$1 AND chat_receiver=$2 AND timestamp>$3 AND timestamp<=$4 AND sent=true AND error='' ORDER BY timestamp ASC
`
insertMessageQuery = `
INSERT INTO message
(chat_jid, chat_receiver, jid, mxid, sender, sender_mxid, timestamp, sent, type, error, broadcast_list_jid)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
`
markMessageSentQuery = "UPDATE message SET sent=true, timestamp=$1 WHERE chat_jid=$2 AND chat_receiver=$3 AND jid=$4"
updateMessageMXIDQuery = "UPDATE message SET mxid=$1, type=$2, error=$3 WHERE chat_jid=$4 AND chat_receiver=$5 AND jid=$6"
deleteMessageQuery = "DELETE FROM message WHERE chat_jid=$1 AND chat_receiver=$2 AND jid=$3"
)
func (mq *MessageQuery) GetAll(ctx context.Context, chat PortalKey) ([]*Message, error) {
return mq.QueryMany(ctx, getAllMessagesQuery, chat.JID, chat.Receiver)
}
func (mq *MessageQuery) GetByJID(ctx context.Context, chat PortalKey, jid types.MessageID) (*Message, error) {
return mq.QueryOne(ctx, getMessageByJIDQuery, chat.JID, chat.Receiver, jid)
}
func (mq *MessageQuery) GetByMXID(ctx context.Context, mxid id.EventID) (*Message, error) {
return mq.QueryOne(ctx, getMessageByMXIDQuery, mxid)
}
func (mq *MessageQuery) GetLastInChat(ctx context.Context, chat PortalKey) (*Message, error) {
return mq.GetLastInChatBefore(ctx, chat, time.Now().Add(60*time.Second))
}
func (mq *MessageQuery) GetLastInChatBefore(ctx context.Context, chat PortalKey, maxTimestamp time.Time) (*Message, error) {
msg, err := mq.QueryOne(ctx, getLastMessageInChatQuery, chat.JID, chat.Receiver, maxTimestamp.Unix())
if msg != nil && msg.Timestamp.IsZero() {
// Old db, we don't know what the last message is.
msg = nil
}
return msg, err
}
func (mq *MessageQuery) GetFirstInChat(ctx context.Context, chat PortalKey) (*Message, error) {
return mq.QueryOne(ctx, getFirstMessageInChatQuery, chat.JID, chat.Receiver)
}
func (mq *MessageQuery) GetMessagesBetween(ctx context.Context, chat PortalKey, minTimestamp, maxTimestamp time.Time) ([]*Message, error) {
return mq.QueryMany(ctx, getMessagesBetweenQuery, chat.JID, chat.Receiver, minTimestamp.Unix(), maxTimestamp.Unix())
}
type MessageErrorType string
const (
MsgNoError MessageErrorType = ""
MsgErrDecryptionFailed MessageErrorType = "decryption_failed"
MsgErrMediaNotFound MessageErrorType = "media_not_found"
)
type MessageType string
const (
MsgUnknown MessageType = ""
MsgFake MessageType = "fake"
MsgNormal MessageType = "message"
MsgReaction MessageType = "reaction"
MsgEdit MessageType = "edit"
MsgMatrixPoll MessageType = "matrix-poll"
MsgBeeperGallery MessageType = "beeper-gallery"
)
type Message struct {
qh *dbutil.QueryHelper[*Message]
Chat PortalKey
JID types.MessageID
MXID id.EventID
Sender types.JID
SenderMXID id.UserID
Timestamp time.Time
Sent bool
Type MessageType
Error MessageErrorType
GalleryPart int
BroadcastListJID types.JID
}
func (msg *Message) IsFakeMXID() bool {
return strings.HasPrefix(msg.MXID.String(), "net.maunium.whatsapp.fake::")
}
func (msg *Message) IsFakeJID() bool {
return strings.HasPrefix(msg.JID, "FAKE::") || msg.JID == string(msg.MXID)
}
const fakeGalleryMXIDFormat = "com.beeper.gallery::%d:%s"
func (msg *Message) Scan(row dbutil.Scannable) (*Message, error) {
var ts int64
err := row.Scan(&msg.Chat.JID, &msg.Chat.Receiver, &msg.JID, &msg.MXID, &msg.Sender, &msg.SenderMXID, &ts, &msg.Sent, &msg.Type, &msg.Error, &msg.BroadcastListJID)
if err != nil {
return nil, err
}
if strings.HasPrefix(msg.MXID.String(), "com.beeper.gallery::") {
_, err = fmt.Sscanf(msg.MXID.String(), fakeGalleryMXIDFormat, &msg.GalleryPart, &msg.MXID)
if err != nil {
return nil, fmt.Errorf("failed to parse gallery MXID: %w", err)
}
}
if ts != 0 {
msg.Timestamp = time.Unix(ts, 0)
}
return msg, nil
}
func (msg *Message) sqlVariables() []any {
mxid := msg.MXID.String()
if msg.GalleryPart != 0 {
mxid = fmt.Sprintf(fakeGalleryMXIDFormat, msg.GalleryPart, mxid)
}
return []any{msg.Chat.JID, msg.Chat.Receiver, msg.JID, mxid, msg.Sender, msg.SenderMXID, msg.Timestamp.Unix(), msg.Sent, msg.Type, msg.Error, msg.BroadcastListJID}
}
func (msg *Message) Insert(ctx context.Context) error {
return msg.qh.Exec(ctx, insertMessageQuery, msg.sqlVariables()...)
}
func (msg *Message) MarkSent(ctx context.Context, ts time.Time) error {
msg.Sent = true
msg.Timestamp = ts
return msg.qh.Exec(ctx, markMessageSentQuery, ts.Unix(), msg.Chat.JID, msg.Chat.Receiver, msg.JID)
}
func (msg *Message) UpdateMXID(ctx context.Context, mxid id.EventID, newType MessageType, newError MessageErrorType) error {
msg.MXID = mxid
msg.Type = newType
msg.Error = newError
return msg.qh.Exec(ctx, updateMessageMXIDQuery, mxid, newType, newError, msg.Chat.JID, msg.Chat.Receiver, msg.JID)
}
func (msg *Message) Delete(ctx context.Context) error {
return msg.qh.Exec(ctx, deleteMessageQuery, msg.Chat.JID, msg.Chat.Receiver, msg.JID)
}

View file

@ -1,121 +0,0 @@
// mautrix-whatsapp - A Matrix-WhatsApp 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 <https://www.gnu.org/licenses/>.
package database
import (
"context"
"fmt"
"strings"
"github.com/lib/pq"
"go.mau.fi/util/dbutil"
)
const (
bulkPutPollOptionsQuery = "INSERT INTO poll_option_id (msg_mxid, opt_id, opt_hash) VALUES ($1, $2, $3)"
bulkPutPollOptionsQueryTemplate = "($1, $%d, $%d)"
bulkPutPollOptionsQueryPlaceholder = "($1, $2, $3)"
getPollOptionIDsByHashesQuery = "SELECT opt_id, opt_hash FROM poll_option_id WHERE msg_mxid=$1 AND opt_hash = ANY($2)"
getPollOptionHashesByIDsQuery = "SELECT opt_id, opt_hash FROM poll_option_id WHERE msg_mxid=$1 AND opt_id = ANY($2)"
getPollOptionQuerySQLiteArrayTemplate = " IN (%s)"
getPollOptionQueryArrayPlaceholder = " = ANY($2)"
)
func init() {
if strings.ReplaceAll(bulkPutPollOptionsQuery, bulkPutPollOptionsQueryPlaceholder, "meow") == bulkPutPollOptionsQuery {
panic("Bulk insert query placeholder not found")
}
if strings.ReplaceAll(getPollOptionIDsByHashesQuery, getPollOptionQueryArrayPlaceholder, "meow") == getPollOptionIDsByHashesQuery {
panic("Array select query placeholder not found")
}
if strings.ReplaceAll(getPollOptionHashesByIDsQuery, getPollOptionQueryArrayPlaceholder, "meow") == getPollOptionIDsByHashesQuery {
panic("Array select query placeholder not found")
}
}
type pollOption struct {
id string
hash [32]byte
}
func scanPollOption(rows dbutil.Scannable) (*pollOption, error) {
var hash []byte
var id string
err := rows.Scan(&id, &hash)
if err != nil {
return nil, err
} else if len(hash) != 32 {
return nil, fmt.Errorf("unexpected hash length %d", len(hash))
} else {
return &pollOption{id: id, hash: [32]byte(hash)}, nil
}
}
func (msg *Message) PutPollOptions(ctx context.Context, opts map[[32]byte]string) error {
args := make([]any, len(opts)*2+1)
placeholders := make([]string, len(opts))
args[0] = msg.MXID
i := 0
for hash, id := range opts {
args[i*2+1] = id
hashCopy := hash
args[i*2+2] = hashCopy[:]
placeholders[i] = fmt.Sprintf(bulkPutPollOptionsQueryTemplate, i*2+2, i*2+3)
i++
}
query := strings.ReplaceAll(bulkPutPollOptionsQuery, bulkPutPollOptionsQueryPlaceholder, strings.Join(placeholders, ","))
return msg.qh.Exec(ctx, query, args...)
}
func getPollOptions[LookupKey any, Key comparable, Value any](
ctx context.Context,
msg *Message,
query string,
things []LookupKey,
getKeyValue func(option *pollOption) (Key, Value),
) (map[Key]Value, error) {
var args []any
if msg.qh.GetDB().Dialect == dbutil.Postgres {
args = []any{msg.MXID, pq.Array(things)}
} else {
query = strings.ReplaceAll(query, getPollOptionQueryArrayPlaceholder, fmt.Sprintf(getPollOptionQuerySQLiteArrayTemplate, strings.TrimSuffix(strings.Repeat("?,", len(things)), ",")))
args = make([]any, len(things)+1)
args[0] = msg.MXID
for i, thing := range things {
args[i+1] = thing
}
}
return dbutil.RowIterAsMap(
dbutil.ConvertRowFn[*pollOption](scanPollOption).NewRowIter(msg.qh.GetDB().Query(ctx, query, args...)),
getKeyValue,
)
}
func (msg *Message) GetPollOptionIDs(ctx context.Context, hashes [][]byte) (map[[32]byte]string, error) {
return getPollOptions(
ctx, msg, getPollOptionIDsByHashesQuery, hashes,
func(t *pollOption) ([32]byte, string) { return t.hash, t.id },
)
}
func (msg *Message) GetPollOptionHashes(ctx context.Context, ids []string) (map[string][32]byte, error) {
return getPollOptions(
ctx, msg, getPollOptionHashesByIDsQuery, ids,
func(t *pollOption) (string, [32]byte) { return t.id, t.hash },
)
}

View file

@ -1,217 +0,0 @@
// mautrix-whatsapp - A Matrix-WhatsApp 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 <https://www.gnu.org/licenses/>.
package database
import (
"context"
"database/sql"
"time"
"go.mau.fi/whatsmeow/types"
"maunium.net/go/mautrix/id"
"go.mau.fi/util/dbutil"
)
type PortalKey struct {
JID types.JID
Receiver types.JID
}
func NewPortalKey(jid, receiver types.JID) PortalKey {
if jid.Server == types.GroupServer || jid.Server == types.NewsletterServer {
receiver = jid
} else if jid.Server == types.LegacyUserServer {
jid.Server = types.DefaultUserServer
}
return PortalKey{
JID: jid.ToNonAD(),
Receiver: receiver.ToNonAD(),
}
}
func (key PortalKey) String() string {
if key.Receiver == key.JID {
return key.JID.String()
}
return key.JID.String() + "-" + key.Receiver.String()
}
type PortalQuery struct {
*dbutil.QueryHelper[*Portal]
}
func newPortal(qh *dbutil.QueryHelper[*Portal]) *Portal {
return &Portal{
qh: qh,
}
}
const (
getAllPortalsQuery = `
SELECT jid, receiver, mxid, name, name_set, topic, topic_set, avatar, avatar_url, avatar_set,
encrypted, last_sync, is_parent, parent_group, in_space,
first_event_id, next_batch_id, relay_user_id, expiration_time
FROM portal
`
getPortalByJIDQuery = getAllPortalsQuery + " WHERE jid=$1 AND receiver=$2"
getPortalByMXIDQuery = getAllPortalsQuery + " WHERE mxid=$1"
getPrivateChatsWithQuery = getAllPortalsQuery + " WHERE jid=$1"
getPrivateChatsOfQuery = getAllPortalsQuery + " WHERE receiver=$1"
getAllPortalsByParentGroupQuery = getAllPortalsQuery + " WHERE parent_group=$1"
findPrivateChatPortalsNotInSpaceQuery = `
SELECT jid FROM portal
LEFT JOIN user_portal ON portal.jid=user_portal.portal_jid 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 (
jid, receiver, mxid, name, name_set, topic, topic_set, avatar, avatar_url, avatar_set,
encrypted, last_sync, is_parent, parent_group, in_space,
first_event_id, next_batch_id, relay_user_id, expiration_time
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)
`
updatePortalQuery = `
UPDATE portal
SET mxid=$3, name=$4, name_set=$5, topic=$6, topic_set=$7, avatar=$8, avatar_url=$9, avatar_set=$10,
encrypted=$11, last_sync=$12, is_parent=$13, parent_group=$14, in_space=$15,
first_event_id=$16, next_batch_id=$17, relay_user_id=$18, expiration_time=$19
WHERE jid=$1 AND receiver=$2
`
clearPortalInSpaceQuery = "UPDATE portal SET in_space=false WHERE parent_group=$1"
deletePortalQuery = "DELETE FROM portal WHERE jid=$1 AND receiver=$2"
)
func (pq *PortalQuery) GetAll(ctx context.Context) ([]*Portal, error) {
return pq.QueryMany(ctx, getAllPortalsQuery)
}
func (pq *PortalQuery) GetByJID(ctx context.Context, key PortalKey) (*Portal, error) {
return pq.QueryOne(ctx, getPortalByJIDQuery, key.JID, key.Receiver)
}
func (pq *PortalQuery) GetByMXID(ctx context.Context, mxid id.RoomID) (*Portal, error) {
return pq.QueryOne(ctx, getPortalByMXIDQuery, mxid)
}
func (pq *PortalQuery) GetAllByJID(ctx context.Context, jid types.JID) ([]*Portal, error) {
return pq.QueryMany(ctx, getPrivateChatsWithQuery, jid.ToNonAD())
}
func (pq *PortalQuery) FindPrivateChats(ctx context.Context, receiver types.JID) ([]*Portal, error) {
return pq.QueryMany(ctx, getPrivateChatsOfQuery, receiver.ToNonAD())
}
func (pq *PortalQuery) GetAllByParentGroup(ctx context.Context, jid types.JID) ([]*Portal, error) {
return pq.QueryMany(ctx, getAllPortalsByParentGroupQuery, jid)
}
func (pq *PortalQuery) FindPrivateChatsNotInSpace(ctx context.Context, receiver types.JID) (keys []PortalKey, err error) {
receiver = receiver.ToNonAD()
scanFn := func(rows dbutil.Scannable) (key PortalKey, err error) {
key.Receiver = receiver
err = rows.Scan(&key.JID)
return
}
return dbutil.ConvertRowFn[PortalKey](scanFn).
NewRowIter(pq.GetDB().Query(ctx, findPrivateChatPortalsNotInSpaceQuery, receiver)).
AsList()
}
type Portal struct {
qh *dbutil.QueryHelper[*Portal]
Key PortalKey
MXID id.RoomID
Name string
NameSet bool
Topic string
TopicSet bool
Avatar string
AvatarURL id.ContentURI
AvatarSet bool
Encrypted bool
LastSync time.Time
IsParent bool
ParentGroup types.JID
InSpace bool
FirstEventID id.EventID
NextBatchID id.BatchID
RelayUserID id.UserID
ExpirationTime uint32
}
func (portal *Portal) Scan(row dbutil.Scannable) (*Portal, error) {
var mxid, avatarURL, firstEventID, nextBatchID, relayUserID, parentGroupJID sql.NullString
var lastSyncTs int64
err := row.Scan(
&portal.Key.JID, &portal.Key.Receiver, &mxid, &portal.Name, &portal.NameSet,
&portal.Topic, &portal.TopicSet, &portal.Avatar, &avatarURL, &portal.AvatarSet, &portal.Encrypted,
&lastSyncTs, &portal.IsParent, &parentGroupJID, &portal.InSpace,
&firstEventID, &nextBatchID, &relayUserID, &portal.ExpirationTime,
)
if err != nil {
return nil, err
}
if lastSyncTs > 0 {
portal.LastSync = time.Unix(lastSyncTs, 0)
}
portal.MXID = id.RoomID(mxid.String)
portal.AvatarURL, _ = id.ParseContentURI(avatarURL.String)
if parentGroupJID.Valid {
portal.ParentGroup, _ = types.ParseJID(parentGroupJID.String)
}
portal.FirstEventID = id.EventID(firstEventID.String)
portal.NextBatchID = id.BatchID(nextBatchID.String)
portal.RelayUserID = id.UserID(relayUserID.String)
return portal, nil
}
func (portal *Portal) sqlVariables() []any {
var lastSyncTS int64
if !portal.LastSync.IsZero() {
lastSyncTS = portal.LastSync.Unix()
}
return []any{
portal.Key.JID, portal.Key.Receiver, dbutil.StrPtr(portal.MXID), portal.Name, portal.NameSet,
portal.Topic, portal.TopicSet, portal.Avatar, portal.AvatarURL.String(), portal.AvatarSet, portal.Encrypted,
lastSyncTS, portal.IsParent, dbutil.StrPtr(portal.ParentGroup.String()), portal.InSpace,
portal.FirstEventID.String(), portal.NextBatchID.String(), dbutil.StrPtr(portal.RelayUserID), portal.ExpirationTime,
}
}
func (portal *Portal) Insert(ctx context.Context) error {
return portal.qh.Exec(ctx, insertPortalQuery, portal.sqlVariables()...)
}
func (portal *Portal) Update(ctx context.Context) error {
return portal.qh.Exec(ctx, updatePortalQuery, portal.sqlVariables()...)
}
func (portal *Portal) Delete(ctx context.Context) error {
return portal.qh.GetDB().DoTxn(ctx, nil, func(ctx context.Context) error {
err := portal.qh.Exec(ctx, clearPortalInSpaceQuery, portal.Key.JID)
if err != nil {
return err
}
return portal.qh.Exec(ctx, deletePortalQuery, portal.Key.JID, portal.Key.Receiver)
})
}

View file

@ -1,153 +0,0 @@
// mautrix-whatsapp - A Matrix-WhatsApp 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 <https://www.gnu.org/licenses/>.
package database
import (
"context"
"database/sql"
"time"
"github.com/rs/zerolog"
"go.mau.fi/whatsmeow/types"
"maunium.net/go/mautrix/id"
"go.mau.fi/util/dbutil"
)
type PuppetQuery struct {
*dbutil.QueryHelper[*Puppet]
}
func newPuppet(qh *dbutil.QueryHelper[*Puppet]) *Puppet {
return &Puppet{
qh: qh,
EnablePresence: true,
EnableReceipts: true,
}
}
const (
getAllPuppetsQuery = `
SELECT username, avatar, avatar_url, displayname, name_quality, name_set, avatar_set, contact_info_set,
last_sync, custom_mxid, access_token, next_batch, enable_presence, enable_receipts
FROM puppet
`
getPuppetByJIDQuery = getAllPuppetsQuery + " WHERE username=$1"
getPuppetByCustomMXIDQuery = getAllPuppetsQuery + " WHERE custom_mxid=$1"
getAllPuppetsWithCustomMXIDQuery = getAllPuppetsQuery + " WHERE custom_mxid<>''"
insertPuppetQuery = `
INSERT INTO puppet (username, avatar, avatar_url, avatar_set, displayname, name_quality, name_set, contact_info_set,
last_sync, custom_mxid, access_token, next_batch, enable_presence, enable_receipts)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
`
updatePuppetQuery = `
UPDATE puppet
SET avatar=$2, avatar_url=$3, avatar_set=$4, displayname=$5, name_quality=$6, name_set=$7, contact_info_set=$8,
last_sync=$9, custom_mxid=$10, access_token=$11, next_batch=$12, enable_presence=$13, enable_receipts=$14
WHERE username=$1
`
)
func (pq *PuppetQuery) GetAll(ctx context.Context) ([]*Puppet, error) {
return pq.QueryMany(ctx, getAllPuppetsQuery)
}
func (pq *PuppetQuery) Get(ctx context.Context, jid types.JID) (*Puppet, error) {
return pq.QueryOne(ctx, getPuppetByJIDQuery, jid.User)
}
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, getAllPuppetsWithCustomMXIDQuery)
}
type Puppet struct {
qh *dbutil.QueryHelper[*Puppet]
JID types.JID
Avatar string
AvatarURL id.ContentURI
AvatarSet bool
Displayname string
NameQuality int8
NameSet bool
ContactInfoSet bool
LastSync time.Time
CustomMXID id.UserID
AccessToken string
NextBatch string
EnablePresence bool
EnableReceipts bool
}
func (puppet *Puppet) Scan(row dbutil.Scannable) (*Puppet, error) {
var displayname, avatar, avatarURL, customMXID, accessToken, nextBatch sql.NullString
var quality, lastSync sql.NullInt64
var enablePresence, enableReceipts, nameSet, avatarSet, contactInfoSet sql.NullBool
var username string
err := row.Scan(&username, &avatar, &avatarURL, &displayname, &quality, &nameSet, &avatarSet, &contactInfoSet, &lastSync, &customMXID, &accessToken, &nextBatch, &enablePresence, &enableReceipts)
if err != nil {
return nil, err
}
puppet.JID = types.NewJID(username, types.DefaultUserServer)
puppet.Displayname = displayname.String
puppet.Avatar = avatar.String
puppet.AvatarURL, _ = id.ParseContentURI(avatarURL.String)
puppet.NameQuality = int8(quality.Int64)
puppet.NameSet = nameSet.Bool
puppet.AvatarSet = avatarSet.Bool
puppet.ContactInfoSet = contactInfoSet.Bool
if lastSync.Int64 > 0 {
puppet.LastSync = time.Unix(lastSync.Int64, 0)
}
puppet.CustomMXID = id.UserID(customMXID.String)
puppet.AccessToken = accessToken.String
puppet.NextBatch = nextBatch.String
puppet.EnablePresence = enablePresence.Bool
puppet.EnableReceipts = enableReceipts.Bool
return puppet, nil
}
func (puppet *Puppet) sqlVariables() []any {
var lastSyncTS int64
if !puppet.LastSync.IsZero() {
lastSyncTS = puppet.LastSync.Unix()
}
return []any{
puppet.JID.User, puppet.Avatar, puppet.AvatarURL.String(), puppet.AvatarSet, puppet.Displayname,
puppet.NameQuality, puppet.NameSet, puppet.ContactInfoSet, lastSyncTS,
puppet.CustomMXID, puppet.AccessToken, puppet.NextBatch,
puppet.EnablePresence, puppet.EnableReceipts,
}
}
func (puppet *Puppet) Insert(ctx context.Context) error {
if puppet.JID.Server != types.DefaultUserServer {
zerolog.Ctx(ctx).Warn().Stringer("jid", puppet.JID).Msg("Not inserting puppet: not a user")
return nil
}
return puppet.qh.Exec(ctx, insertPuppetQuery, puppet.sqlVariables()...)
}
func (puppet *Puppet) Update(ctx context.Context) error {
return puppet.qh.Exec(ctx, updatePuppetQuery, puppet.sqlVariables()...)
}

View file

@ -1,89 +0,0 @@
// mautrix-whatsapp - A Matrix-WhatsApp 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 <https://www.gnu.org/licenses/>.
package database
import (
"context"
"go.mau.fi/whatsmeow/types"
"maunium.net/go/mautrix/id"
"go.mau.fi/util/dbutil"
)
type ReactionQuery struct {
*dbutil.QueryHelper[*Reaction]
}
func newReaction(qh *dbutil.QueryHelper[*Reaction]) *Reaction {
return &Reaction{qh: qh}
}
const (
getReactionByTargetJIDQuery = `
SELECT chat_jid, chat_receiver, target_jid, sender, mxid, jid FROM reaction
WHERE chat_jid=$1 AND chat_receiver=$2 AND target_jid=$3 AND sender=$4
`
getReactionByMXIDQuery = `
SELECT chat_jid, chat_receiver, target_jid, sender, mxid, jid FROM reaction
WHERE mxid=$1
`
upsertReactionQuery = `
INSERT INTO reaction (chat_jid, chat_receiver, target_jid, sender, mxid, jid)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (chat_jid, chat_receiver, target_jid, sender)
DO UPDATE SET mxid=excluded.mxid, jid=excluded.jid
`
deleteReactionQuery = `
DELETE FROM reaction WHERE chat_jid=$1 AND chat_receiver=$2 AND target_jid=$3 AND sender=$4
`
)
func (rq *ReactionQuery) GetByTargetJID(ctx context.Context, chat PortalKey, jid types.MessageID, sender types.JID) (*Reaction, error) {
return rq.QueryOne(ctx, getReactionByTargetJIDQuery, chat.JID, chat.Receiver, jid, sender.ToNonAD())
}
func (rq *ReactionQuery) GetByMXID(ctx context.Context, mxid id.EventID) (*Reaction, error) {
return rq.QueryOne(ctx, getReactionByMXIDQuery, mxid)
}
type Reaction struct {
qh *dbutil.QueryHelper[*Reaction]
Chat PortalKey
TargetJID types.MessageID
Sender types.JID
MXID id.EventID
JID types.MessageID
}
func (reaction *Reaction) Scan(row dbutil.Scannable) (*Reaction, error) {
return dbutil.ValueOrErr(reaction, row.Scan(&reaction.Chat.JID, &reaction.Chat.Receiver, &reaction.TargetJID, &reaction.Sender, &reaction.MXID, &reaction.JID))
}
func (reaction *Reaction) sqlVariables() []any {
reaction.Sender = reaction.Sender.ToNonAD()
return []any{reaction.Chat.JID, reaction.Chat.Receiver, reaction.TargetJID, reaction.Sender, reaction.MXID, reaction.JID}
}
func (reaction *Reaction) Upsert(ctx context.Context) error {
return reaction.qh.Exec(ctx, upsertReactionQuery, reaction.sqlVariables()...)
}
func (reaction *Reaction) Delete(ctx context.Context) error {
return reaction.qh.Exec(ctx, deleteReactionQuery, reaction.Chat.JID, reaction.Chat.Receiver, reaction.TargetJID, reaction.Sender)
}

View file

@ -1,208 +0,0 @@
-- v0 -> v57 (compatible with v45+): Latest revision
CREATE TABLE "user" (
mxid TEXT PRIMARY KEY,
username TEXT UNIQUE,
agent SMALLINT,
device SMALLINT,
management_room TEXT,
space_room TEXT,
phone_last_seen BIGINT,
phone_last_pinged BIGINT,
timezone TEXT
);
CREATE TABLE portal (
jid TEXT,
receiver TEXT,
mxid TEXT UNIQUE,
name TEXT NOT NULL,
name_set BOOLEAN NOT NULL DEFAULT false,
topic TEXT NOT NULL,
topic_set BOOLEAN NOT NULL DEFAULT false,
avatar TEXT NOT NULL,
avatar_url TEXT,
avatar_set BOOLEAN NOT NULL DEFAULT false,
encrypted BOOLEAN NOT NULL DEFAULT false,
last_sync BIGINT NOT NULL DEFAULT 0,
is_parent BOOLEAN NOT NULL DEFAULT false,
parent_group TEXT,
in_space BOOLEAN NOT NULL DEFAULT false,
first_event_id TEXT,
next_batch_id TEXT,
relay_user_id TEXT,
expiration_time BIGINT NOT NULL DEFAULT 0 CHECK (expiration_time >= 0 AND expiration_time < 4294967296),
PRIMARY KEY (jid, receiver)
);
CREATE INDEX portal_parent_group_idx ON portal(parent_group);
CREATE TABLE puppet (
username TEXT PRIMARY KEY,
displayname TEXT,
name_quality SMALLINT,
avatar TEXT,
avatar_url TEXT,
name_set BOOLEAN NOT NULL DEFAULT false,
avatar_set BOOLEAN NOT NULL DEFAULT false,
contact_info_set BOOLEAN NOT NULL DEFAULT false,
last_sync BIGINT NOT NULL DEFAULT 0,
custom_mxid TEXT,
access_token TEXT,
next_batch TEXT,
enable_presence BOOLEAN NOT NULL DEFAULT true,
enable_receipts BOOLEAN NOT NULL DEFAULT true
);
-- only: postgres
CREATE TYPE error_type AS ENUM ('', 'decryption_failed', 'media_not_found');
CREATE TABLE message (
chat_jid TEXT,
chat_receiver TEXT,
jid TEXT,
mxid TEXT UNIQUE,
sender TEXT,
sender_mxid TEXT NOT NULL DEFAULT '',
timestamp BIGINT,
sent BOOLEAN,
error error_type,
type TEXT,
broadcast_list_jid TEXT,
PRIMARY KEY (chat_jid, chat_receiver, jid),
FOREIGN KEY (chat_jid, chat_receiver) REFERENCES portal(jid, receiver) ON DELETE CASCADE
);
CREATE INDEX message_timestamp_idx ON message (chat_jid, chat_receiver, timestamp);
CREATE TABLE poll_option_id (
msg_mxid TEXT,
opt_id TEXT,
opt_hash bytea CHECK ( length(opt_hash) = 32 ),
PRIMARY KEY (msg_mxid, opt_id),
CONSTRAINT poll_option_unique_hash UNIQUE (msg_mxid, opt_hash),
CONSTRAINT message_mxid_fkey FOREIGN KEY (msg_mxid) REFERENCES message(mxid) ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE TABLE reaction (
chat_jid TEXT,
chat_receiver TEXT,
target_jid TEXT,
sender TEXT,
mxid TEXT NOT NULL,
jid TEXT NOT NULL,
PRIMARY KEY (chat_jid, chat_receiver, target_jid, sender),
FOREIGN KEY (chat_jid, chat_receiver, target_jid) REFERENCES message(chat_jid, chat_receiver, jid)
ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE TABLE disappearing_message (
room_id TEXT,
event_id TEXT,
expire_in BIGINT NOT NULL,
expire_at BIGINT,
PRIMARY KEY (room_id, event_id)
);
CREATE TABLE user_portal (
user_mxid TEXT,
portal_jid TEXT,
portal_receiver TEXT,
last_read_ts BIGINT NOT NULL DEFAULT 0,
in_space BOOLEAN NOT NULL DEFAULT false,
PRIMARY KEY (user_mxid, portal_jid, portal_receiver),
FOREIGN KEY (user_mxid) REFERENCES "user"(mxid) ON UPDATE CASCADE ON DELETE CASCADE,
FOREIGN KEY (portal_jid, portal_receiver) REFERENCES portal(jid, receiver) ON UPDATE CASCADE ON DELETE CASCADE
);
CREATE TABLE backfill_queue (
queue_id INTEGER PRIMARY KEY
-- only: postgres
GENERATED ALWAYS AS IDENTITY
,
user_mxid TEXT,
type INTEGER NOT NULL,
priority INTEGER NOT NULL,
portal_jid TEXT,
portal_receiver TEXT,
time_start TIMESTAMP,
dispatch_time TIMESTAMP,
completed_at TIMESTAMP,
batch_delay INTEGER,
max_batch_events INTEGER NOT NULL,
max_total_events INTEGER,
FOREIGN KEY (user_mxid) REFERENCES "user" (mxid) ON DELETE CASCADE ON UPDATE CASCADE,
FOREIGN KEY (portal_jid, portal_receiver) REFERENCES portal(jid, receiver) ON DELETE CASCADE
);
CREATE TABLE backfill_state (
user_mxid TEXT,
portal_jid TEXT,
portal_receiver TEXT,
processing_batch BOOLEAN,
backfill_complete BOOLEAN,
first_expected_ts BIGINT,
PRIMARY KEY (user_mxid, portal_jid, portal_receiver),
FOREIGN KEY (user_mxid) REFERENCES "user" (mxid) ON DELETE CASCADE ON UPDATE CASCADE,
FOREIGN KEY (portal_jid, portal_receiver) REFERENCES portal (jid, receiver) ON DELETE CASCADE
);
CREATE TABLE media_backfill_requests (
user_mxid TEXT,
portal_jid TEXT,
portal_receiver TEXT,
event_id TEXT,
media_key bytea,
status INTEGER,
error TEXT,
PRIMARY KEY (user_mxid, portal_jid, portal_receiver, event_id),
FOREIGN KEY (user_mxid) REFERENCES "user"(mxid) ON UPDATE CASCADE ON DELETE CASCADE,
FOREIGN KEY (portal_jid, portal_receiver) REFERENCES portal(jid, receiver) ON UPDATE CASCADE ON DELETE CASCADE
);
CREATE TABLE history_sync_conversation (
user_mxid TEXT,
conversation_id TEXT,
portal_jid TEXT,
portal_receiver TEXT,
last_message_timestamp TIMESTAMP,
archived BOOLEAN,
pinned INTEGER,
mute_end_time TIMESTAMP,
disappearing_mode INTEGER,
end_of_history_transfer_type INTEGER,
ephemeral_Expiration INTEGER,
marked_as_unread BOOLEAN,
unread_count INTEGER,
PRIMARY KEY (user_mxid, conversation_id),
FOREIGN KEY (user_mxid) REFERENCES "user"(mxid) ON UPDATE CASCADE ON DELETE CASCADE,
FOREIGN KEY (portal_jid, portal_receiver) REFERENCES portal(jid, receiver) ON UPDATE CASCADE ON DELETE CASCADE
);
CREATE TABLE history_sync_message (
user_mxid TEXT,
conversation_id TEXT,
message_id TEXT,
timestamp TIMESTAMP,
data bytea,
inserted_time TIMESTAMP,
PRIMARY KEY (user_mxid, conversation_id, message_id),
FOREIGN KEY (user_mxid) REFERENCES "user"(mxid) ON UPDATE CASCADE ON DELETE CASCADE,
FOREIGN KEY (user_mxid, conversation_id) REFERENCES history_sync_conversation(user_mxid, conversation_id) ON DELETE CASCADE
);

View file

@ -1,3 +0,0 @@
-- v36: Store approximate last seen timestamp of the main device
ALTER TABLE "user" ADD COLUMN phone_last_seen BIGINT;

View file

@ -1,11 +0,0 @@
-- v37: Store message error type as string
-- only: postgres
CREATE TYPE error_type AS ENUM ('', 'decryption_failed', 'media_not_found');
ALTER TABLE message ADD COLUMN error error_type NOT NULL DEFAULT '';
UPDATE message SET error='decryption_failed' WHERE decryption_error=true;
-- TODO do this on sqlite at some point
-- only: postgres
ALTER TABLE message DROP COLUMN decryption_error;

View file

@ -1,3 +0,0 @@
-- v38: Store timestamp for previous phone ping
ALTER TABLE "user" ADD COLUMN phone_last_pinged BIGINT;

View file

@ -1,21 +0,0 @@
-- v39: Add support for reactions
ALTER TABLE message ADD COLUMN type TEXT NOT NULL DEFAULT 'message';
-- only: postgres
ALTER TABLE message ALTER COLUMN type DROP DEFAULT;
UPDATE message SET type='' WHERE error='decryption_failed';
UPDATE message SET type='fake' WHERE jid LIKE 'FAKE::%' OR mxid LIKE 'net.maunium.whatsapp.fake::%' OR jid=mxid;
CREATE TABLE reaction (
chat_jid TEXT,
chat_receiver TEXT,
target_jid TEXT,
sender TEXT,
mxid TEXT NOT NULL,
jid TEXT NOT NULL,
PRIMARY KEY (chat_jid, chat_receiver, target_jid, sender),
CONSTRAINT target_message_fkey FOREIGN KEY (chat_jid, chat_receiver, target_jid)
REFERENCES message (chat_jid, chat_receiver, jid)
ON DELETE CASCADE ON UPDATE CASCADE
);

View file

@ -1,22 +0,0 @@
-- v40: Add backfill queue
CREATE TABLE backfill_queue (
queue_id INTEGER PRIMARY KEY
-- only: postgres
GENERATED ALWAYS AS IDENTITY
,
user_mxid TEXT,
type INTEGER NOT NULL,
priority INTEGER NOT NULL,
portal_jid TEXT,
portal_receiver TEXT,
time_start TIMESTAMP,
time_end TIMESTAMP,
completed_at TIMESTAMP,
batch_delay INTEGER,
max_batch_events INTEGER NOT NULL,
max_total_events INTEGER,
FOREIGN KEY (user_mxid) REFERENCES "user" (mxid) ON DELETE CASCADE ON UPDATE CASCADE,
FOREIGN KEY (portal_jid, portal_receiver) REFERENCES portal(jid, receiver) ON DELETE CASCADE
);

View file

@ -1,31 +0,0 @@
-- v41: Store history syncs for later backfills
CREATE TABLE history_sync_conversation (
user_mxid TEXT,
conversation_id TEXT,
portal_jid TEXT,
portal_receiver TEXT,
last_message_timestamp TIMESTAMP,
archived BOOLEAN,
pinned INTEGER,
mute_end_time TIMESTAMP,
disappearing_mode INTEGER,
end_of_history_transfer_type INTEGER,
ephemeral_expiration INTEGER,
marked_as_unread BOOLEAN,
unread_count INTEGER,
PRIMARY KEY (user_mxid, conversation_id),
FOREIGN KEY (user_mxid) REFERENCES "user" (mxid) ON DELETE CASCADE ON UPDATE CASCADE,
FOREIGN KEY (portal_jid, portal_receiver) REFERENCES portal (jid, receiver) ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE TABLE history_sync_message (
user_mxid TEXT,
conversation_id TEXT,
message_id TEXT,
timestamp TIMESTAMP,
data BYTEA,
PRIMARY KEY (user_mxid, conversation_id, message_id),
FOREIGN KEY (user_mxid) REFERENCES "user" (mxid) ON DELETE CASCADE ON UPDATE CASCADE,
FOREIGN KEY (user_mxid, conversation_id) REFERENCES history_sync_conversation (user_mxid, conversation_id) ON DELETE CASCADE
);

View file

@ -1,9 +0,0 @@
-- v42: Update backfill queue tables to be sortable by priority
UPDATE backfill_queue
SET type=CASE
WHEN type = 1 THEN 200
WHEN type = 2 THEN 300
ELSE type
END
WHERE type = 1 OR type = 2;

View file

@ -1,14 +0,0 @@
-- v43: Add table for tracking which media needs to be requested from the user's phone
CREATE TABLE media_backfill_requests (
user_mxid TEXT,
portal_jid TEXT,
portal_receiver TEXT,
event_id TEXT,
media_key BYTEA,
status INTEGER,
error TEXT,
PRIMARY KEY (user_mxid, portal_jid, portal_receiver, event_id),
FOREIGN KEY (user_mxid) REFERENCES "user" (mxid) ON DELETE CASCADE ON UPDATE CASCADE,
FOREIGN KEY (portal_jid, portal_receiver) REFERENCES portal (jid, receiver) ON DELETE CASCADE
);

View file

@ -1,3 +0,0 @@
-- v44: Add timezone column for users
ALTER TABLE "user" ADD COLUMN timezone TEXT;

View file

@ -1,5 +0,0 @@
-- v45: Add dispatch time to backfill queue
ALTER TABLE backfill_queue ADD COLUMN dispatch_time TIMESTAMP;
UPDATE backfill_queue SET dispatch_time=completed_at;
ALTER TABLE backfill_queue DROP COLUMN time_end;

View file

@ -1,3 +0,0 @@
-- v46: Add inserted time to history sync message
ALTER TABLE history_sync_message ADD COLUMN inserted_time TIMESTAMP;

View file

@ -1,13 +0,0 @@
-- v47: Add table for keeping track of backfill state
CREATE TABLE backfill_state (
user_mxid TEXT,
portal_jid TEXT,
portal_receiver TEXT,
processing_batch BOOLEAN,
backfill_complete BOOLEAN,
first_expected_ts INTEGER,
PRIMARY KEY (user_mxid, portal_jid, portal_receiver),
FOREIGN KEY (user_mxid) REFERENCES "user" (mxid) ON DELETE CASCADE ON UPDATE CASCADE,
FOREIGN KEY (portal_jid, portal_receiver) REFERENCES portal (jid, receiver) ON DELETE CASCADE
);

View file

@ -1,7 +0,0 @@
-- v48: Move crypto/state/whatsmeow store upgrade handling to separate systems
CREATE TABLE crypto_version (version INTEGER PRIMARY KEY);
INSERT INTO crypto_version VALUES (6);
CREATE TABLE whatsmeow_version (version INTEGER PRIMARY KEY);
INSERT INTO whatsmeow_version VALUES (1);
CREATE TABLE mx_version (version INTEGER PRIMARY KEY);
INSERT INTO mx_version VALUES (1);

View file

@ -1,13 +0,0 @@
-- v49: Convert first_expected_ts to BIGINT
-- only: postgres
DO
$do$
BEGIN
IF (SELECT data_type FROM information_schema.columns WHERE table_name='backfill_state' AND column_name='first_expected_ts') = 'integer' THEN
ALTER TABLE backfill_state ALTER COLUMN first_expected_ts TYPE BIGINT;
ELSE
ALTER TABLE backfill_state ALTER COLUMN first_expected_ts TYPE BIGINT USING EXTRACT(EPOCH FROM first_expected_ts);
END IF;
END
$do$

View file

@ -1,13 +0,0 @@
-- v50: Add last sync timestamp for puppets
ALTER TABLE puppet ADD COLUMN last_sync BIGINT NOT NULL DEFAULT 0;
ALTER TABLE puppet ADD COLUMN name_set BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE puppet ADD COLUMN avatar_set BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE portal ADD COLUMN name_set BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE portal ADD COLUMN avatar_set BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE portal ADD COLUMN topic_set BOOLEAN NOT NULL DEFAULT false;
UPDATE puppet SET name_set=true WHERE displayname<>'';
UPDATE puppet SET avatar_set=true WHERE avatar<>'';
UPDATE portal SET name_set=true WHERE name<>'';
UPDATE portal SET avatar_set=true WHERE avatar<>'';
UPDATE portal SET topic_set=true WHERE topic<>'';

View file

@ -1,3 +0,0 @@
-- v51: Add last sync timestamp for portals too
ALTER TABLE portal ADD COLUMN last_sync BIGINT NOT NULL DEFAULT 0;

View file

@ -1,5 +0,0 @@
-- v52: Store portal metadata for communities
ALTER TABLE portal ADD COLUMN is_parent BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE portal ADD COLUMN parent_group TEXT;
ALTER TABLE portal ADD COLUMN in_space BOOLEAN NOT NULL DEFAULT false;

View file

@ -1,2 +0,0 @@
-- v53: Add index to make querying by community faster
CREATE INDEX portal_parent_group_idx ON portal(parent_group);

View file

@ -1,11 +0,0 @@
-- v54: Store mapping for poll option IDs from Matrix
CREATE TABLE poll_option_id (
msg_mxid TEXT,
opt_id TEXT,
opt_hash bytea CHECK ( length(opt_hash) = 32 ),
PRIMARY KEY (msg_mxid, opt_id),
CONSTRAINT poll_option_unique_hash UNIQUE (msg_mxid, opt_hash),
CONSTRAINT message_mxid_fkey FOREIGN KEY (msg_mxid) REFERENCES message(mxid) ON DELETE CASCADE ON UPDATE CASCADE
);

View file

@ -1,3 +0,0 @@
-- v55: Store whether custom contact info has been set for a puppet
ALTER TABLE puppet ADD COLUMN contact_info_set BOOLEAN NOT NULL DEFAULT false;

View file

@ -1,2 +0,0 @@
-- v56 (compatible with v45+): Store whether custom contact info has been set for a puppet
ALTER TABLE message ADD COLUMN sender_mxid TEXT NOT NULL DEFAULT '';

View file

@ -1,2 +0,0 @@
-- v57 (compatible with v45+): Add index for message timestamp to make read receipt handling faster
CREATE INDEX message_timestamp_idx ON message (chat_jid, chat_receiver, timestamp);

View file

@ -1,37 +0,0 @@
// mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge.
// Copyright (C) 2022 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 <https://www.gnu.org/licenses/>.
package upgrades
import (
"context"
"embed"
"errors"
"go.mau.fi/util/dbutil"
)
var Table dbutil.UpgradeTable
//go:embed *.sql
var rawUpgrades embed.FS
func init() {
Table.Register(-1, 35, 0, "Unsupported version", dbutil.TxnModeOff, func(ctx context.Context, database *dbutil.Database) error {
return errors.New("please upgrade to mautrix-whatsapp v0.4.0 before upgrading to a newer version")
})
Table.RegisterFS(rawUpgrades)
}

View file

@ -1,146 +0,0 @@
// mautrix-whatsapp - A Matrix-WhatsApp 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 <https://www.gnu.org/licenses/>.
package database
import (
"context"
"database/sql"
"sync"
"time"
"go.mau.fi/whatsmeow/types"
"maunium.net/go/mautrix/id"
"go.mau.fi/util/dbutil"
)
type UserQuery struct {
*dbutil.QueryHelper[*User]
}
func newUser(qh *dbutil.QueryHelper[*User]) *User {
return &User{
qh: qh,
lastReadCache: make(map[PortalKey]time.Time),
inSpaceCache: make(map[PortalKey]bool),
}
}
const (
getAllUsersQuery = `SELECT mxid, username, agent, device, management_room, space_room, phone_last_seen, phone_last_pinged, timezone FROM "user"`
getUserByMXIDQuery = getAllUsersQuery + ` WHERE mxid=$1`
getUserByUsernameQuery = getAllUsersQuery + ` WHERE username=$1`
insertUserQuery = `
INSERT INTO "user" (
mxid, username, agent, device,
management_room, space_room,
phone_last_seen, phone_last_pinged, timezone
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
`
updateUserQuery = `
UPDATE "user"
SET username=$2, agent=$3, device=$4,
management_room=$5, space_room=$6,
phone_last_seen=$7, phone_last_pinged=$8, timezone=$9
WHERE mxid=$1
`
getUserLastAppStateKeyIDQuery = "SELECT key_id FROM whatsmeow_app_state_sync_keys WHERE jid=$1 ORDER BY timestamp DESC LIMIT 1"
)
func (uq *UserQuery) GetAll(ctx context.Context) ([]*User, error) {
return uq.QueryMany(ctx, getAllUsersQuery)
}
func (uq *UserQuery) GetByMXID(ctx context.Context, userID id.UserID) (*User, error) {
return uq.QueryOne(ctx, getUserByMXIDQuery, userID)
}
func (uq *UserQuery) GetByUsername(ctx context.Context, username string) (*User, error) {
return uq.QueryOne(ctx, getUserByUsernameQuery, username)
}
type User struct {
qh *dbutil.QueryHelper[*User]
MXID id.UserID
JID types.JID
ManagementRoom id.RoomID
SpaceRoom id.RoomID
PhoneLastSeen time.Time
PhoneLastPinged time.Time
Timezone string
lastReadCache map[PortalKey]time.Time
lastReadCacheLock sync.Mutex
inSpaceCache map[PortalKey]bool
inSpaceCacheLock sync.Mutex
}
func (user *User) Scan(row dbutil.Scannable) (*User, error) {
var username, timezone sql.NullString
var device, agent sql.NullInt16
var phoneLastSeen, phoneLastPinged sql.NullInt64
err := row.Scan(&user.MXID, &username, &agent, &device, &user.ManagementRoom, &user.SpaceRoom, &phoneLastSeen, &phoneLastPinged, &timezone)
if err != nil {
return nil, err
}
user.Timezone = timezone.String
if len(username.String) > 0 {
user.JID = types.JID{
User: username.String,
Device: uint16(device.Int16),
Server: types.DefaultUserServer,
}
}
if phoneLastSeen.Valid {
user.PhoneLastSeen = time.Unix(phoneLastSeen.Int64, 0)
}
if phoneLastPinged.Valid {
user.PhoneLastPinged = time.Unix(phoneLastPinged.Int64, 0)
}
return user, nil
}
func (user *User) sqlVariables() []any {
var username *string
var agent, device *uint16
if !user.JID.IsEmpty() {
username = dbutil.StrPtr(user.JID.User)
var zero uint16
agent = &zero
device = dbutil.NumPtr(user.JID.Device)
}
return []any{
user.MXID, username, agent, device, user.ManagementRoom, user.SpaceRoom,
dbutil.UnixPtr(user.PhoneLastSeen), dbutil.UnixPtr(user.PhoneLastPinged),
user.Timezone,
}
}
func (user *User) Insert(ctx context.Context) error {
return user.qh.Exec(ctx, insertUserQuery, user.sqlVariables()...)
}
func (user *User) Update(ctx context.Context) error {
return user.qh.Exec(ctx, updateUserQuery, user.sqlVariables()...)
}
func (user *User) GetLastAppStateKeyID(ctx context.Context) (keyID []byte, err error) {
err = user.qh.GetDB().QueryRow(ctx, getUserLastAppStateKeyIDQuery, user.JID).Scan(&keyID)
return
}

View file

@ -1,114 +0,0 @@
// mautrix-whatsapp - A Matrix-WhatsApp 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 <https://www.gnu.org/licenses/>.
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_jid=$2 AND portal_receiver=$3"
setLastReadTSQuery = `
INSERT INTO user_portal (user_mxid, portal_jid, portal_receiver, last_read_ts) VALUES ($1, $2, $3, $4)
ON CONFLICT (user_mxid, portal_jid, portal_receiver) DO UPDATE SET last_read_ts=excluded.last_read_ts WHERE user_portal.last_read_ts<excluded.last_read_ts
`
getIsInSpaceQuery = "SELECT in_space FROM user_portal WHERE user_mxid=$1 AND portal_jid=$2 AND portal_receiver=$3"
setIsInSpaceQuery = `
INSERT INTO user_portal (user_mxid, portal_jid, portal_receiver, in_space) VALUES ($1, $2, $3, true)
ON CONFLICT (user_mxid, portal_jid, portal_receiver) DO UPDATE SET in_space=true
`
)
func (user *User) GetLastReadTS(ctx context.Context, portal PortalKey) time.Time {
user.lastReadCacheLock.Lock()
defer user.lastReadCacheLock.Unlock()
if cached, ok := user.lastReadCache[portal]; ok {
return cached
}
var ts int64
var parsedTS time.Time
err := user.qh.GetDB().QueryRow(ctx, getLastReadTSQuery, user.MXID, portal.JID, portal.Receiver).Scan(&ts)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
zerolog.Ctx(ctx).Err(err).
Str("user_id", user.MXID.String()).
Any("portal_key", portal).
Msg("Failed to query last read timestamp")
return parsedTS
}
if ts != 0 {
parsedTS = time.Unix(ts, 0)
}
user.lastReadCache[portal] = parsedTS
return user.lastReadCache[portal]
}
func (user *User) SetLastReadTS(ctx context.Context, portal PortalKey, ts time.Time) {
user.lastReadCacheLock.Lock()
defer user.lastReadCacheLock.Unlock()
_, err := user.qh.GetDB().Exec(ctx, setLastReadTSQuery, user.MXID, portal.JID, portal.Receiver, ts.Unix())
if err != nil {
zerolog.Ctx(ctx).Err(err).
Str("user_id", user.MXID.String()).
Any("portal_key", portal).
Msg("Failed to update last read timestamp")
} else {
zerolog.Ctx(ctx).Debug().
Str("user_id", user.MXID.String()).
Any("portal_key", portal).
Time("last_read_ts", ts).
Msg("Updated last read timestamp of portal")
user.lastReadCache[portal] = ts
}
}
func (user *User) IsInSpace(ctx context.Context, portal PortalKey) bool {
user.inSpaceCacheLock.Lock()
defer user.inSpaceCacheLock.Unlock()
if cached, ok := user.inSpaceCache[portal]; ok {
return cached
}
var inSpace bool
err := user.qh.GetDB().QueryRow(ctx, getIsInSpaceQuery, user.MXID, portal.JID, portal.Receiver).Scan(&inSpace)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
zerolog.Ctx(ctx).Err(err).
Str("user_id", user.MXID.String()).
Any("portal_key", portal).
Msg("Failed to query in space status")
return false
}
user.inSpaceCache[portal] = inSpace
return inSpace
}
func (user *User) MarkInSpace(ctx context.Context, portal PortalKey) {
user.inSpaceCacheLock.Lock()
defer user.inSpaceCacheLock.Unlock()
_, err := user.qh.GetDB().Exec(ctx, setIsInSpaceQuery, user.MXID, portal.JID, portal.Receiver)
if err != nil {
zerolog.Ctx(ctx).Err(err).
Str("user_id", user.MXID.String()).
Any("portal_key", portal).
Msg("Failed to update in space status")
} else {
user.inSpaceCache[portal] = true
}
}

View file

@ -1,102 +0,0 @@
// mautrix-whatsapp - A Matrix-WhatsApp 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 <https://www.gnu.org/licenses/>.
package main
import (
"context"
"fmt"
"time"
"github.com/rs/zerolog"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/id"
"maunium.net/go/mautrix-whatsapp/database"
)
func (portal *Portal) MarkDisappearing(ctx context.Context, eventID id.EventID, expiresIn time.Duration, startsAt time.Time) {
if expiresIn == 0 {
return
}
expiresAt := startsAt.Add(expiresIn)
msg := portal.bridge.DB.DisappearingMessage.NewWithValues(portal.MXID, eventID, expiresIn, expiresAt)
err := msg.Insert(ctx)
if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to insert disappearing message")
}
if expiresAt.Before(time.Now().Add(1 * time.Hour)) {
go portal.sleepAndDelete(context.WithoutCancel(ctx), msg)
}
}
func (br *WABridge) SleepAndDeleteUpcoming(ctx context.Context) {
msgs, err := br.DB.DisappearingMessage.GetUpcomingScheduled(ctx, 1*time.Hour)
if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to get upcoming disappearing messages")
return
}
for _, msg := range msgs {
portal := br.GetPortalByMXID(msg.RoomID)
if portal == nil {
err = msg.Delete(ctx)
if err != nil {
zerolog.Ctx(ctx).Err(err).
Stringer("event_id", msg.EventID).
Msg("Failed to delete disappearing message row with no portal")
}
} else {
go portal.sleepAndDelete(ctx, msg)
}
}
}
func (portal *Portal) sleepAndDelete(ctx context.Context, msg *database.DisappearingMessage) {
if _, alreadySleeping := portal.currentlySleepingToDelete.LoadOrStore(msg.EventID, true); alreadySleeping {
return
}
defer portal.currentlySleepingToDelete.Delete(msg.EventID)
log := zerolog.Ctx(ctx)
sleepTime := msg.ExpireAt.Sub(time.Now())
log.Debug().
Stringer("room_id", portal.MXID).
Stringer("event_id", msg.EventID).
Dur("sleep_time", sleepTime).
Msg("Sleeping before making message disappear")
time.Sleep(sleepTime)
_, err := portal.MainIntent().RedactEvent(ctx, msg.RoomID, msg.EventID, mautrix.ReqRedact{
Reason: "Message expired",
TxnID: fmt.Sprintf("mxwa_disappear_%s", msg.EventID),
})
if err != nil {
log.Err(err).
Stringer("room_id", portal.MXID).
Stringer("event_id", msg.EventID).
Msg("Failed to make event disappear")
} else {
log.Debug().
Stringer("room_id", portal.MXID).
Stringer("event_id", msg.EventID).
Msg("Disappeared event")
}
err = msg.Delete(ctx)
if err != nil {
log.Err(err).Msg("Failed to delete disapperaing message row in database after redacting event")
}
}

View file

@ -15,11 +15,7 @@ function fixperms {
}
if [[ ! -f /data/config.yaml ]]; then
if [[ "$BRIDGEV2" == "1" ]]; then
/usr/bin/mautrix-whatsapp -c /data/config.yaml -e
else
cp /opt/mautrix-whatsapp/example-config.yaml /data/config.yaml
fi
/usr/bin/mautrix-whatsapp -c /data/config.yaml -e
echo "Didn't find a config file."
echo "Copied default config file to /data/config.yaml"
echo "Modify that config file to your liking."

View file

@ -1,481 +0,0 @@
# Homeserver details.
homeserver:
# The address that this appservice can use to connect to the homeserver.
address: https://matrix.example.com
# The domain of the homeserver (also known as server_name, used for MXIDs, etc).
domain: example.com
# What software is the homeserver running?
# Standard Matrix homeservers like Synapse, Dendrite and Conduit should just use "standard" here.
software: standard
# The URL to push real-time bridge status to.
# If set, the bridge will make POST requests to this URL whenever a user's whatsapp connection state changes.
# The bridge will use the appservice as_token to authorize requests.
status_endpoint: null
# Endpoint for reporting per-message status.
message_send_checkpoint_endpoint: null
# Does the homeserver support https://github.com/matrix-org/matrix-spec-proposals/pull/2246?
async_media: false
# Should the bridge use a websocket for connecting to the homeserver?
# The server side is currently not documented anywhere and is only implemented by mautrix-wsproxy,
# mautrix-asmux (deprecated), and hungryserv (proprietary).
websocket: false
# How often should the websocket be pinged? Pinging will be disabled if this is zero.
ping_interval_seconds: 0
# Application service host/registration related details.
# Changing these values requires regeneration of the registration.
appservice:
# The address that the homeserver can use to connect to this appservice.
address: http://localhost:29318
# The hostname and port where this appservice should listen.
hostname: 0.0.0.0
port: 29318
# Database config.
database:
# The database type. "sqlite3-fk-wal" and "postgres" are supported.
type: postgres
# The database URI.
# SQLite: A raw file path is supported, but `file:<path>?_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: whatsapp
# Appservice bot details.
bot:
# Username of the appservice bot.
username: whatsappbot
# Display name and avatar for bot. Set to "remove" to remove display name/avatar, leave empty
# to leave display name/avatar as-is.
displayname: WhatsApp bridge bot
avatar: mxc://maunium.net/NeXNQarUbrlYBiPCpprYsRqr
# 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"
# Segment-compatible analytics endpoint for tracking some events, like provisioning API login and encryption errors.
analytics:
# Hostname of the tracking server. The path is hardcoded to /v1/track
host: api.segment.io
# API key to send with tracking requests. Tracking is disabled if this is null.
token: null
# Optional user ID for tracking events. If null, defaults to using Matrix user ID.
user_id: null
# Prometheus config.
metrics:
# Enable prometheus metrics?
enabled: false
# IP and port where the metrics listener should be. The path is always /metrics
listen: 127.0.0.1:8001
# Config for things that are directly sent to WhatsApp.
whatsapp:
# Device name that's shown in the "WhatsApp Web" section in the mobile app.
os_name: Mautrix-WhatsApp bridge
# Browser name that determines the logo shown in the mobile app.
# Must be "unknown" for a generic icon or a valid browser name if you want a specific icon.
# List of valid browser names: https://github.com/tulir/whatsmeow/blob/efc632c008604016ddde63bfcfca8de4e5304da9/binary/proto/def.proto#L43-L64
browser_name: unknown
# Proxy to use for all WhatsApp connections.
proxy: null
# Alternative to proxy: an HTTP endpoint that returns the proxy URL to use for WhatsApp connections.
get_proxy_url: null
# Whether the proxy options should only apply to the login websocket and not to authenticated connections.
proxy_only_login: false
# Bridge config
bridge:
# Localpart template of MXIDs for WhatsApp users.
# {{.}} is replaced with the phone number of the WhatsApp user.
username_template: whatsapp_{{.}}
# Displayname template for WhatsApp users.
# {{.PushName}} - nickname set by the WhatsApp user
# {{.BusinessName}} - validated WhatsApp business name
# {{.Phone}} - phone number (international format)
# The following variables are also available, but will cause problems on multi-user instances:
# {{.FullName}} - full name from contact list
# {{.FirstName}} - first name from contact list
displayname_template: "{{or .BusinessName .PushName .JID}} (WA)"
# 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 `!wa sync space` to create and fill the space for the first time.
personal_filtering_spaces: false
# Should the bridge send a read receipt from the bridge bot when a message has been sent to WhatsApp?
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 incoming calls send a message to the Matrix room?
call_start_notices: true
# Should another user's cryptographic identity changing send a message to Matrix?
identity_change_notices: false
portal_message_buffer: 128
# Settings for handling history sync payloads.
history_sync:
# Enable backfilling history sync payloads from WhatsApp?
backfill: true
# The maximum number of initial conversations that should be synced.
# Other conversations will be backfilled on demand when receiving a message or when initiating a direct chat.
max_initial_conversations: -1
# Maximum number of messages to backfill in each conversation.
# Set to -1 to disable limit.
message_count: 50
# Should the bridge request a full sync from the phone when logging in?
# This bumps the size of history syncs from 3 months to 1 year.
request_full_sync: false
# Configuration parameters that are sent to the phone along with the request full sync flag.
# By default (when the values are null or 0), the config isn't sent at all.
full_sync_config:
# Number of days of history to request.
# The limit seems to be around 3 years, but using higher values doesn't break.
days_limit: null
# This is presumably the maximum size of the transferred history sync blob, which may affect what the phone includes in the blob.
size_mb_limit: null
# This is presumably the local storage quota, which may affect what the phone includes in the history sync blob.
storage_quota_mb: null
# If this value is greater than 0, then if the conversation's last message was more than
# this number of hours ago, then the conversation will automatically be marked it as read.
# Conversations that have a last message that is less than this number of hours ago will
# have their unread status synced from WhatsApp.
unread_hours_threshold: 0
###############################################################################
# The settings below are only applicable for backfilling using batch sending, #
# which is no longer supported in Synapse. #
###############################################################################
# Settings for media requests. If the media expired, then it will not be on the WA servers.
# Media can always be requested by reacting with the ♻️ (recycle) emoji.
# These settings determine if the media requests should be done automatically during or after backfill.
media_requests:
# Should expired media be automatically requested from the server as part of the backfill process?
auto_request_media: true
# Whether to request the media immediately after the media message is backfilled ("immediate")
# or at a specific time of the day ("local_time").
request_method: immediate
# If request_method is "local_time", what time should the requests be sent (in minutes after midnight)?
request_local_time: 120
# Maximum number of media request responses to handle in parallel per user.
max_async_handle: 2
# Settings for immediate backfills. These backfills should generally be small and their main purpose is
# to populate each of the initial chats (as configured by max_initial_conversations) with a few messages
# so that you can continue conversations without losing context.
immediate:
# The number of concurrent backfill workers to create for immediate backfills.
# Note that using more than one worker could cause the room list to jump around
# since there are no guarantees about the order in which the backfills will complete.
worker_count: 1
# The maximum number of events to backfill initially.
max_events: 10
# Settings for deferred backfills. The purpose of these backfills are to fill in the rest of
# the chat history that was not covered by the immediate backfills.
# These backfills generally should happen at a slower pace so as not to overload the homeserver.
# Each deferred backfill config should define a "stage" of backfill (i.e. the last week of messages).
# The fields are as follows:
# - start_days_ago: the number of days ago to start backfilling from.
# To indicate the start of time, use -1. For example, for a week ago, use 7.
# - max_batch_events: the number of events to send per batch.
# - batch_delay: the number of seconds to wait before backfilling each batch.
deferred:
# Last Week
- start_days_ago: 7
max_batch_events: 20
batch_delay: 5
# Last Month
- start_days_ago: 30
max_batch_events: 50
batch_delay: 10
# Last 3 months
- start_days_ago: 90
max_batch_events: 100
batch_delay: 10
# The start of time
- start_days_ago: -1
max_batch_events: 500
batch_delay: 10
# Should puppet avatars be fetched from the server even if an avatar is already set?
user_avatar_sync: true
# Should Matrix users leaving groups be bridged to WhatsApp?
bridge_matrix_leave: true
# 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 (except with mautrix-asmux)
# and is therefore prone to race conditions.
sync_direct_chat_list: false
# Should the bridge use MSC2867 to bridge manual "mark as unread"s from
# WhatsApp and set the unread status on initial backfill?
# This will only work on clients that support the m.marked_unread or
# com.famedly.marked_unread room account data.
sync_manual_marked_unread: true
# When double puppeting is enabled, users can use `!wa toggle` to change whether
# presence is bridged. This setting sets the default value.
# Existing users won't be affected when these are changed.
default_bridge_presence: true
# Send the presence as "available" to whatsapp when users start typing on a portal.
# This works as a workaround for homeservers that do not support presence, and allows
# users to see when the whatsapp user on the other side is typing during a conversation.
send_presence_on_typing: false
# Should the bridge always send "active" delivery receipts (two gray ticks on WhatsApp)
# even if the user isn't marked as online (e.g. when presence bridging isn't enabled)?
#
# By default, the bridge acts like WhatsApp web, which only sends active delivery
# receipts when it's in the foreground.
force_active_delivery_receipts: false
# 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
# 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
# Should group members be synced in parallel? This makes member sync faster
parallel_member_sync: false
# Should Matrix m.notice-type messages be bridged?
bridge_notices: true
# 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
# When using double puppeting, should muted chats be muted in Matrix?
mute_bridging: false
# When using double puppeting, should archived chats be moved to a specific tag in Matrix?
# Note that WhatsApp unarchives chats when a message is received, which will also be mirrored to Matrix.
# This can be set to a tag (e.g. m.lowpriority), or null to disable.
archive_tag: null
# Same as above, but for pinned chats. The favorite tag is called m.favourite
pinned_tag: null
# Should mute status and tags only be bridged when the portal room is created?
tag_only_on_create: true
# Should WhatsApp status messages be bridged into a Matrix room?
# Disabling this won't affect already created status broadcast rooms.
enable_status_broadcast: true
# Should sending WhatsApp status messages be allowed?
# This can cause issues if the user has lots of contacts, so it's disabled by default.
disable_status_broadcast_send: true
# Should the status broadcast room be muted and moved into low priority by default?
# This is only applied when creating the room, the user can unmute it later.
mute_status_broadcast: true
# Tag to apply to the status broadcast room.
status_broadcast_tag: m.lowpriority
# Should the bridge use thumbnails from WhatsApp?
# They're disabled by default due to very low resolution.
whatsapp_thumbnail: false
# Allow invite permission for user. User can invite any bots to room with whatsapp
# users (private chat and groups)
allow_user_invite: false
# Whether or not created rooms should have federation enabled.
# If false, created portal rooms will never be federated.
federate_rooms: 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 stop if the WhatsApp server says another user connected with the same session?
# This is only safe on single-user bridges.
crash_on_stream_replaced: false
# Should the bridge detect URLs in outgoing messages, ask the homeserver to generate a preview,
# and send it to WhatsApp? URL previews can always be sent using the `com.beeper.linkpreviews`
# key in the event content even if this is disabled.
url_previews: false
# Send captions in the same message as images. This will send data compatible with both MSC2530 and MSC3552.
# This is currently not supported in most clients.
caption_in_message: false
# Send galleries as a single event? This is not an MSC (yet).
beeper_galleries: false
# Should polls be sent using MSC3381 event types?
extev_polls: false
# Should cross-chat replies from WhatsApp be bridged? Most servers and clients don't support this.
cross_room_replies: false
# Disable generating reply fallbacks? Some extremely bad clients still rely on them,
# but they're being phased out and will be completely removed in the future.
disable_reply_fallbacks: false
# Maximum time for handling Matrix events. Duration strings formatted for https://pkg.go.dev/time#ParseDuration
# Null means there's no enforced timeout.
message_handling_timeout:
# Send an error message after this timeout, but keep waiting for the response until the deadline.
# This is counted from the origin_server_ts, so the warning time is consistent regardless of the source of delay.
# If the message is older than this when it reaches the bridge, the message won't be handled at all.
error_after: null
# Drop messages after this timeout. They may still go through if the message got sent to the servers.
# This is counted from the time the bridge starts handling the message.
deadline: 120s
# The prefix for commands. Only required in non-management rooms.
command_prefix: "!wa"
# 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 a WhatsApp 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
# Should users mentions be in the event wire content to enable the server to send push notifications?
plaintext_mentions: 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 WhatsApp 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 WhatsApp 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 WhatsApp via the relaybot.
message_formats:
m.text: "<b>{{ .Sender.Displayname }}</b>: {{ .Message }}"
m.notice: "<b>{{ .Sender.Displayname }}</b>: {{ .Message }}"
m.emote: "* <b>{{ .Sender.Displayname }}</b> {{ .Message }}"
m.file: "<b>{{ .Sender.Displayname }}</b> sent a file"
m.image: "<b>{{ .Sender.Displayname }}</b> sent an image"
m.audio: "<b>{{ .Sender.Displayname }}</b> sent an audio file"
m.video: "<b>{{ .Sender.Displayname }}</b> sent a video"
m.location: "<b>{{ .Sender.Displayname }}</b> 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-whatsapp.log
max_size: 100
max_backups: 10
compress: true

View file

@ -1,208 +0,0 @@
// mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge.
// Copyright (C) 2023 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 <https://www.gnu.org/licenses/>.
package main
import (
"context"
"fmt"
"html"
"regexp"
"sort"
"strings"
"github.com/rs/zerolog"
"go.mau.fi/whatsmeow/types"
"golang.org/x/exp/slices"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/format"
"maunium.net/go/mautrix/id"
)
var italicRegex = regexp.MustCompile("([\\s>~*]|^)_(.+?)_([^a-zA-Z\\d]|$)")
var boldRegex = regexp.MustCompile("([\\s>_~]|^)\\*(.+?)\\*([^a-zA-Z\\d]|$)")
var strikethroughRegex = regexp.MustCompile("([\\s>_*]|^)~(.+?)~([^a-zA-Z\\d]|$)")
var codeBlockRegex = regexp.MustCompile("```(?:.|\n)+?```")
var inlineURLRegex = regexp.MustCompile(`\[(.+?)]\((.+?)\)`)
const mentionedJIDsContextKey = "fi.mau.whatsapp.mentioned_jids"
const allowedMentionsContextKey = "fi.mau.whatsapp.allowed_mentions"
type Formatter struct {
bridge *WABridge
matrixHTMLParser *format.HTMLParser
waReplString map[*regexp.Regexp]string
waReplFunc map[*regexp.Regexp]func(string) string
waReplFuncText map[*regexp.Regexp]func(string) string
}
func NewFormatter(bridge *WABridge) *Formatter {
formatter := &Formatter{
bridge: bridge,
matrixHTMLParser: &format.HTMLParser{
TabsToSpaces: 4,
Newline: "\n",
PillConverter: func(displayname, mxid, eventID string, ctx format.Context) string {
allowedMentions, _ := ctx.ReturnData[allowedMentionsContextKey].(map[types.JID]bool)
if mxid[0] == '@' {
var jid types.JID
if puppet := bridge.GetPuppetByMXID(id.UserID(mxid)); puppet != nil {
jid = puppet.JID
} else if user := bridge.GetUserByMXIDIfExists(id.UserID(mxid)); user != nil {
jid = user.JID.ToNonAD()
}
if !jid.IsEmpty() && (allowedMentions == nil || allowedMentions[jid]) {
if allowedMentions == nil {
jids, ok := ctx.ReturnData[mentionedJIDsContextKey].([]string)
if !ok {
ctx.ReturnData[mentionedJIDsContextKey] = []string{jid.String()}
} else {
ctx.ReturnData[mentionedJIDsContextKey] = append(jids, jid.String())
}
}
return "@" + jid.User
}
}
return displayname
},
BoldConverter: func(text string, _ format.Context) string { return fmt.Sprintf("*%s*", text) },
ItalicConverter: func(text string, _ format.Context) string { return fmt.Sprintf("_%s_", text) },
StrikethroughConverter: func(text string, _ format.Context) string { return fmt.Sprintf("~%s~", text) },
MonospaceConverter: func(text string, _ format.Context) string { return fmt.Sprintf("```%s```", text) },
MonospaceBlockConverter: func(text, language string, _ format.Context) string { return fmt.Sprintf("```%s```", text) },
},
waReplString: map[*regexp.Regexp]string{
italicRegex: "$1<em>$2</em>$3",
boldRegex: "$1<strong>$2</strong>$3",
strikethroughRegex: "$1<del>$2</del>$3",
},
}
formatter.waReplFunc = map[*regexp.Regexp]func(string) string{
codeBlockRegex: func(str string) string {
str = str[3 : len(str)-3]
if strings.ContainsRune(str, '\n') {
return fmt.Sprintf("<pre><code>%s</code></pre>", str)
}
return fmt.Sprintf("<code>%s</code>", str)
},
}
formatter.waReplFuncText = map[*regexp.Regexp]func(string) string{}
return formatter
}
func (formatter *Formatter) getMatrixInfoByJID(ctx context.Context, roomID id.RoomID, jid types.JID) (mxid id.UserID, displayname string) {
if puppet := formatter.bridge.GetPuppetByJID(jid); puppet != nil {
mxid = puppet.MXID
displayname = puppet.Displayname
}
if user := formatter.bridge.GetUserByJID(jid); user != nil {
mxid = user.MXID
member, err := formatter.bridge.StateStore.GetMember(ctx, roomID, user.MXID)
if err != nil {
zerolog.Ctx(ctx).Err(err).
Stringer("room_id", roomID).
Stringer("user_id", user.MXID).
Msg("Failed to get member profile from state store")
} else if len(member.Displayname) > 0 {
displayname = member.Displayname
}
}
return
}
func (formatter *Formatter) ParseWhatsApp(ctx context.Context, roomID id.RoomID, content *event.MessageEventContent, mentionedJIDs []string, allowInlineURL, forceHTML bool) {
output := html.EscapeString(content.Body)
for regex, replacement := range formatter.waReplString {
output = regex.ReplaceAllString(output, replacement)
}
for regex, replacer := range formatter.waReplFunc {
output = regex.ReplaceAllStringFunc(output, replacer)
}
if allowInlineURL {
output = inlineURLRegex.ReplaceAllStringFunc(output, func(s string) string {
groups := inlineURLRegex.FindStringSubmatch(s)
return fmt.Sprintf(`<a href="%s">%s</a>`, groups[2], groups[1])
})
}
alreadyMentioned := make(map[id.UserID]struct{})
content.Mentions = &event.Mentions{}
for _, rawJID := range mentionedJIDs {
jid, err := types.ParseJID(rawJID)
if err != nil {
continue
} else if jid.Server == types.LegacyUserServer {
jid.Server = types.DefaultUserServer
} else if jid.Server != types.DefaultUserServer {
// TODO lid support?
continue
}
mxid, displayname := formatter.getMatrixInfoByJID(ctx, roomID, jid)
number := "@" + jid.User
output = strings.ReplaceAll(output, number, fmt.Sprintf(`<a href="https://matrix.to/#/%s">%s</a>`, mxid, displayname))
content.Body = strings.ReplaceAll(content.Body, number, displayname)
if _, ok := alreadyMentioned[mxid]; !ok {
alreadyMentioned[mxid] = struct{}{}
content.Mentions.UserIDs = append(content.Mentions.UserIDs, mxid)
}
}
if output != content.Body || forceHTML {
output = strings.ReplaceAll(output, "\n", "<br/>")
content.FormattedBody = output
content.Format = event.FormatHTML
for regex, replacer := range formatter.waReplFuncText {
content.Body = regex.ReplaceAllStringFunc(content.Body, replacer)
}
}
}
func (formatter *Formatter) ParseMatrix(html string, mentions *event.Mentions) (string, []string) {
ctx := format.NewContext(context.TODO())
var mentionedJIDs []string
if mentions != nil {
var allowedMentions = make(map[types.JID]bool)
mentionedJIDs = make([]string, 0, len(mentions.UserIDs))
for _, userID := range mentions.UserIDs {
var jid types.JID
if puppet := formatter.bridge.GetPuppetByMXID(userID); puppet != nil {
jid = puppet.JID
mentionedJIDs = append(mentionedJIDs, puppet.JID.String())
} else if user := formatter.bridge.GetUserByMXIDIfExists(userID); user != nil {
jid = user.JID.ToNonAD()
}
if !jid.IsEmpty() && !allowedMentions[jid] {
allowedMentions[jid] = true
mentionedJIDs = append(mentionedJIDs, jid.String())
}
}
ctx.ReturnData[allowedMentionsContextKey] = allowedMentions
}
result := formatter.matrixHTMLParser.Parse(html, ctx)
if mentions == nil {
mentionedJIDs, _ = ctx.ReturnData[mentionedJIDsContextKey].([]string)
sort.Strings(mentionedJIDs)
mentionedJIDs = slices.Compact(mentionedJIDs)
}
return result, mentionedJIDs
}
func (formatter *Formatter) ParseMatrixWithoutMentions(html string) string {
ctx := format.NewContext(context.TODO())
ctx.ReturnData[allowedMentionsContextKey] = map[types.JID]struct{}{}
return formatter.matrixHTMLParser.Parse(html, ctx)
}

20
go.mod
View file

@ -8,18 +8,13 @@ require (
github.com/gorilla/mux v1.8.0
github.com/gorilla/websocket v1.5.0
github.com/lib/pq v1.10.9
github.com/mattn/go-sqlite3 v1.14.23
github.com/prometheus/client_golang v1.20.3
github.com/rs/zerolog v1.33.0
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
github.com/tidwall/gjson v1.17.3
go.mau.fi/util v0.8.1-0.20240925093630-1734c3c342eb
go.mau.fi/webp v0.1.0
go.mau.fi/whatsmeow v0.0.0-20240927134544-69ba055bef0f
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0
golang.org/x/image v0.20.0
golang.org/x/net v0.29.0
golang.org/x/sync v0.8.0
google.golang.org/protobuf v1.34.2
gopkg.in/yaml.v3 v3.0.1
maunium.net/go/mautrix v0.21.1-0.20240927113633-d1e5b09d972b
@ -27,20 +22,17 @@ require (
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/coreos/go-systemd/v22 v22.5.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/klauspost/compress v1.17.9 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/mattn/go-sqlite3 v1.14.23 // indirect
github.com/petermattis/goid v0.0.0-20240813172612-4fcff4a6cae7 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.55.0 // indirect
github.com/prometheus/procfs v0.15.1 // indirect
github.com/rogpeppe/go-internal v1.10.0 // indirect
github.com/rs/xid v1.6.0 // indirect
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect
github.com/tidwall/gjson v1.17.3 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
@ -48,8 +40,10 @@ require (
go.mau.fi/libsignal v0.1.1 // indirect
go.mau.fi/zeroconfig v0.1.3 // indirect
golang.org/x/crypto v0.27.0 // indirect
golang.org/x/sync v0.8.0 // indirect
golang.org/x/sys v0.25.0 // indirect
golang.org/x/text v0.18.0 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
maunium.net/go/mauflag v1.0.0 // indirect
)

23
go.sum
View file

@ -2,10 +2,6 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
@ -20,14 +16,13 @@ github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
@ -38,21 +33,13 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.23 h1:gbShiuAP1W5j9UOksQ06aiiqPMxYecovVGwmTxWtuw0=
github.com/mattn/go-sqlite3 v1.14.23/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/petermattis/goid v0.0.0-20240813172612-4fcff4a6cae7 h1:Dx7Ovyv/SFnMFw3fD4oEoeorXc6saIiQ23LrGLth0Gw=
github.com/petermattis/goid v0.0.0-20240813172612-4fcff4a6cae7/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.20.3 h1:oPksm4K8B+Vt35tUhw6GbSNSgVlVSBH0qELP/7u83l4=
github.com/prometheus/client_golang v1.20.3/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc=
github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8=
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=

File diff suppressed because it is too large Load diff

279
main.go
View file

@ -1,279 +0,0 @@
// mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge.
// Copyright (C) 2022 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 <https://www.gnu.org/licenses/>.
package main
import (
"context"
_ "embed"
"net/url"
"os"
"strconv"
"strings"
"sync"
"time"
"github.com/rs/zerolog"
"go.mau.fi/whatsmeow/proto/waCompanionReg"
waLog "go.mau.fi/whatsmeow/util/log"
"google.golang.org/protobuf/proto"
"go.mau.fi/whatsmeow"
waProto "go.mau.fi/whatsmeow/binary/proto"
"go.mau.fi/whatsmeow/store"
"go.mau.fi/whatsmeow/store/sqlstore"
"go.mau.fi/whatsmeow/types"
"go.mau.fi/util/configupgrade"
"maunium.net/go/mautrix/bridge"
"maunium.net/go/mautrix/bridge/commands"
"maunium.net/go/mautrix/bridge/status"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
"maunium.net/go/mautrix-whatsapp/config"
"maunium.net/go/mautrix-whatsapp/database"
)
// 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"
)
//go:embed example-config.yaml
var ExampleConfig string
type WABridge struct {
bridge.Bridge
Config *config.Config
DB *database.Database
Provisioning *ProvisioningAPI
Formatter *Formatter
Metrics *MetricsHandler
WAContainer *sqlstore.Container
WAVersion string
usersByMXID map[id.UserID]*User
usersByUsername map[string]*User
usersLock sync.Mutex
spaceRooms map[id.RoomID]*User
spaceRoomsLock sync.Mutex
managementRooms map[id.RoomID]*User
managementRoomsLock sync.Mutex
portalsByMXID map[id.RoomID]*Portal
portalsByJID map[database.PortalKey]*Portal
portalsLock sync.Mutex
puppets map[types.JID]*Puppet
puppetsByCustomMXID map[id.UserID]*Puppet
puppetsLock sync.Mutex
}
func (br *WABridge) Init() {
br.CommandProcessor = commands.NewProcessor(&br.Bridge)
br.RegisterCommands()
// TODO this is a weird place for this
br.EventProcessor.On(event.EphemeralEventPresence, br.HandlePresence)
br.EventProcessor.On(TypeMSC3381PollStart, br.MatrixHandler.HandleMessage)
br.EventProcessor.On(TypeMSC3381PollResponse, br.MatrixHandler.HandleMessage)
br.EventProcessor.On(TypeMSC3381V2PollResponse, br.MatrixHandler.HandleMessage)
Analytics.log = br.ZLog.With().Str("component", "analytics").Logger()
Analytics.url = (&url.URL{
Scheme: "https",
Host: br.Config.Analytics.Host,
Path: "/v1/track",
}).String()
Analytics.key = br.Config.Analytics.Token
Analytics.userID = br.Config.Analytics.UserID
if Analytics.IsEnabled() {
Analytics.log.Info().Str("override_user_id", Analytics.userID).Msg("Analytics metrics are enabled")
}
br.DB = database.New(br.Bridge.DB)
br.WAContainer = sqlstore.NewWithDB(br.DB.RawDB, br.DB.Dialect.String(), waLog.Zerolog(br.ZLog.With().Str("db_section", "whatsmeow").Logger()))
br.WAContainer.DatabaseErrorHandler = br.DB.HandleSignalStoreError
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()}
}
br.Formatter = NewFormatter(br)
br.Metrics = NewMetricsHandler(br.Config.Metrics.Listen, br.ZLog.With().Str("component", "metrics").Logger(), br.DB)
br.MatrixHandler.TrackEventDuration = br.Metrics.TrackMatrixEvent
store.BaseClientPayload.UserAgent.OsVersion = proto.String(br.WAVersion)
store.BaseClientPayload.UserAgent.OsBuildNumber = proto.String(br.WAVersion)
store.DeviceProps.Os = proto.String(br.Config.WhatsApp.OSName)
store.DeviceProps.RequireFullSync = proto.Bool(br.Config.Bridge.HistorySync.RequestFullSync)
if fsc := br.Config.Bridge.HistorySync.FullSyncConfig; fsc.DaysLimit > 0 && fsc.SizeLimit > 0 && fsc.StorageQuota > 0 {
store.DeviceProps.HistorySyncConfig = &waProto.DeviceProps_HistorySyncConfig{
FullSyncDaysLimit: proto.Uint32(fsc.DaysLimit),
FullSyncSizeMbLimit: proto.Uint32(fsc.SizeLimit),
StorageQuotaMb: proto.Uint32(fsc.StorageQuota),
}
}
versionParts := strings.Split(br.WAVersion, ".")
if len(versionParts) > 2 {
primary, _ := strconv.Atoi(versionParts[0])
secondary, _ := strconv.Atoi(versionParts[1])
tertiary, _ := strconv.Atoi(versionParts[2])
store.DeviceProps.Version.Primary = proto.Uint32(uint32(primary))
store.DeviceProps.Version.Secondary = proto.Uint32(uint32(secondary))
store.DeviceProps.Version.Tertiary = proto.Uint32(uint32(tertiary))
}
platformID, ok := waCompanionReg.DeviceProps_PlatformType_value[strings.ToUpper(br.Config.WhatsApp.BrowserName)]
if ok {
store.DeviceProps.PlatformType = waProto.DeviceProps_PlatformType(platformID).Enum()
}
}
func (br *WABridge) Start() {
err := br.WAContainer.Upgrade()
if err != nil {
br.ZLog.WithLevel(zerolog.FatalLevel).Err(err).Msg("Failed to upgrade whatsmeow database")
os.Exit(15)
}
if br.Provisioning != nil {
br.Provisioning.Init()
}
// TODO find out how the new whatsapp version checks for updates
ver, err := whatsmeow.GetLatestVersion(br.AS.HTTPClient)
if err != nil {
br.ZLog.Err(err).Msg("Failed to get latest WhatsApp web version number")
} else {
br.ZLog.Debug().
Stringer("hardcoded_version", store.GetWAVersion()).
Stringer("latest_version", *ver).
Msg("Got latest WhatsApp web version number")
store.SetWAVersion(*ver)
}
br.WaitWebsocketConnected()
go br.StartUsers()
if br.Config.Metrics.Enabled {
go br.Metrics.Start()
}
go br.Loop()
}
func (br *WABridge) Loop() {
ctx := br.ZLog.With().Str("action", "background loop").Logger().WithContext(context.TODO())
for {
br.SleepAndDeleteUpcoming(ctx)
time.Sleep(1 * time.Hour)
br.WarnUsersAboutDisconnection()
}
}
func (br *WABridge) WarnUsersAboutDisconnection() {
br.usersLock.Lock()
for _, user := range br.usersByUsername {
if user.IsConnected() && !user.PhoneRecentlySeen(true) {
go user.sendPhoneOfflineWarning(context.TODO())
}
}
br.usersLock.Unlock()
}
func (br *WABridge) StartUsers() {
br.ZLog.Debug().Msg("Starting users")
foundAnySessions := false
for _, user := range br.GetAllUsers() {
if !user.JID.IsEmpty() {
foundAnySessions = true
}
go user.Connect()
}
if !foundAnySessions {
br.SendGlobalBridgeState(status.BridgeState{StateEvent: status.StateUnconfigured}.Fill(nil))
}
br.ZLog.Debug().Msg("Starting custom puppets")
for _, loopuppet := range br.GetAllPuppetsWithCustomMXID() {
go func(puppet *Puppet) {
puppet.zlog.Debug().Stringer("custom_mxid", puppet.CustomMXID).Msg("Starting double puppet")
err := puppet.StartCustomMXID(true)
if err != nil {
puppet.zlog.Err(err).Stringer("custom_mxid", puppet.CustomMXID).Msg("Failed to start double puppet")
}
}(loopuppet)
}
}
func (br *WABridge) Stop() {
br.Metrics.Stop()
for _, user := range br.usersByUsername {
if user.Client == nil {
continue
}
user.zlog.Debug().Msg("Disconnecting user")
user.Client.Disconnect()
close(user.historySyncs)
}
}
func (br *WABridge) GetExampleConfig() string {
return ExampleConfig
}
func (br *WABridge) GetConfigPtr() interface{} {
br.Config = &config.Config{
BaseConfig: &br.Bridge.Config,
}
br.Config.BaseConfig.Bridge = &br.Config.Bridge
return br.Config
}
func main() {
br := &WABridge{
usersByMXID: make(map[id.UserID]*User),
usersByUsername: make(map[string]*User),
spaceRooms: make(map[id.RoomID]*User),
managementRooms: make(map[id.RoomID]*User),
portalsByMXID: make(map[id.RoomID]*Portal),
portalsByJID: make(map[database.PortalKey]*Portal),
puppets: make(map[types.JID]*Puppet),
puppetsByCustomMXID: make(map[id.UserID]*Puppet),
}
br.Bridge = bridge.Bridge{
Name: "mautrix-whatsapp",
URL: "https://github.com/mautrix/whatsapp",
Description: "A Matrix-WhatsApp puppeting bridge.",
Version: "0.10.9",
ProtocolName: "WhatsApp",
BeeperServiceName: "whatsapp",
BeeperNetworkName: "whatsapp",
CryptoPickleKey: "maunium.net/go/mautrix-whatsapp",
ConfigUpgrader: &configupgrade.StructUpgrader{
SimpleUpgrader: configupgrade.SimpleUpgrader(config.DoUpgrade),
Blocks: config.SpacedBlocks,
Base: ExampleConfig,
},
Child: br,
}
br.InitVersion(Tag, Commit, BuildTime)
br.WAVersion = strings.FieldsFunc(br.Version, func(r rune) bool { return r == '-' || r == '+' })[0]
br.Main()
}

147
matrix.go
View file

@ -1,147 +0,0 @@
// mautrix-whatsapp - A Matrix-WhatsApp 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 <https://www.gnu.org/licenses/>.
package main
import (
"context"
"fmt"
"github.com/rs/zerolog"
"go.mau.fi/whatsmeow/types"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/bridge"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/format"
"maunium.net/go/mautrix/id"
"maunium.net/go/mautrix-whatsapp/database"
)
func (br *WABridge) CreatePrivatePortal(roomID id.RoomID, brInviter bridge.User, brGhost bridge.Ghost) {
inviter := brInviter.(*User)
puppet := brGhost.(*Puppet)
key := database.NewPortalKey(puppet.JID, inviter.JID)
portal := br.GetPortalByJID(key)
log := br.ZLog.With().
Str("action", "create private portal").
Stringer("target_room_id", roomID).
Stringer("inviter_mxid", inviter.MXID).
Stringer("invitee_jid", puppet.JID).
Logger()
ctx := log.WithContext(context.TODO())
if len(portal.MXID) == 0 {
br.createPrivatePortalFromInvite(ctx, roomID, inviter, puppet, portal)
return
}
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 [%s](%s)", portal.MXID, portal.MXID.URI(br.Config.Homeserver.Domain).MatrixToURL())
errorContent := format.RenderMarkdown(errorMessage, true, false)
_, _ = intent.SendMessageEvent(ctx, roomID, event.EventMessage, errorContent)
log.Debug().Msg("Leaving private chat room from invite as we already have chat with the user")
_, _ = intent.LeaveRoom(ctx, roomID)
}
func (br *WABridge) createPrivatePortalFromInvite(ctx context.Context, roomID id.RoomID, inviter *User, puppet *Puppet, portal *Portal) {
log := zerolog.Ctx(ctx)
// TODO 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")
} else {
encryptionEnabled = existingEncryption.Algorithm == id.AlgorithmMegolmV1
}
portal.MXID = roomID
portal.updateLogger()
portal.Topic = PrivateChatTopic
portal.Name = puppet.Displayname
portal.AvatarURL = puppet.AvatarURL
portal.Avatar = puppet.Avatar
log.Info().Msg("Created private chat portal from invite")
intent := puppet.DefaultIntent()
if br.Config.Bridge.Encryption.Default || encryptionEnabled {
_, 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.MainIntent().SetRoomTopic(ctx, portal.MXID, portal.Topic)
if portal.shouldSetDMRoomMetadata() {
_, err = portal.MainIntent().SetRoomName(ctx, portal.MXID, portal.Name)
portal.NameSet = err == nil
_, err = portal.MainIntent().SetRoomAvatar(ctx, portal.MXID, portal.AvatarURL)
portal.AvatarSet = err == nil
}
err = portal.Update(ctx)
if err != nil {
log.Err(err).Msg("Failed to save portal to database after creating from invite")
}
portal.UpdateBridgeInfo(ctx)
_, _ = intent.SendNotice(ctx, roomID, "Private chat portal created")
}
func (br *WABridge) HandlePresence(ctx context.Context, evt *event.Event) {
user := br.GetUserByMXIDIfExists(evt.Sender)
if user == nil || !user.IsLoggedIn() {
return
}
customPuppet := br.GetPuppetByCustomMXID(user.MXID)
// TODO move this flag to the user and/or portal data
if customPuppet != nil && !customPuppet.EnablePresence {
return
}
presence := types.PresenceAvailable
if evt.Content.AsPresence().Presence != event.PresenceOnline {
presence = types.PresenceUnavailable
user.zlog.Debug().Msg("Marking offline")
} else {
user.zlog.Debug().Msg("Marking online")
}
user.lastPresence = presence
if user.Client.Store.PushName != "" {
err := user.Client.SendPresence(presence)
if err != nil {
user.zlog.Err(err).Msg("Failed to set presence")
}
}
}

View file

@ -1,322 +0,0 @@
// mautrix-whatsapp - A Matrix-WhatsApp 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 <https://www.gnu.org/licenses/>.
package main
import (
"context"
"errors"
"fmt"
"sync"
"time"
"github.com/rs/zerolog"
"go.mau.fi/whatsmeow"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/bridge/status"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
)
var (
errUserNotConnected = errors.New("you are not connected to WhatsApp")
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")
errMNoticeDisabled = errors.New("bridging m.notice messages is disabled")
errUnexpectedParsedContentType = errors.New("unexpected parsed content type")
errInvalidGeoURI = errors.New("invalid `geo:` URI in message")
errUnknownMsgType = errors.New("unknown msgtype")
errMediaDownloadFailed = errors.New("failed to download media")
errMediaDecryptFailed = errors.New("failed to decrypt media")
errMediaConvertFailed = errors.New("failed to convert media")
errMediaWhatsAppUploadFailed = errors.New("failed to upload media to WhatsApp")
errMediaUnsupportedType = errors.New("unsupported media type")
errTargetNotFound = errors.New("target event not found")
errReactionDatabaseNotFound = errors.New("reaction database entry not found")
errReactionTargetNotFound = errors.New("reaction target message not found")
errTargetIsFake = errors.New("target is a fake event")
errReactionSentBySomeoneElse = errors.New("target reaction was sent by someone else")
errDMSentByOtherUser = errors.New("target message was sent by the other user in a DM")
errPollMissingQuestion = errors.New("poll message is missing question")
errPollDuplicateOption = errors.New("poll options must be unique")
errGalleryRelay = errors.New("can't send gallery through relay user")
errGalleryCaption = errors.New("can't send gallery with caption")
errEditUnknownTarget = errors.New("unknown edit target message")
errEditUnknownTargetType = errors.New("unsupported edited message type")
errEditDifferentSender = errors.New("can't edit message sent by another user")
errEditTooOld = errors.New("message is too old to be edited")
errBroadcastReactionNotSupported = errors.New("reacting to status messages is not currently supported")
errBroadcastSendDisabled = errors.New("sending status messages is disabled")
errMessageDisconnected = &whatsmeow.DisconnectedError{Action: "message send"}
errMessageRetryDisconnected = &whatsmeow.DisconnectedError{Action: "message send (retry)"}
errMessageTakingLong = errors.New("bridging the message is taking longer than usual")
errTimeoutBeforeHandling = errors.New("message timed out before handling was started")
)
func errorToStatusReason(err error) (reason event.MessageStatusReason, status event.MessageStatus, isCertain, sendNotice bool, humanMessage string) {
switch {
case errors.Is(err, whatsmeow.ErrBroadcastListUnsupported),
errors.Is(err, errUnexpectedParsedContentType),
errors.Is(err, errUnknownMsgType),
errors.Is(err, errInvalidGeoURI),
errors.Is(err, whatsmeow.ErrUnknownServer),
errors.Is(err, whatsmeow.ErrRecipientADJID),
errors.Is(err, errBroadcastReactionNotSupported),
errors.Is(err, errBroadcastSendDisabled):
return event.MessageStatusUnsupported, event.MessageStatusFail, true, true, ""
case errors.Is(err, errMNoticeDisabled):
return event.MessageStatusUnsupported, event.MessageStatusFail, true, false, ""
case errors.Is(err, errMediaUnsupportedType),
errors.Is(err, errPollMissingQuestion),
errors.Is(err, errPollDuplicateOption),
errors.Is(err, errEditDifferentSender),
errors.Is(err, errEditTooOld),
errors.Is(err, errEditUnknownTarget),
errors.Is(err, errEditUnknownTargetType):
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, errMessageTakingLong):
return event.MessageStatusTooOld, event.MessageStatusPending, false, true, err.Error()
case errors.Is(err, errTargetNotFound),
errors.Is(err, errTargetIsFake),
errors.Is(err, errReactionDatabaseNotFound),
errors.Is(err, errReactionTargetNotFound),
errors.Is(err, errReactionSentBySomeoneElse),
errors.Is(err, errDMSentByOtherUser):
return event.MessageStatusGenericError, event.MessageStatusFail, true, false, ""
case errors.Is(err, whatsmeow.ErrNotConnected),
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, ""
case errors.Is(err, errMessageDisconnected),
errors.Is(err, errMessageRetryDisconnected):
return event.MessageStatusGenericError, event.MessageStatusRetriable, false, true, ""
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 {
zerolog.Ctx(ctx).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 {
zerolog.Ctx(ctx).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 {
zerolog.Ctx(ctx).Err(err).Msg("Failed to mark message as read by bot (Matrix-side delivery receipt)")
}
}
}
func (portal *Portal) sendMessageMetrics(ctx context.Context, evt *event.Event, err error, part string, ms *metricSender) {
origEvtID := evt.ID
if retryMeta := evt.Content.AsMessage().MessageSendRetry; retryMeta != nil {
origEvtID = retryMeta.OriginalEventID
}
if err != nil {
level := zerolog.ErrorLevel
if part == "Ignoring" {
level = zerolog.DebugLevel
}
zerolog.Ctx(ctx).WithLevel(level).Err(err).Msg(part + " Matrix 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 {
zerolog.Ctx(ctx).Debug().Msg("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 {
zerolog.Ctx(ctx).Debug().Object("timings", ms.timings).Msg("Matrix event timings")
}
}
type messageTimings struct {
initReceive time.Duration
decrypt time.Duration
implicitRR time.Duration
portalQueue time.Duration
totalReceive time.Duration
preproc time.Duration
convert time.Duration
whatsmeow whatsmeow.MessageDebugTimings
totalSend time.Duration
}
func (mt *messageTimings) MarshalZerologObject(e *zerolog.Event) {
e.Dur("init_receive", mt.initReceive).
Dur("decrypt", mt.decrypt).
Dur("implicit_rr", mt.implicitRR).
Dur("portal_queue", mt.portalQueue).
Dur("total_receive", mt.totalReceive).
Dur("preproc", mt.preproc).
Dur("convert", mt.convert).
Object("whatsmeow", mt.whatsmeow).
Dur("total_send", mt.totalSend)
}
type metricSender struct {
portal *Portal
previousNotice id.EventID
lock sync.Mutex
completed bool
retryNum int
timings *messageTimings
}
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(ctx context.Context, evt *event.Event, err error, part string, completed bool) {
ms.lock.Lock()
defer ms.lock.Unlock()
if !completed && ms.completed {
return
}
ms.portal.sendMessageMetrics(ctx, evt, err, part, ms)
ms.retryNum++
ms.completed = completed
}

View file

@ -1,320 +0,0 @@
// mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge.
// Copyright (C) 2021 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 <https://www.gnu.org/licenses/>.
package main
import (
"context"
"errors"
"net/http"
"runtime/debug"
"strconv"
"sync"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/rs/zerolog"
"go.mau.fi/whatsmeow/types"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
"maunium.net/go/mautrix-whatsapp/database"
)
type MetricsHandler struct {
db *database.Database
server *http.Server
log zerolog.Logger
running bool
ctx context.Context
stopRecorder func()
matrixEventHandling *prometheus.HistogramVec
whatsappMessageAge prometheus.Histogram
whatsappMessageHandling *prometheus.HistogramVec
countCollection prometheus.Histogram
disconnections *prometheus.CounterVec
incomingRetryReceipts *prometheus.CounterVec
connectionFailures *prometheus.CounterVec
puppetCount prometheus.Gauge
userCount prometheus.Gauge
messageCount prometheus.Gauge
portalCount *prometheus.GaugeVec
encryptedGroupCount prometheus.Gauge
encryptedPrivateCount prometheus.Gauge
unencryptedGroupCount prometheus.Gauge
unencryptedPrivateCount prometheus.Gauge
connected prometheus.Gauge
connectedState map[string]bool
connectedStateLock sync.Mutex
loggedIn prometheus.Gauge
loggedInState map[string]bool
loggedInStateLock sync.Mutex
}
func NewMetricsHandler(address string, log zerolog.Logger, db *database.Database) *MetricsHandler {
portalCount := promauto.NewGaugeVec(prometheus.GaugeOpts{
Name: "whatsapp_portals_total",
Help: "Number of portal rooms on Matrix",
}, []string{"type", "encrypted"})
return &MetricsHandler{
db: db,
server: &http.Server{Addr: address, Handler: promhttp.Handler()},
log: log,
running: false,
matrixEventHandling: promauto.NewHistogramVec(prometheus.HistogramOpts{
Name: "matrix_event",
Help: "Time spent processing Matrix events",
}, []string{"event_type"}),
whatsappMessageAge: promauto.NewHistogram(prometheus.HistogramOpts{
Name: "remote_event_age",
Help: "Age of messages received from WhatsApp",
Buckets: []float64{1, 2, 3, 5, 7.5, 10, 20, 30, 60},
}),
whatsappMessageHandling: promauto.NewHistogramVec(prometheus.HistogramOpts{
Name: "remote_event",
Help: "Time spent processing WhatsApp messages",
}, []string{"message_type"}),
countCollection: promauto.NewHistogram(prometheus.HistogramOpts{
Name: "whatsapp_count_collection",
Help: "Time spent collecting the whatsapp_*_total metrics",
}),
disconnections: promauto.NewCounterVec(prometheus.CounterOpts{
Name: "whatsapp_disconnections",
Help: "Number of times a Matrix user has been disconnected from WhatsApp",
}, []string{"user_id"}),
connectionFailures: promauto.NewCounterVec(prometheus.CounterOpts{
Name: "whatsapp_connection_failures",
Help: "Number of times a connection has failed to whatsapp",
}, []string{"reason"}),
incomingRetryReceipts: promauto.NewCounterVec(prometheus.CounterOpts{
Name: "whatsapp_incoming_retry_receipts",
Help: "Number of times a remote WhatsApp user has requested a retry from the bridge. retry_count = 5 is usually the last attempt (and very likely means a failed message)",
}, []string{"retry_count", "message_found"}),
puppetCount: promauto.NewGauge(prometheus.GaugeOpts{
Name: "whatsapp_puppets_total",
Help: "Number of WhatsApp users bridged into Matrix",
}),
userCount: promauto.NewGauge(prometheus.GaugeOpts{
Name: "whatsapp_users_total",
Help: "Number of Matrix users using the bridge",
}),
messageCount: promauto.NewGauge(prometheus.GaugeOpts{
Name: "whatsapp_messages_total",
Help: "Number of messages bridged",
}),
portalCount: portalCount,
encryptedGroupCount: portalCount.With(prometheus.Labels{"type": "group", "encrypted": "true"}),
encryptedPrivateCount: portalCount.With(prometheus.Labels{"type": "private", "encrypted": "true"}),
unencryptedGroupCount: portalCount.With(prometheus.Labels{"type": "group", "encrypted": "false"}),
unencryptedPrivateCount: portalCount.With(prometheus.Labels{"type": "private", "encrypted": "false"}),
loggedIn: promauto.NewGauge(prometheus.GaugeOpts{
Name: "bridge_logged_in",
Help: "Users logged into the bridge",
}),
loggedInState: make(map[string]bool),
connected: promauto.NewGauge(prometheus.GaugeOpts{
Name: "bridge_connected",
Help: "Bridge users connected to WhatsApp",
}),
connectedState: make(map[string]bool),
}
}
func noop() {}
func (mh *MetricsHandler) TrackMatrixEvent(eventType event.Type) func() {
if !mh.running {
return noop
}
start := time.Now()
return func() {
duration := time.Now().Sub(start)
mh.matrixEventHandling.
With(prometheus.Labels{"event_type": eventType.Type}).
Observe(duration.Seconds())
}
}
func (mh *MetricsHandler) TrackWhatsAppMessage(timestamp time.Time, messageType string) func() {
if !mh.running {
return noop
}
start := time.Now()
return func() {
duration := time.Now().Sub(start)
mh.whatsappMessageHandling.
With(prometheus.Labels{"message_type": messageType}).
Observe(duration.Seconds())
mh.whatsappMessageAge.Observe(time.Now().Sub(timestamp).Seconds())
}
}
func (mh *MetricsHandler) TrackDisconnection(userID id.UserID) {
if !mh.running {
return
}
mh.disconnections.With(prometheus.Labels{"user_id": string(userID)}).Inc()
}
func (mh *MetricsHandler) TrackConnectionFailure(reason string) {
if !mh.running {
return
}
mh.connectionFailures.With(prometheus.Labels{"reason": reason}).Inc()
}
func (mh *MetricsHandler) TrackRetryReceipt(count int, found bool) {
if !mh.running {
return
}
mh.incomingRetryReceipts.With(prometheus.Labels{
"retry_count": strconv.Itoa(count),
"message_found": strconv.FormatBool(found),
}).Inc()
}
func (mh *MetricsHandler) TrackLoginState(jid types.JID, loggedIn bool) {
if !mh.running {
return
}
mh.loggedInStateLock.Lock()
defer mh.loggedInStateLock.Unlock()
currentVal, ok := mh.loggedInState[jid.User]
if !ok || currentVal != loggedIn {
mh.loggedInState[jid.User] = loggedIn
if loggedIn {
mh.loggedIn.Inc()
} else {
mh.loggedIn.Dec()
}
}
}
func (mh *MetricsHandler) TrackConnectionState(jid types.JID, connected bool) {
if !mh.running {
return
}
mh.connectedStateLock.Lock()
defer mh.connectedStateLock.Unlock()
currentVal, ok := mh.connectedState[jid.User]
if !ok || currentVal != connected {
mh.connectedState[jid.User] = connected
if connected {
mh.connected.Inc()
} else {
mh.connected.Dec()
}
}
}
func (mh *MetricsHandler) updateStats() {
start := time.Now()
var puppetCount int
err := mh.db.QueryRow(mh.ctx, "SELECT COUNT(*) FROM puppet").Scan(&puppetCount)
if err != nil {
mh.log.Err(err).Msg("Failed to scan number of puppets")
} else {
mh.puppetCount.Set(float64(puppetCount))
}
var userCount int
err = mh.db.QueryRow(mh.ctx, `SELECT COUNT(*) FROM "user"`).Scan(&userCount)
if err != nil {
mh.log.Err(err).Msg("Failed to scan number of users")
} else {
mh.userCount.Set(float64(userCount))
}
var messageCount int
err = mh.db.QueryRow(mh.ctx, "SELECT COUNT(*) FROM message").Scan(&messageCount)
if err != nil {
mh.log.Err(err).Msg("Failed to scan number of messages")
} else {
mh.messageCount.Set(float64(messageCount))
}
var encryptedGroupCount, encryptedPrivateCount, unencryptedGroupCount, unencryptedPrivateCount int
err = mh.db.QueryRow(mh.ctx, `
SELECT
COUNT(CASE WHEN jid LIKE '%@g.us' AND encrypted THEN 1 END) AS encrypted_group_portals,
COUNT(CASE WHEN jid LIKE '%@s.whatsapp.net' AND encrypted THEN 1 END) AS encrypted_private_portals,
COUNT(CASE WHEN jid LIKE '%@g.us' AND NOT encrypted THEN 1 END) AS unencrypted_group_portals,
COUNT(CASE WHEN jid LIKE '%@s.whatsapp.net' AND NOT encrypted THEN 1 END) AS unencrypted_private_portals
FROM portal WHERE mxid<>''
`).Scan(&encryptedGroupCount, &encryptedPrivateCount, &unencryptedGroupCount, &unencryptedPrivateCount)
if err != nil {
mh.log.Err(err).Msg("Failed to scan number of portals")
} else {
mh.encryptedGroupCount.Set(float64(encryptedGroupCount))
mh.encryptedPrivateCount.Set(float64(encryptedPrivateCount))
mh.unencryptedGroupCount.Set(float64(unencryptedGroupCount))
mh.unencryptedPrivateCount.Set(float64(encryptedPrivateCount))
}
mh.countCollection.Observe(time.Now().Sub(start).Seconds())
}
func (mh *MetricsHandler) startUpdatingStats() {
defer func() {
err := recover()
if err != nil {
mh.log.WithLevel(zerolog.PanicLevel).
Bytes(zerolog.ErrorStackFieldName, debug.Stack()).
Interface(zerolog.ErrorFieldName, err).
Msg("Panic in metric updater")
}
}()
ticker := time.Tick(10 * time.Second)
for {
mh.updateStats()
select {
case <-mh.ctx.Done():
return
case <-ticker:
}
}
}
func (mh *MetricsHandler) Start() {
mh.running = true
mh.ctx, mh.stopRecorder = context.WithCancel(context.Background())
go mh.startUpdatingStats()
err := mh.server.ListenAndServe()
mh.running = false
if err != nil && !errors.Is(err, http.ErrServerClosed) {
mh.log.Err(err).Msg("Error in metrics listener")
}
}
func (mh *MetricsHandler) Stop() {
if !mh.running {
return
}
mh.stopRecorder()
err := mh.server.Close()
if err != nil {
mh.log.Err(err).Msg("Failed to close metrics listener")
}
}

View file

@ -87,10 +87,10 @@ func (wa *WhatsAppClient) handleWAHistorySync(ctx context.Context, evt *waHistor
Msg("Failed to parse chat JID in history sync")
continue
} else if jid.Server == types.BroadcastServer {
log.Debug().Str("chat_jid", jid.String()).Msg("Skipping broadcast list in history sync")
log.Debug().Stringer("chat_jid", jid).Msg("Skipping broadcast list in history sync")
continue
} else if jid.Server == types.HiddenUserServer {
log.Debug().Str("chat_jid", jid.String()).Msg("Skipping hidden user JID chat in history sync")
log.Debug().Stringer("chat_jid", jid).Msg("Skipping hidden user JID chat in history sync")
continue
}
totalMessageCount += len(conv.GetMessages())

5512
portal.go

File diff suppressed because it is too large Load diff

View file

@ -1,808 +0,0 @@
// mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge.
// Copyright (C) 2022 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 <https://www.gnu.org/licenses/>.
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
_ "net/http/pprof"
"regexp"
"strings"
"time"
"github.com/gorilla/mux"
"github.com/gorilla/websocket"
"github.com/rs/zerolog"
"github.com/rs/zerolog/hlog"
"go.mau.fi/util/requestlog"
"go.mau.fi/whatsmeow"
"go.mau.fi/whatsmeow/appstate"
"go.mau.fi/whatsmeow/types"
"maunium.net/go/mautrix/bridge/status"
"maunium.net/go/mautrix/id"
)
type ProvisioningAPI struct {
bridge *WABridge
log zerolog.Logger
}
func (prov *ProvisioningAPI) Init() {
prov.log.Debug().Str("base_path", 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/ping", prov.Ping).Methods(http.MethodGet)
r.HandleFunc("/v1/login", prov.Login).Methods(http.MethodGet)
r.HandleFunc("/v1/logout", prov.Logout).Methods(http.MethodPost)
r.HandleFunc("/v1/delete_session", prov.DeleteSession).Methods(http.MethodPost)
r.HandleFunc("/v1/disconnect", prov.Disconnect).Methods(http.MethodPost)
r.HandleFunc("/v1/reconnect", prov.Reconnect).Methods(http.MethodPost)
r.HandleFunc("/v1/debug/appstate/{name}", prov.SyncAppState).Methods(http.MethodPost)
r.HandleFunc("/v1/contacts", prov.ListContacts).Methods(http.MethodGet)
r.HandleFunc("/v1/groups", prov.ListGroups).Methods(http.MethodGet, http.MethodPost)
r.HandleFunc("/v1/resolve_identifier/{number}", prov.ResolveIdentifier).Methods(http.MethodGet)
r.HandleFunc("/v1/bulk_resolve_identifier", prov.BulkResolveIdentifier).Methods(http.MethodPost)
r.HandleFunc("/v1/pm/{number}", prov.StartPM).Methods(http.MethodPost)
r.HandleFunc("/v1/open/{groupID}", prov.OpenGroup).Methods(http.MethodPost)
r.HandleFunc("/v1/group/open/{groupID}", prov.OpenGroup).Methods(http.MethodPost)
r.HandleFunc("/v1/group/resolve/{inviteCode}", prov.ResolveGroupInvite).Methods(http.MethodPost)
r.HandleFunc("/v1/group/join/{inviteCode}", prov.JoinGroup).Methods(http.MethodPost)
prov.bridge.AS.Router.HandleFunc("/_matrix/app/com.beeper.asmux/ping", prov.BridgeStatePing).Methods(http.MethodPost)
prov.bridge.AS.Router.HandleFunc("/_matrix/app/com.beeper.bridge_state", prov.BridgeStatePing).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)
}
// Deprecated, just use /disconnect
r.HandleFunc("/v1/delete_connection", prov.Disconnect).Methods(http.MethodPost)
}
func (prov *ProvisioningAPI) AuthMiddleware(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
auth := r.Header.Get("Authorization")
if len(auth) == 0 && strings.HasSuffix(r.URL.Path, "/login") {
authParts := strings.Split(r.Header.Get("Sec-WebSocket-Protocol"), ",")
for _, part := range authParts {
part = strings.TrimSpace(part)
if strings.HasPrefix(part, "net.maunium.whatsapp.auth-") {
auth = part[len("net.maunium.whatsapp.auth-"):]
break
}
}
} else if strings.HasPrefix(auth, "Bearer ") {
auth = auth[len("Bearer "):]
}
if auth != prov.bridge.Config.Bridge.Provisioning.SharedSecret {
hlog.FromRequest(r).Debug().Msg("Authentication token does not match shared secret")
jsonResponse(w, http.StatusForbidden, map[string]interface{}{
"error": "Authentication token does not match shared secret",
"errcode": "M_FORBIDDEN",
})
return
}
userID := r.URL.Query().Get("user_id")
user := prov.bridge.GetUserByMXID(id.UserID(userID))
if user != nil {
hlog.FromRequest(r).UpdateContext(func(c zerolog.Context) zerolog.Context {
return c.Stringer("user_id", user.MXID)
})
}
h.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), "user", 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) DeleteSession(w http.ResponseWriter, r *http.Request) {
user := r.Context().Value("user").(*User)
if user.Session == nil && user.Client == nil {
jsonResponse(w, http.StatusNotFound, Error{
Error: "Nothing to purge: no session information stored and no active connection.",
ErrCode: "no session",
})
return
}
user.DeleteConnection()
user.DeleteSession(r.Context())
jsonResponse(w, http.StatusOK, Response{true, "Session information purged"})
user.removeFromJIDMap(status.BridgeState{StateEvent: status.StateLoggedOut})
}
func (prov *ProvisioningAPI) Disconnect(w http.ResponseWriter, r *http.Request) {
user := r.Context().Value("user").(*User)
if user.Client == nil {
jsonResponse(w, http.StatusNotFound, Error{
Error: "You don't have a WhatsApp connection.",
ErrCode: "no connection",
})
return
}
user.DeleteConnection()
jsonResponse(w, http.StatusOK, Response{true, "Disconnected from WhatsApp"})
user.BridgeState.Send(status.BridgeState{StateEvent: status.StateBadCredentials, Error: WANotConnected})
}
func (prov *ProvisioningAPI) Reconnect(w http.ResponseWriter, r *http.Request) {
user := r.Context().Value("user").(*User)
if user.Client == nil {
if user.Session == nil {
jsonResponse(w, http.StatusForbidden, Error{
Error: "No existing connection and no session. Please log in first.",
ErrCode: "no session",
})
} else {
user.Connect()
jsonResponse(w, http.StatusAccepted, Response{true, "Created connection to WhatsApp."})
}
} else {
user.DeleteConnection()
user.BridgeState.Send(status.BridgeState{StateEvent: status.StateTransientDisconnect, Error: WANotConnected})
user.Connect()
jsonResponse(w, http.StatusAccepted, Response{true, "Restarted connection to WhatsApp"})
}
}
func (prov *ProvisioningAPI) SyncAppState(w http.ResponseWriter, r *http.Request) {
user := r.Context().Value("user").(*User)
if user == nil || user.Client == nil {
jsonResponse(w, http.StatusNotFound, Error{
Error: "User is not connected to WhatsApp",
ErrCode: "no session",
})
return
}
vars := mux.Vars(r)
nameStr := vars["name"]
if len(nameStr) == 0 {
jsonResponse(w, http.StatusBadRequest, Error{
Error: "The `name` parameter is required",
ErrCode: "missing-name-param",
})
return
}
var name appstate.WAPatchName
for _, existingName := range appstate.AllPatchNames {
if nameStr == string(existingName) {
name = existingName
}
}
if len(name) == 0 {
jsonResponse(w, http.StatusBadRequest, Error{
Error: fmt.Sprintf("'%s' is not a valid app state patch name", nameStr),
ErrCode: "invalid-name-param",
})
return
}
fullStr := r.URL.Query().Get("full")
fullSync := len(fullStr) > 0 && (fullStr == "1" || strings.ToLower(fullStr)[0] == 't')
err := user.Client.FetchAppState(name, fullSync, false)
if err != nil {
jsonResponse(w, http.StatusInternalServerError, Error{false, err.Error(), "sync-fail"})
} else {
jsonResponse(w, http.StatusOK, Response{true, fmt.Sprintf("Synced app state %s", name)})
}
}
func (prov *ProvisioningAPI) ListContacts(w http.ResponseWriter, r *http.Request) {
if user := r.Context().Value("user").(*User); user.Session == nil {
jsonResponse(w, http.StatusBadRequest, Error{
Error: "User is not logged into WhatsApp",
ErrCode: "no session",
})
} else if contacts, err := user.Session.Contacts.GetAllContacts(); err != nil {
hlog.FromRequest(r).Err(err).Msg("Failed to fetch all contacts")
jsonResponse(w, http.StatusInternalServerError, Error{
Error: "Internal server error while fetching contact list",
ErrCode: "failed to get contacts",
})
} else {
augmentedContacts := map[types.JID]interface{}{}
for jid, contact := range contacts {
var avatarUrl id.ContentURI
if puppet := prov.bridge.GetPuppetByJID(jid); puppet != nil {
avatarUrl = puppet.AvatarURL
}
augmentedContacts[jid] = map[string]interface{}{
"Found": contact.Found,
"FirstName": contact.FirstName,
"FullName": contact.FullName,
"PushName": contact.PushName,
"BusinessName": contact.BusinessName,
"AvatarURL": avatarUrl,
}
}
jsonResponse(w, http.StatusOK, augmentedContacts)
}
}
func (prov *ProvisioningAPI) ListGroups(w http.ResponseWriter, r *http.Request) {
user := r.Context().Value("user").(*User)
if user.Session == nil {
jsonResponse(w, http.StatusBadRequest, Error{
Error: "User is not logged into WhatsApp",
ErrCode: "no session",
})
return
}
if r.Method == http.MethodPost {
err := user.ResyncGroups(r.URL.Query().Get("create_portals") == "true")
if err != nil {
hlog.FromRequest(r).Err(err).Msg("Failed to resync groups")
jsonResponse(w, http.StatusInternalServerError, Error{
Error: "Internal server error while resyncing groups",
ErrCode: "failed to sync groups",
})
return
}
}
if groups, err := user.getCachedGroupList(); err != nil {
hlog.FromRequest(r).Err(err).Msg("Failed to fetch group list")
jsonResponse(w, http.StatusInternalServerError, Error{
Error: "Internal server error while fetching group list",
ErrCode: "failed to get groups",
})
} else {
jsonResponse(w, http.StatusOK, groups)
}
}
type OtherUserInfo struct {
MXID id.UserID `json:"mxid"`
JID types.JID `json:"jid"`
Name string `json:"displayname"`
Avatar id.ContentURI `json:"avatar_url"`
}
type PortalInfo struct {
RoomID id.RoomID `json:"room_id"`
OtherUser *OtherUserInfo `json:"other_user,omitempty"`
GroupInfo *types.GroupInfo `json:"group_info,omitempty"`
JustCreated bool `json:"just_created"`
}
func looksEmaily(str string) bool {
for _, char := range str {
// Characters that are usually in emails, but shouldn't be in phone numbers
if (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || char == '@' {
return true
}
}
return false
}
func (prov *ProvisioningAPI) resolveIdentifier(w http.ResponseWriter, r *http.Request) (types.JID, *User) {
number, _ := mux.Vars(r)["number"]
if strings.HasSuffix(number, "@"+types.DefaultUserServer) {
jid, _ := types.ParseJID(number)
number = "+" + jid.User
}
if looksEmaily(number) {
jsonResponse(w, http.StatusBadRequest, Error{
Error: "WhatsApp only supports phone numbers as user identifiers",
ErrCode: "number looks like email",
})
} else if user := r.Context().Value("user").(*User); !user.IsLoggedIn() {
jsonResponse(w, http.StatusBadRequest, Error{
Error: "User is not logged into WhatsApp",
ErrCode: "no session",
})
} else if resp, err := user.Client.IsOnWhatsApp([]string{number}); err != nil {
jsonResponse(w, http.StatusInternalServerError, Error{
Error: fmt.Sprintf("Failed to check if number is on WhatsApp: %v", err),
ErrCode: "error checking number",
})
} else if len(resp) == 0 {
jsonResponse(w, http.StatusInternalServerError, Error{
Error: "Didn't get a response to checking if the number is on WhatsApp",
ErrCode: "error checking number",
})
} else if !resp[0].IsIn {
jsonResponse(w, http.StatusNotFound, Error{
Error: fmt.Sprintf("The server said +%s is not on WhatsApp", resp[0].JID.User),
ErrCode: "not on whatsapp",
})
} else {
return resp[0].JID, user
}
return types.EmptyJID, nil
}
func (prov *ProvisioningAPI) StartPM(w http.ResponseWriter, r *http.Request) {
jid, user := prov.resolveIdentifier(w, r)
if jid.IsEmpty() || user == nil {
// resolveIdentifier already responded with an error
return
}
portal, puppet, justCreated, err := user.StartPM(r.Context(), jid, "provisioning API PM")
if err != nil {
jsonResponse(w, http.StatusInternalServerError, Error{
Error: fmt.Sprintf("Failed to create portal: %v", err),
})
}
statusCode := http.StatusOK
if justCreated {
statusCode = http.StatusCreated
}
jsonResponse(w, statusCode, PortalInfo{
RoomID: portal.MXID,
OtherUser: &OtherUserInfo{
JID: puppet.JID,
MXID: puppet.MXID,
Name: puppet.Displayname,
Avatar: puppet.AvatarURL,
},
JustCreated: justCreated,
})
}
func (prov *ProvisioningAPI) ResolveIdentifier(w http.ResponseWriter, r *http.Request) {
jid, user := prov.resolveIdentifier(w, r)
if jid.IsEmpty() || user == nil {
// resolveIdentifier already responded with an error
return
}
portal := user.GetPortalByJID(jid)
puppet := user.bridge.GetPuppetByJID(jid)
jsonResponse(w, http.StatusOK, PortalInfo{
RoomID: portal.MXID,
OtherUser: &OtherUserInfo{
JID: puppet.JID,
MXID: puppet.MXID,
Name: puppet.Displayname,
Avatar: puppet.AvatarURL,
},
})
}
type ReqBulkResolveIdentifier struct {
Numbers []string `json:"numbers"`
}
func (prov *ProvisioningAPI) BulkResolveIdentifier(w http.ResponseWriter, r *http.Request) {
var req ReqBulkResolveIdentifier
var resp []types.IsOnWhatsAppResponse
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonResponse(w, http.StatusBadRequest, Error{
Error: "Failed to parse request JSON",
ErrCode: "bad json",
})
} else if user := r.Context().Value("user").(*User); !user.IsLoggedIn() {
jsonResponse(w, http.StatusBadRequest, Error{
Error: "User is not logged into WhatsApp",
ErrCode: "no session",
})
} else if resp, err = user.Client.IsOnWhatsApp(req.Numbers); err != nil {
jsonResponse(w, http.StatusInternalServerError, Error{
Error: fmt.Sprintf("Failed to check if number is on WhatsApp: %v", err),
ErrCode: "error checking number",
})
} else {
jsonResponse(w, http.StatusOK, resp)
}
}
func (prov *ProvisioningAPI) OpenGroup(w http.ResponseWriter, r *http.Request) {
groupID, _ := mux.Vars(r)["groupID"]
if user := r.Context().Value("user").(*User); !user.IsLoggedIn() {
jsonResponse(w, http.StatusBadRequest, Error{
Error: "User is not logged into WhatsApp",
ErrCode: "no session",
})
} else if jid, err := types.ParseJID(groupID); err != nil || jid.Server != types.GroupServer || (!strings.ContainsRune(jid.User, '-') && len(jid.User) < 15) {
jsonResponse(w, http.StatusBadRequest, Error{
Error: "Invalid group ID",
ErrCode: "invalid group id",
})
} else if info, err := user.Client.GetGroupInfo(jid); err != nil {
hlog.FromRequest(r).Err(err).Msg("Failed to get group info by JID")
// TODO return better responses for different errors (like ErrGroupNotFound and ErrNotInGroup)
jsonResponse(w, http.StatusInternalServerError, Error{
Error: fmt.Sprintf("Failed to get group info: %v", err),
ErrCode: "error getting group info",
})
} else {
hlog.FromRequest(r).Debug().Stringer("chat_jid", jid).Msg("Importing group chat for user")
portal := user.GetPortalByJID(info.JID)
statusCode := http.StatusOK
if len(portal.MXID) == 0 {
err = portal.CreateMatrixRoom(r.Context(), user, info, nil, true, true)
if err != nil {
jsonResponse(w, http.StatusInternalServerError, Error{
Error: fmt.Sprintf("Failed to create portal: %v", err),
})
return
}
statusCode = http.StatusCreated
}
jsonResponse(w, statusCode, PortalInfo{
RoomID: portal.MXID,
GroupInfo: info,
JustCreated: statusCode == http.StatusCreated,
})
}
}
func (prov *ProvisioningAPI) resolveGroupInvite(w http.ResponseWriter, r *http.Request) (*types.GroupInfo, *User) {
inviteCode, _ := mux.Vars(r)["inviteCode"]
if user := r.Context().Value("user").(*User); !user.IsLoggedIn() {
jsonResponse(w, http.StatusBadRequest, Error{
Error: "User is not logged into WhatsApp",
ErrCode: "no session",
})
} else if info, err := user.Client.GetGroupInfoFromLink(inviteCode); err != nil {
if errors.Is(err, whatsmeow.ErrInviteLinkRevoked) {
jsonResponse(w, http.StatusBadRequest, Error{
Error: whatsmeow.ErrInviteLinkRevoked.Error(),
ErrCode: "invite link revoked",
})
} else if errors.Is(err, whatsmeow.ErrInviteLinkInvalid) {
jsonResponse(w, http.StatusBadRequest, Error{
Error: whatsmeow.ErrInviteLinkInvalid.Error(),
ErrCode: "invalid invite link",
})
} else {
hlog.FromRequest(r).Err(err).Msg("Failed to get group info from link")
jsonResponse(w, http.StatusInternalServerError, Error{
Error: fmt.Sprintf("Failed to fetch group info with link: %v", err),
ErrCode: "error getting group info",
})
}
} else {
return info, user
}
return nil, nil
}
func (prov *ProvisioningAPI) ResolveGroupInvite(w http.ResponseWriter, r *http.Request) {
info, user := prov.resolveGroupInvite(w, r)
if info == nil {
return
}
jsonResponse(w, http.StatusOK, PortalInfo{
RoomID: user.GetPortalByJID(info.JID).MXID,
GroupInfo: info,
})
}
func (prov *ProvisioningAPI) JoinGroup(w http.ResponseWriter, r *http.Request) {
info, user := prov.resolveGroupInvite(w, r)
if info == nil {
return
}
user.groupJoinLock.Lock()
user.skipGroupCreateDelay = info.JID
defer func() {
user.skipGroupCreateDelay = types.EmptyJID
user.groupJoinLock.Unlock()
}()
inviteCode, _ := mux.Vars(r)["inviteCode"]
if jid, err := user.Client.JoinGroupWithLink(inviteCode); err != nil {
hlog.FromRequest(r).Err(err).Msg("Failed to join group")
jsonResponse(w, http.StatusInternalServerError, Error{
Error: fmt.Sprintf("Failed to join group: %v", err),
ErrCode: "error joining group",
})
} else {
hlog.FromRequest(r).Debug().Stringer("chat_jid", jid).Msg("Successfully joined group")
portal := user.GetPortalByJID(jid)
statusCode := http.StatusOK
if len(portal.MXID) == 0 {
time.Sleep(500 * time.Millisecond) // Wait for incoming group info to create the portal automatically
err = portal.CreateMatrixRoom(r.Context(), user, info, nil, true, true)
if err != nil {
jsonResponse(w, http.StatusInternalServerError, Error{
Error: fmt.Sprintf("Failed to create portal: %v", err),
})
return
}
statusCode = http.StatusCreated
}
jsonResponse(w, statusCode, PortalInfo{
RoomID: portal.MXID,
GroupInfo: info,
JustCreated: statusCode == http.StatusCreated,
})
}
}
func (prov *ProvisioningAPI) Ping(w http.ResponseWriter, r *http.Request) {
user := r.Context().Value("user").(*User)
wa := map[string]interface{}{
"has_session": user.Session != nil,
"management_room": user.ManagementRoom,
"conn": nil,
}
if !user.JID.IsEmpty() {
wa["jid"] = user.JID.String()
wa["phone"] = "+" + user.JID.User
wa["device"] = user.JID.Device
if user.Session != nil {
wa["platform"] = user.Session.Platform
}
}
if user.Client != nil {
wa["conn"] = map[string]interface{}{
"is_connected": user.Client.IsConnected(),
"is_logged_in": user.Client.IsLoggedIn(),
}
}
resp := map[string]interface{}{
"mxid": user.MXID,
"admin": user.Admin,
"whitelisted": user.Whitelisted,
"relay_whitelisted": user.RelayWhitelisted,
"whatsapp": wa,
}
jsonResponse(w, http.StatusOK, resp)
}
func jsonResponse(w http.ResponseWriter, status int, response interface{}) {
w.Header().Add("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(response)
}
func (prov *ProvisioningAPI) Logout(w http.ResponseWriter, r *http.Request) {
user := r.Context().Value("user").(*User)
if user.Session == nil {
jsonResponse(w, http.StatusOK, Error{
Error: "You're not logged in",
ErrCode: "not logged in",
})
return
}
force := strings.ToLower(r.URL.Query().Get("force")) != "false"
if user.Client == nil {
if !force {
jsonResponse(w, http.StatusNotFound, Error{
Error: "You're not connected",
ErrCode: "not connected",
})
}
} else {
err := user.Client.Logout()
if err != nil {
hlog.FromRequest(r).Err(err).Msg("Unknown error while logging out")
if !force {
jsonResponse(w, http.StatusInternalServerError, Error{
Error: fmt.Sprintf("Unknown error while logging out: %v", err),
ErrCode: err.Error(),
})
return
}
} else {
user.Session = nil
}
user.DeleteConnection()
}
user.bridge.Metrics.TrackConnectionState(user.JID, false)
user.removeFromJIDMap(status.BridgeState{StateEvent: status.StateLoggedOut})
user.DeleteSession(r.Context())
jsonResponse(w, http.StatusOK, Response{true, "Logged out successfully."})
}
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true
},
Subprotocols: []string{"net.maunium.whatsapp.login"},
}
var notNumbers = regexp.MustCompile("[^0-9]")
func (prov *ProvisioningAPI) Login(w http.ResponseWriter, r *http.Request) {
userID := r.URL.Query().Get("user_id")
user := prov.bridge.GetUserByMXID(id.UserID(userID))
log := hlog.FromRequest(r)
c, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Err(err).Msg("Failed to upgrade connection to websocket")
return
}
defer func() {
err := c.Close()
if err != nil {
log.Debug().Err(err).Msg("Error closing websocket")
}
}()
go func() {
// Read everything so SetCloseHandler() works
for {
_, _, err = c.ReadMessage()
if err != nil {
break
}
}
}()
ctx, cancel := context.WithCancel(context.Background())
c.SetCloseHandler(func(code int, text string) error {
log.Debug().Int("close_code", code).Msg("Login websocket closed, cancelling login")
cancel()
return nil
})
if userTimezone := r.URL.Query().Get("tz"); userTimezone != "" {
log.Debug().Str("timezone", userTimezone).Msg("Updating user timezone")
user.Timezone = userTimezone
err = user.Update(r.Context())
if err != nil {
log.Err(err).Msg("Failed to save user after updating timezone")
}
} else {
log.Debug().Msg("No timezone provided in request")
}
qrChan, qrReceivedChan, err := user.Login(ctx)
expiryTime := time.Now().Add(160 * time.Second)
if err != nil {
log.Err(err).Msg("Failed to log in via provisioning API")
if errors.Is(err, ErrAlreadyLoggedIn) {
go user.Connect()
_ = c.WriteJSON(Error{
Error: "You're already logged into WhatsApp",
ErrCode: "already logged in",
})
} else {
_ = c.WriteJSON(Error{
Error: "Failed to connect to WhatsApp",
ErrCode: "connection error",
})
}
return
}
phoneNum := r.URL.Query().Get("phone_number")
if phoneNum != "" {
rawPhone := phoneNum
phoneNum = notNumbers.ReplaceAllString(phoneNum, "")
if len(phoneNum) < 7 || strings.HasPrefix(phoneNum, "0") {
log.Warn().Str("phone", rawPhone).Msg("Invalid phone number in login request")
Analytics.Track(user.MXID, "$login_failure", map[string]any{
"error": "invalid phone number",
"phone": rawPhone,
})
errorMsg := "Invalid phone number"
if len(phoneNum) > 6 {
errorMsg = "Please enter the phone number in international format"
}
_ = c.WriteJSON(Error{
Error: errorMsg,
ErrCode: "invalid phone number",
})
go user.DeleteConnection()
return
}
select {
case <-qrReceivedChan:
case <-time.After(5 * time.Second):
log.Warn().Msg("Didn't receive QR event within 5 seconds of starting login")
}
pairingCode, err := user.Client.PairPhone(phoneNum, true, whatsmeow.PairClientChrome, "Chrome (Linux)")
if err != nil {
log.Err(err).Msg("Failed to start phone code login")
Analytics.Track(user.MXID, "$login_failure", map[string]any{
"error": "phone code start fail",
"go_error": err.Error(),
})
_ = c.WriteJSON(Error{
Error: "Failed to request pairing code",
ErrCode: "code error",
})
go user.DeleteConnection()
return
} else {
log.Debug().Msg("Started phone number login")
_ = c.WriteJSON(map[string]any{
"pairing_code": pairingCode,
"timeout": int(time.Until(expiryTime).Seconds()),
})
}
}
log.Debug().Msg("Started login via provisioning API")
Analytics.Track(user.MXID, "$login_start")
for {
select {
case evt := <-qrChan:
switch evt.Event {
case whatsmeow.QRChannelSuccess.Event:
jid := user.Client.Store.ID
log.Debug().Stringer("jid", jid).Msg("Successful login via provisioning API")
Analytics.Track(user.MXID, "$login_success")
_ = c.WriteJSON(map[string]interface{}{
"success": true,
"jid": jid,
"phone": fmt.Sprintf("+%s", jid.User),
"platform": user.Client.Store.Platform,
})
case whatsmeow.QRChannelTimeout.Event:
log.Debug().Msg("Login via provisioning API timed out")
errCode := "login timed out"
Analytics.Track(user.MXID, "$login_failure", map[string]interface{}{"error": errCode})
_ = c.WriteJSON(Error{
Error: "QR code scan timed out. Please try again.",
ErrCode: errCode,
})
case whatsmeow.QRChannelErrUnexpectedEvent.Event:
log.Debug().Msg("Login via provisioning API failed due to unexpected event")
errCode := "unexpected event"
Analytics.Track(user.MXID, "$login_failure", map[string]interface{}{"error": errCode})
_ = c.WriteJSON(Error{
Error: "Got unexpected event while waiting for QRs, perhaps you're already logged in?",
ErrCode: errCode,
})
case whatsmeow.QRChannelClientOutdated.Event:
log.Debug().Msg("Login via provisioning API failed due to outdated client")
errCode := "bridge outdated"
Analytics.Track(user.MXID, "$login_failure", map[string]interface{}{"error": errCode})
_ = c.WriteJSON(Error{
Error: "Got client outdated error while waiting for QRs. The bridge must be updated to continue.",
ErrCode: errCode,
})
case whatsmeow.QRChannelScannedWithoutMultidevice.Event:
errCode := "multidevice not enabled"
Analytics.Track(user.MXID, "$login_failure", map[string]interface{}{"error": errCode})
_ = c.WriteJSON(Error{
Error: "Please enable the WhatsApp multidevice beta and scan the QR code again.",
ErrCode: errCode,
})
continue
case "error":
errCode := "fatal error"
Analytics.Track(user.MXID, "$login_failure", map[string]interface{}{"error": errCode})
_ = c.WriteJSON(Error{
Error: "Fatal error while logging in",
ErrCode: errCode,
})
case "code":
Analytics.Track(user.MXID, "$qrcode_retrieved")
_ = c.WriteJSON(map[string]interface{}{
"code": evt.Code,
"timeout": int(evt.Timeout.Seconds()),
})
continue
}
return
case <-ctx.Done():
return
}
}
}

422
puppet.go
View file

@ -1,422 +0,0 @@
// mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge.
// Copyright (C) 2021 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 <https://www.gnu.org/licenses/>.
package main
import (
"context"
"fmt"
"regexp"
"sync"
"time"
"github.com/rs/zerolog"
"go.mau.fi/whatsmeow/types"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/appservice"
"maunium.net/go/mautrix/bridge"
"maunium.net/go/mautrix/id"
"maunium.net/go/mautrix-whatsapp/config"
"maunium.net/go/mautrix-whatsapp/database"
)
var userIDRegex *regexp.Regexp
func (br *WABridge) ParsePuppetMXID(mxid id.UserID) (jid types.JID, ok bool) {
if userIDRegex == nil {
userIDRegex = br.Config.MakeUserIDRegex("([0-9]+)")
}
match := userIDRegex.FindStringSubmatch(string(mxid))
if len(match) == 2 {
jid = types.NewJID(match[1], types.DefaultUserServer)
ok = true
}
return
}
func (br *WABridge) GetPuppetByMXID(mxid id.UserID) *Puppet {
jid, ok := br.ParsePuppetMXID(mxid)
if !ok {
return nil
}
return br.GetPuppetByJID(jid)
}
func (br *WABridge) GetPuppetByJID(jid types.JID) *Puppet {
ctx := context.TODO()
jid = jid.ToNonAD()
if jid.Server == types.LegacyUserServer {
jid.Server = types.DefaultUserServer
} else if jid.Server != types.DefaultUserServer {
return nil
}
br.puppetsLock.Lock()
defer br.puppetsLock.Unlock()
puppet, ok := br.puppets[jid]
if !ok {
dbPuppet, err := br.DB.Puppet.Get(ctx, jid)
if err != nil {
br.ZLog.Err(err).Stringer("jid", jid).Msg("Failed to get puppet from database")
return nil
}
if dbPuppet == nil {
dbPuppet = br.DB.Puppet.New()
dbPuppet.JID = jid
err = dbPuppet.Insert(ctx)
if err != nil {
br.ZLog.Err(err).Stringer("jid", jid).Msg("Failed to insert new puppet to database")
return nil
}
}
puppet = br.NewPuppet(dbPuppet)
br.puppets[puppet.JID] = puppet
if len(puppet.CustomMXID) > 0 {
br.puppetsByCustomMXID[puppet.CustomMXID] = puppet
}
}
return puppet
}
func (br *WABridge) 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).Stringer("mxid", mxid).Msg("Failed to get puppet by custom mxid from database")
}
if dbPuppet == nil {
return nil
}
puppet = br.NewPuppet(dbPuppet)
br.puppets[puppet.JID] = puppet
br.puppetsByCustomMXID[puppet.CustomMXID] = puppet
}
return puppet
}
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 {
if user.JID.IsEmpty() {
return nil
}
p := user.bridge.GetPuppetByJID(user.JID)
if p == nil {
return nil
}
return p
}
func (br *WABridge) IsGhost(id id.UserID) bool {
_, ok := br.ParsePuppetMXID(id)
return ok
}
func (br *WABridge) GetIGhost(id id.UserID) bridge.Ghost {
p := br.GetPuppetByMXID(id)
if p == nil {
return nil
}
return p
}
func (puppet *Puppet) GetMXID() id.UserID {
return puppet.MXID
}
func (br *WABridge) GetAllPuppetsWithCustomMXID() []*Puppet {
return br.dbPuppetsToPuppets(br.DB.Puppet.GetAllWithCustomMXID(context.TODO()))
}
func (br *WABridge) GetAllPuppets() []*Puppet {
return br.dbPuppetsToPuppets(br.DB.Puppet.GetAll(context.TODO()))
}
func (br *WABridge) dbPuppetsToPuppets(dbPuppets []*database.Puppet, err error) []*Puppet {
if err != nil {
br.ZLog.Err(err).Msg("Error getting puppets from database")
return nil
}
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.JID]
if !ok {
puppet = br.NewPuppet(dbPuppet)
br.puppets[dbPuppet.JID] = puppet
if len(dbPuppet.CustomMXID) > 0 {
br.puppetsByCustomMXID[dbPuppet.CustomMXID] = puppet
}
}
output[index] = puppet
}
return output
}
func (br *WABridge) FormatPuppetMXID(jid types.JID) id.UserID {
return id.NewUserID(
br.Config.Bridge.FormatUsername(jid.User),
br.Config.Homeserver.Domain)
}
func (br *WABridge) NewPuppet(dbPuppet *database.Puppet) *Puppet {
return &Puppet{
Puppet: dbPuppet,
bridge: br,
zlog: br.ZLog.With().Stringer("puppet_jid", dbPuppet.JID).Logger(),
MXID: br.FormatPuppetMXID(dbPuppet.JID),
}
}
type Puppet struct {
*database.Puppet
bridge *WABridge
zlog zerolog.Logger
typingIn id.RoomID
typingAt time.Time
MXID id.UserID
customIntent *appservice.IntentAPI
customUser *User
syncLock sync.Mutex
}
var _ bridge.GhostWithProfile = (*Puppet)(nil)
func (puppet *Puppet) GetDisplayname() string {
return puppet.Displayname
}
func (puppet *Puppet) GetAvatarURL() id.ContentURI {
return puppet.AvatarURL
}
func (puppet *Puppet) IntentFor(portal *Portal) *appservice.IntentAPI {
if puppet.customIntent == nil || portal.Key.JID == puppet.JID || (portal.Key.JID.Server == types.BroadcastServer && portal.Key.Receiver != puppet.JID) {
return puppet.DefaultIntent()
}
return puppet.customIntent
}
func (puppet *Puppet) CustomIntent() *appservice.IntentAPI {
return puppet.customIntent
}
func (puppet *Puppet) DefaultIntent() *appservice.IntentAPI {
return puppet.bridge.AS.Intent(puppet.MXID)
}
func (puppet *Puppet) UpdateAvatar(ctx context.Context, source *User, forcePortalSync bool) bool {
changed := source.updateAvatar(ctx, puppet.JID, false, &puppet.Avatar, &puppet.AvatarURL, &puppet.AvatarSet, puppet.DefaultIntent())
if !changed || puppet.Avatar == "unauthorized" {
if forcePortalSync {
go puppet.updatePortalAvatar(ctx)
}
return changed
}
err := puppet.DefaultIntent().SetAvatarURL(ctx, puppet.AvatarURL)
if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to set avatar from puppet")
} else {
puppet.AvatarSet = true
}
go puppet.updatePortalAvatar(ctx)
return true
}
func (puppet *Puppet) UpdateName(ctx context.Context, contact types.ContactInfo, forcePortalSync bool) bool {
newName, quality := puppet.bridge.Config.Bridge.FormatDisplayname(puppet.JID, contact)
if (puppet.Displayname != newName || !puppet.NameSet) && quality >= puppet.NameQuality {
oldName := puppet.Displayname
puppet.Displayname = newName
puppet.NameQuality = quality
puppet.NameSet = false
err := puppet.DefaultIntent().SetDisplayName(ctx, newName)
if err == nil {
puppet.zlog.Debug().Str("old_name", oldName).Str("new_name", newName).Msg("Updated name")
puppet.NameSet = true
go puppet.updatePortalName(ctx)
} else {
puppet.zlog.Err(err).Msg("Failed to set displayname")
}
return true
} else if forcePortalSync {
go puppet.updatePortalName(ctx)
}
return false
}
func (puppet *Puppet) UpdateContactInfo(ctx context.Context) bool {
if !puppet.bridge.SpecVersions.Supports(mautrix.BeeperFeatureArbitraryProfileMeta) {
return false
}
if puppet.ContactInfoSet {
return false
}
contactInfo := map[string]any{
"com.beeper.bridge.identifiers": []string{
fmt.Sprintf("tel:+%s", puppet.JID.User),
fmt.Sprintf("whatsapp:%s", puppet.JID.String()),
},
"com.beeper.bridge.remote_id": puppet.JID.String(),
"com.beeper.bridge.service": "whatsapp",
"com.beeper.bridge.network": "whatsapp",
}
err := puppet.DefaultIntent().BeeperUpdateProfile(ctx, contactInfo)
if err != nil {
puppet.zlog.Err(err).Msg("Failed to store custom contact info in profile")
return false
} else {
puppet.ContactInfoSet = true
return true
}
}
func (puppet *Puppet) updatePortalMeta(meta func(portal *Portal)) {
for _, portal := range puppet.bridge.GetAllPortalsByJID(puppet.JID) {
// Get room create lock to prevent races between receiving contact info and room creation.
portal.roomCreateLock.Lock()
meta(portal)
portal.roomCreateLock.Unlock()
}
}
func (puppet *Puppet) updatePortalAvatar(ctx context.Context) {
puppet.updatePortalMeta(func(portal *Portal) {
if portal.Avatar == puppet.Avatar && portal.AvatarURL == puppet.AvatarURL && (portal.AvatarSet || !portal.shouldSetDMRoomMetadata()) {
return
}
portal.AvatarURL = puppet.AvatarURL
portal.Avatar = puppet.Avatar
portal.AvatarSet = false
if len(portal.MXID) > 0 && !portal.shouldSetDMRoomMetadata() {
portal.UpdateBridgeInfo(ctx)
} else if len(portal.MXID) > 0 {
_, err := portal.MainIntent().SetRoomAvatar(ctx, portal.MXID, puppet.AvatarURL)
if err != nil {
portal.zlog.Err(err).Msg("Failed to set avatar from puppet")
} else {
portal.AvatarSet = true
portal.UpdateBridgeInfo(ctx)
}
}
err := portal.Update(ctx)
if err != nil {
portal.zlog.Err(err).Msg("Failed to save portal after updating avatar from puppet")
}
})
}
func (puppet *Puppet) updatePortalName(ctx context.Context) {
puppet.updatePortalMeta(func(portal *Portal) {
portal.UpdateName(ctx, puppet.Displayname, types.EmptyJID, true)
})
}
func (puppet *Puppet) SyncContact(ctx context.Context, source *User, onlyIfNoName, shouldHavePushName bool, reason string) {
if puppet == nil {
return
}
if onlyIfNoName && len(puppet.Displayname) > 0 && (!shouldHavePushName || puppet.NameQuality > config.NameQualityPhone) {
source.EnqueuePuppetResync(puppet)
return
}
log := zerolog.Ctx(ctx).With().
Str("method", "Puppet.SyncContact").
Stringer("puppet_jid", puppet.JID).
Stringer("source_user_jid", source.JID).
Stringer("source_user_mxid", source.MXID).
Logger()
ctx = log.WithContext(ctx)
contact, err := source.Client.Store.Contacts.GetContact(puppet.JID)
if err != nil {
log.Err(err).
Stringer("source_mxid", source.MXID).
Str("sync_reason", reason).
Msg("Failed to get contact info through user in SyncContact")
} else if !contact.Found {
log.Warn().
Stringer("source_mxid", source.MXID).
Str("sync_reason", reason).
Msg("No contact info found through user in SyncContact")
}
puppet.syncInternal(ctx, source, &contact, false, false)
}
func (puppet *Puppet) Sync(ctx context.Context, source *User, contact *types.ContactInfo, forceAvatarSync, forcePortalSync bool) {
log := zerolog.Ctx(ctx).With().
Str("method", "Puppet.Sync").
Stringer("puppet_jid", puppet.JID).
Stringer("source_user_jid", source.JID).
Stringer("source_user_mxid", source.MXID).
Logger()
ctx = log.WithContext(ctx)
puppet.syncInternal(ctx, source, contact, forceAvatarSync, forcePortalSync)
}
func (puppet *Puppet) syncInternal(ctx context.Context, source *User, contact *types.ContactInfo, forceAvatarSync, forcePortalSync bool) {
log := zerolog.Ctx(ctx)
puppet.syncLock.Lock()
defer puppet.syncLock.Unlock()
err := puppet.DefaultIntent().EnsureRegistered(ctx)
if err != nil {
log.Err(err).Msg("Failed to ensure registered")
}
log.Debug().Stringer("source_jid", source.JID).Msg("Syncing info through user")
update := false
if contact != nil {
if puppet.JID.User == source.JID.User {
contact.PushName = source.Client.Store.PushName
}
update = puppet.UpdateName(ctx, *contact, forcePortalSync) || update
}
if len(puppet.Avatar) == 0 || forceAvatarSync || puppet.bridge.Config.Bridge.UserAvatarSync {
update = puppet.UpdateAvatar(ctx, source, forcePortalSync) || update
}
update = puppet.UpdateContactInfo(ctx) || update
if update || puppet.LastSync.Add(24*time.Hour).Before(time.Now()) {
puppet.LastSync = time.Now()
err = puppet.Update(ctx)
if err != nil {
log.Err(err).Msg("Failed to save puppet after sync")
}
}
}

View file

@ -1,195 +0,0 @@
// mautrix-whatsapp - A Matrix-WhatsApp 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 <https://www.gnu.org/licenses/>.
package main
import (
"bytes"
"context"
"image"
"net/http"
"net/url"
"regexp"
"strings"
"time"
"github.com/rs/zerolog"
"golang.org/x/net/idna"
"google.golang.org/protobuf/proto"
"go.mau.fi/whatsmeow"
waProto "go.mau.fi/whatsmeow/binary/proto"
"maunium.net/go/mautrix/appservice"
"maunium.net/go/mautrix/crypto/attachment"
"maunium.net/go/mautrix/event"
)
func (portal *Portal) convertURLPreviewToBeeper(ctx context.Context, intent *appservice.IntentAPI, source *User, msg *waProto.ExtendedTextMessage) []*event.BeeperLinkPreview {
if msg.GetMatchedText() == "" {
return []*event.BeeperLinkPreview{}
}
output := &event.BeeperLinkPreview{
MatchedURL: msg.GetMatchedText(),
LinkPreview: event.LinkPreview{
CanonicalURL: msg.GetCanonicalURL(),
Title: msg.GetTitle(),
Description: msg.GetDescription(),
},
}
if len(output.CanonicalURL) == 0 {
output.CanonicalURL = output.MatchedURL
}
var thumbnailData []byte
if msg.ThumbnailDirectPath != nil {
var err error
thumbnailData, err = source.Client.DownloadThumbnail(msg)
if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to download thumbnail for link preview")
}
}
if thumbnailData == nil && msg.JPEGThumbnail != nil {
thumbnailData = msg.JPEGThumbnail
}
if thumbnailData != nil {
output.ImageHeight = int(msg.GetThumbnailHeight())
output.ImageWidth = int(msg.GetThumbnailWidth())
if output.ImageHeight == 0 || output.ImageWidth == 0 {
src, _, err := image.Decode(bytes.NewReader(thumbnailData))
if err == nil {
imageBounds := src.Bounds()
output.ImageWidth, output.ImageHeight = imageBounds.Max.X, imageBounds.Max.Y
}
}
output.ImageSize = len(thumbnailData)
output.ImageType = http.DetectContentType(thumbnailData)
uploadData, uploadMime := thumbnailData, output.ImageType
if portal.Encrypted {
crypto := attachment.NewEncryptedFile()
crypto.EncryptInPlace(uploadData)
uploadMime = "application/octet-stream"
output.ImageEncryption = &event.EncryptedFileInfo{EncryptedFile: *crypto}
}
resp, err := intent.UploadBytes(ctx, uploadData, uploadMime)
if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to reupload thumbnail for link preview")
} else {
if output.ImageEncryption != nil {
output.ImageEncryption.URL = resp.ContentURI.CUString()
} else {
output.ImageURL = resp.ContentURI.CUString()
}
}
}
if msg.GetPreviewType() == waProto.ExtendedTextMessage_VIDEO {
output.Type = "video.other"
}
return []*event.BeeperLinkPreview{output}
}
var URLRegex = regexp.MustCompile(`https?://[^\s/_*]+(?:/\S*)?`)
func (portal *Portal) convertURLPreviewToWhatsApp(ctx context.Context, sender *User, content *event.MessageEventContent, dest *waProto.ExtendedTextMessage) bool {
log := zerolog.Ctx(ctx)
var preview *event.BeeperLinkPreview
if content.BeeperLinkPreviews != nil {
// Note: this check explicitly happens after checking for nil: empty arrays are treated as no previews,
// but omitting the field means the bridge may look for URLs in the message text.
if len(content.BeeperLinkPreviews) == 0 {
return false
}
// WhatsApp only supports a single preview.
preview = content.BeeperLinkPreviews[0]
} else if portal.bridge.Config.Bridge.URLPreviews {
if matchedURL := URLRegex.FindString(content.Body); len(matchedURL) == 0 {
return false
} else if parsed, err := url.Parse(matchedURL); err != nil {
return false
} else if parsed.Host, err = idna.ToASCII(parsed.Host); err != nil {
return false
} else if mxPreview, err := portal.MainIntent().GetURLPreview(ctx, parsed.String()); err != nil {
log.Err(err).Str("url", matchedURL).Msg("Failed to fetch URL preview")
return false
} else {
preview = &event.BeeperLinkPreview{
LinkPreview: *mxPreview,
MatchedURL: matchedURL,
}
}
}
if preview == nil || len(preview.MatchedURL) == 0 {
return false
}
dest.MatchedText = &preview.MatchedURL
if len(preview.CanonicalURL) > 0 {
dest.CanonicalURL = &preview.CanonicalURL
}
if len(preview.Description) > 0 {
dest.Description = &preview.Description
}
if len(preview.Title) > 0 {
dest.Title = &preview.Title
}
if strings.HasPrefix(preview.Type, "video.") {
dest.PreviewType = waProto.ExtendedTextMessage_VIDEO.Enum()
}
imageMXC := preview.ImageURL.ParseOrIgnore()
if preview.ImageEncryption != nil {
imageMXC = preview.ImageEncryption.URL.ParseOrIgnore()
}
if !imageMXC.IsEmpty() {
data, err := portal.MainIntent().DownloadBytes(ctx, imageMXC)
if err != nil {
log.Err(err).Str("image_url", string(preview.ImageURL)).Msg("Failed to download URL preview image")
return true
}
if preview.ImageEncryption != nil {
err = preview.ImageEncryption.DecryptInPlace(data)
if err != nil {
log.Err(err).Msg("Failed to decrypt URL preview image")
return true
}
}
dest.MediaKeyTimestamp = proto.Int64(time.Now().Unix())
uploadResp, err := sender.Client.Upload(ctx, data, whatsmeow.MediaLinkThumbnail)
if err != nil {
log.Err(err).Msg("Failed to reupload URL preview thumbnail")
return true
}
dest.ThumbnailSHA256 = uploadResp.FileSHA256
dest.ThumbnailEncSHA256 = uploadResp.FileEncSHA256
dest.ThumbnailDirectPath = &uploadResp.DirectPath
dest.MediaKey = uploadResp.MediaKey
var width, height int
dest.JPEGThumbnail, width, height, err = createThumbnailAndGetSize(data, false)
if err != nil {
log.Err(err).Msg("Failed to create JPEG thumbnail for URL preview")
}
if preview.ImageHeight > 0 && preview.ImageWidth > 0 {
dest.ThumbnailWidth = proto.Uint32(uint32(preview.ImageWidth))
dest.ThumbnailHeight = proto.Uint32(uint32(preview.ImageHeight))
} else if width > 0 && height > 0 {
dest.ThumbnailWidth = proto.Uint32(uint32(width))
dest.ThumbnailHeight = proto.Uint32(uint32(height))
}
}
return true
}

1639
user.go

File diff suppressed because it is too large Load diff