mirror of
https://github.com/mautrix/whatsapp.git
synced 2025-03-14 14:15:38 +00:00
all: delete legacy bridge
This commit is contained in:
parent
fc2db39bcb
commit
ea2d8ba07d
73 changed files with 53 additions and 16276 deletions
|
@ -1,6 +1,3 @@
|
|||
include:
|
||||
- project: 'mautrix/ci'
|
||||
file: '/gov2.yml'
|
||||
|
||||
variables:
|
||||
BINARY_NAME_V2: mautrix-whatsapp
|
||||
file: '/gov2-as-default.yml'
|
||||
|
|
|
@ -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
|
||||
|
|
11
CHANGELOG.md
11
CHANGELOG.md
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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"]
|
||||
|
|
|
@ -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
|
|
@ -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"]
|
|
@ -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.
|
||||
|
|
26
ROADMAP.md
26
ROADMAP.md
|
@ -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
|
||||
|
|
96
analytics.go
96
analytics.go
|
@ -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")
|
||||
}
|
||||
}()
|
||||
}
|
136
backfillqueue.go
136
backfillqueue.go
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
101
bridgestate.go
101
bridgestate.go
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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 "$@"
|
4
build.sh
4
build.sh
|
@ -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 "$@"
|
||||
|
|
1248
commands.go
1248
commands.go
File diff suppressed because it is too large
Load diff
337
config/bridge.go
337
config/bridge.go
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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"},
|
||||
}
|
|
@ -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")
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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()...)
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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 },
|
||||
)
|
||||
}
|
|
@ -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)
|
||||
})
|
||||
}
|
|
@ -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()...)
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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
|
||||
);
|
|
@ -1,3 +0,0 @@
|
|||
-- v36: Store approximate last seen timestamp of the main device
|
||||
|
||||
ALTER TABLE "user" ADD COLUMN phone_last_seen BIGINT;
|
|
@ -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;
|
|
@ -1,3 +0,0 @@
|
|||
-- v38: Store timestamp for previous phone ping
|
||||
|
||||
ALTER TABLE "user" ADD COLUMN phone_last_pinged BIGINT;
|
|
@ -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
|
||||
);
|
|
@ -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
|
||||
);
|
|
@ -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
|
||||
);
|
|
@ -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;
|
|
@ -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
|
||||
);
|
|
@ -1,3 +0,0 @@
|
|||
-- v44: Add timezone column for users
|
||||
|
||||
ALTER TABLE "user" ADD COLUMN timezone TEXT;
|
|
@ -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;
|
|
@ -1,3 +0,0 @@
|
|||
-- v46: Add inserted time to history sync message
|
||||
|
||||
ALTER TABLE history_sync_message ADD COLUMN inserted_time TIMESTAMP;
|
|
@ -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
|
||||
);
|
|
@ -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);
|
|
@ -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$
|
|
@ -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<>'';
|
|
@ -1,3 +0,0 @@
|
|||
-- v51: Add last sync timestamp for portals too
|
||||
|
||||
ALTER TABLE portal ADD COLUMN last_sync BIGINT NOT NULL DEFAULT 0;
|
|
@ -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;
|
|
@ -1,2 +0,0 @@
|
|||
-- v53: Add index to make querying by community faster
|
||||
CREATE INDEX portal_parent_group_idx ON portal(parent_group);
|
|
@ -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
|
||||
);
|
|
@ -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;
|
|
@ -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 '';
|
|
@ -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);
|
|
@ -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)
|
||||
}
|
146
database/user.go
146
database/user.go
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
102
disappear.go
102
disappear.go
|
@ -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")
|
||||
}
|
||||
}
|
|
@ -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
|
||||
echo "Didn't find a config file."
|
||||
echo "Copied default config file to /data/config.yaml"
|
||||
echo "Modify that config file to your liking."
|
||||
|
|
|
@ -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
|
208
formatting.go
208
formatting.go
|
@ -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
20
go.mod
|
@ -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
23
go.sum
|
@ -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=
|
||||
|
|
1024
historysync.go
1024
historysync.go
File diff suppressed because it is too large
Load diff
279
main.go
279
main.go
|
@ -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
147
matrix.go
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
320
metrics.go
320
metrics.go
|
@ -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")
|
||||
}
|
||||
}
|
|
@ -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())
|
||||
|
|
808
provisioning.go
808
provisioning.go
|
@ -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
422
puppet.go
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
195
urlpreview.go
195
urlpreview.go
|
@ -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
|
||||
}
|
Loading…
Add table
Reference in a new issue