Watch
1
0
Fork
You've already forked mautrix-whatsapp
0
mirror of https://github.com/mautrix/whatsapp.git synced 2026-11-09 23:59:17 -05:00

Compare commits

..
Author SHA1 Message Date
Nick Mills-Barrett
c29d5632bb
Fetch contact name after login
Not sure this is the correct way to do this.
2025-04-15 12:14:02 +01:00
72 changed files with 1778 additions and 6880 deletions

View file

@ -1 +0,0 @@
code_format_allowlist: ["wa-connect-failure-%d"]

View file

@ -1,18 +1,14 @@
--- ---
name: Bug report name: Bug report
about: If something is definitely wrong in the bridge (rather than just a setup issue), about: If something is definitely wrong in the bridge (rather than just a setup issue),
file a bug report. Remember to include relevant logs. Asking in the Matrix room first file a bug report. Remember to include relevant logs.
is strongly recommended. labels: bug
type: Bug
--- ---
<!-- Include relevant logs, the bridge version and other important details here --> <!--
Remember to include relevant logs, the bridge version and any other details.
### Checklist If you aren't sure what's needed, ask in the Matrix room rather than opening an
incomplete issue. Issues with insufficient detail will likely just be ignored.
<!-- All items below are mandatory. Issues not following the rules may be closed without comment. --> -->
* [ ] This is an actual bug, not just a setup issue (see the [troubleshooting docs](https://docs.mau.fi/bridges/general/troubleshooting.html) or ask in the Matrix room for setup help).
* [ ] I am certain that sufficient information is included. Ask in the Matrix room first if not.
* [ ] The bug is still present on the main branch. The `!wa version` command output is: ``

View file

@ -1,6 +1,6 @@
--- ---
name: Enhancement request name: Enhancement request
about: Submit a feature request or other suggestion about: Submit a feature request or other suggestion
type: Feature labels: enhancement
--- ---

View file

@ -11,14 +11,14 @@ jobs:
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
go-version: ["1.26", "1.27"] go-version: ["1.23", "1.24"]
name: Lint ${{ matrix.go-version == '1.27' && '(latest)' || '(old)' }} name: Lint ${{ matrix.go-version == '1.24' && '(latest)' || '(old)' }}
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v4
- name: Set up Go - name: Set up Go
uses: actions/setup-go@v7 uses: actions/setup-go@v5
with: with:
go-version: ${{ matrix.go-version }} go-version: ${{ matrix.go-version }}
cache: true cache: true
@ -30,7 +30,6 @@ jobs:
run: | run: |
go install golang.org/x/tools/cmd/goimports@latest go install golang.org/x/tools/cmd/goimports@latest
go install honnef.co/go/tools/cmd/staticcheck@latest go install honnef.co/go/tools/cmd/staticcheck@latest
go install github.com/beeper/pre-commit-go/bridgelint@latest
export PATH="$HOME/go/bin:$PATH" export PATH="$HOME/go/bin:$PATH"
- name: Run pre-commit - name: Run pre-commit

View file

@ -17,7 +17,7 @@ jobs:
lock-stale: lock-stale:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: dessant/lock-threads@v6 - uses: dessant/lock-threads@v5
id: lock id: lock
with: with:
issue-inactive-days: 90 issue-inactive-days: 90

View file

@ -1,6 +1,6 @@
repos: repos:
- repo: https://github.com/pre-commit/pre-commit-hooks - repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0 rev: v5.0.0
hooks: hooks:
- id: trailing-whitespace - id: trailing-whitespace
exclude_types: [markdown] exclude_types: [markdown]
@ -9,7 +9,7 @@ repos:
- id: check-added-large-files - id: check-added-large-files
- repo: https://github.com/tekwizely/pre-commit-golang - repo: https://github.com/tekwizely/pre-commit-golang
rev: v1.0.0-rc.4 rev: v1.0.0-rc.1
hooks: hooks:
- id: go-imports-repo - id: go-imports-repo
args: args:
@ -21,12 +21,9 @@ repos:
- id: go-mod-tidy - id: go-mod-tidy
- repo: https://github.com/beeper/pre-commit-go - repo: https://github.com/beeper/pre-commit-go
rev: v0.5.0 rev: v0.4.2
hooks: hooks:
- id: zerolog-ban-msgf - id: zerolog-ban-msgf
- id: zerolog-use-stringer - id: zerolog-use-stringer
- id: prevent-literal-http-methods - id: prevent-literal-http-methods
- id: zerolog-ban-global-log - id: zerolog-ban-global-log
- id: bridgelint
language: system
verbose: true

View file

@ -1,218 +1,3 @@
# v26.10
* Added support for new rich response messages used by the Muse AI bot.
* Added support for bridging custom profile set for Muse.
* Added automatic one-time resync of keys needed to decrypt Muse messages.
* Added support for blocking and unblocking users on WhatsApp.
* Added support for contact array messages (thanks to [@voska] in [#961]).
* Added partial support for filling quoted message when sending replies to
WhatsApp.
* Changed undecryptable message resends to only bridge as edits and never as new
messages, to avoid races if the message was deleted before the resend.
* Changed login to wait for OTKs to be successfully uploaded, as the login will
fail if the bridge is stopped before that point.
* Changed chat info syncing to not bridge mute status if app state sync is
failing.
* Changed power levels to allow poll votes in read-only groups even if the room
is encrypted.
* Fixed backfilled media retries not getting sent if configured for local time.
* Fixed "delete for me" from WhatsApp not being bridged correctly in DMs.
* Fixed very long WhatsApp messages not being bridged.
* Fixed document message caption edits not being handled correctly.
[@voska]: https://github.com/voska
[#961]: https://github.com/mautrix/whatsapp/pull/961
# v26.09
* Bumped minimum Go version to 1.26.
* Added option to limit eager member sync in group chat portals.
* Added option to fetch avatars lazily when using direct media.
* Added one-time migration to ensure LID ghost avatars are consistent with the
old phone number ghosts.
* Fixed the resolve identifier command and API not returning any user info for
users who hadn't previously been encountered.
* Fixed group portal power levels to always allow poll responses.
* Fixed handling history sync events where the phone sends nonsensical
timestamps.
* Fixed handling edits to HD media captions.
* Fixed handling bridging own read receipts from the native apps in channels.
* Fixed decrypting messages from the new Muse AI bot
* May require resyncing app state using `!wa sync appstate regular_high`.
* The message contents aren't supported yet.
# v26.08
* Switched direct chats to use LIDs instead of phone numbers.
# v26.07
* Updated Docker image to Alpine 3.24.
* Added support for the extra passkey authentication step when logging in.
* Added support for newer versions of poll messages from WhatsApp.
* Fixed poll votes in DMs.
# v26.06
* Added placeholder for group message history share notices.
* Fixed community spaces not being bridged properly in some cases.
* Fixed handling edits of HD media.
* Fixed duplicate message when a message from another own device is initially
undecryptable and later gets resolved.
# v26.05
* Added support for importing sticker packs from WhatsApp.
* Added support for WhatsApp's new message edit encryption scheme.
# v26.04
* Added support for @room mentions in both directions.
* Changed initial backfill to happen even if WhatsApp doesn't send full history.
* Fixed panic when handling updates to unknown polls from WhatsApp.
* Fixed some background loops not stopping when a user is logged out.
# v26.03
* Added option to save outgoing messages in the database to allow encryption
retries to work across restarts.
* Fixed contact list API not returning some contacts.
* Fixed business template messages with media duplicating the text part.
# v26.02
* Bumped minimum Go version to 1.25.
* Added automatic recovery for WhatsApp app state sync issues.
* Fixed LID redirects for some non-message events.
# v26.01
* Fixed broadcast list messages to LIDs causing split DMs.
* Fixed read receipts not working correctly in LID DMs.
* Fixed backfill sometimes racing with receiving LID mappings.
# v25.12
* Updated Docker image to Alpine 3.23.
* Fixed group member invites from Matrix not automatically disinviting the phone
number ghost when the invite is redirected to a LID ghost.
# v25.11
* Added interface support for notifying about failed invites when creating a
group and sending the invites via DM (only applicable to provisioning API).
* Added migration to automatically delete duplicate LID DM portals that were
created earlier.
* Changed contact list API to only include actual phone contacts.
* Removed extra unrecognized message notice when receiving live photos
(bridging the live photo video is not currently planned).
* Fixed pairing not working with latest WhatsApp Android version.
* Fixed replies, read receipts and typing notifications not being bridged
correctly after DM LID migration.
* Fixed backfill creating duplicate portals if history sync contains both LID
and phone number DM data.
* Fixed some cases of LID and phone number user infos getting out of sync.
* Fixed muting chat forever not being bridged correctly from WhatsApp.
* Fixed old mutes being re-applied on chat resync in some cases.
* Fixed backfilling failing if some reactions were missing sender info.
* Fixed space not being deleted when leaving community on WhatsApp.
* Fixed sticker size metadata on Matrix not matching how native WhatsApp Web
renders them.
* Fixed ratelimit errors in login not being exposed to the user properly
(thanks to [@dead8309] in [#852]).
[@dead8309]: https://github.com/dead8309
[#852]: https://github.com/mautrix/whatsapp/pull/852
# v25.10
* Switched to calendar versioning.
* Added support for bridging event edits.
* Fixed backfill creating incorrect disappearing timer change notices.
* Fixed previous messages not being marked as read when sending a new message.
* Fixed incoming call notices with LID addressing going into different DM room.
# v0.12.5 (2025-09-16)
* Removed legacy provisioning API and database legacy migration.
Upgrading directly from versions prior to v0.11.0 is not supported.
* If you've been using the bridge since before v0.11.0 and have prevented the
bridge from writing to the config, you must either update the config
manually or allow the bridge to update it for you **before** upgrading to
this release (i.e. run v0.12.4 once with config writing allowed).
* Added support for changing group name/topic/avatar from Matrix
(thanks to [@Petersmit27] in [#834]).
* Added `RedactedPhone` placeholder for displayname templates. This allows
community announcement groups (where you can't see participants phone numbers)
to have better names than random numbers.
* Added support for `com.beeper.disappearing_timer` state event, which stores
the disappearing setting of chats and allows changing the setting from Matrix.
* Added lottieconverter to Docker images to enable converting animated stickers
from WhatsApp.
* Added support for creating WhatsApp groups.
* Fixed sent PNGs not being rendered on WhatsApp iOS.
[@Petersmit27]: https://github.com/Petersmit27
[#834]: https://github.com/mautrix/whatsapp/pull/834
# v0.12.4 (2025-08-16)
* Deprecated legacy provisioning API. The `/_matrix/provision/v1` endpoints will
be deleted in the next release.
* Bumped minimum Go version to 1.24.
* Added support for bridging HD dual uploads from WhatsApp into edits on Matrix.
* Added better placeholders for pin and keep messages from WhatsApp.
* Fixed bridging animated webp stickers to WhatsApp.
* Note that non-square stickers may appear corrupted on native clients.
The bridge will not automatically add padding to animated stickers like it
does for static ones.
* Fixed avatar changes not reflecting on both the LID and phone number ghost of
a given user in certain cases.
* Fixed first message after group LID migration still using the phone number
ghost.
* Fixed bot messages in DMs being split into another portal room.
* Fixed new group members not having a phone number name in some cases.
# v0.12.3 (2025-07-16)
* Further improved support for `@lid` users.
* Added automatic conversion when sending quicktime/mov videos to WhatsApp.
* Fixed disappearing message timer not automatically fixing itself in some cases.
* Fixed call notices being sent to DM portal even if the call was in a group.
# v0.12.2 (2025-06-16)
* Improved support for `@lid` users.
* **N.B.** As mentioned in the v0.12.0 release, old registration files may
have `[0-9]+` in the `users` regex. You must change it to `.+`, as the new
`lid` identifiers are bridged as `lid-<number>` instead of just `<phone number>`.
* Updated Docker image to Alpine 3.22.
* Fixed network errors on first connect not triggering automatic reconnect.
* Fixed animated sticker zips not being extracted when using direct media.
# v0.12.1 (2025-05-16)
* Added prefix to identify forwarded messages on WhatsApp.
* Updated mime type of unconverted animated stickers to `video/lottie+json`
which is now registered with IANA.
* Changed relogin command to not require entering phone number twice when using
phone code login.
* Fixed outgoing messages being rejected if they replied to a fake message
generated by the bridge.
* Fixed backfilling messages in existing portals after relogining.
# v0.12.0 (2025-04-16)
* Migrated Signal session store to use new `@lid` identifiers to support future
chats that don't expose phone numbers.
* **N.B.** Old registration files may have `[0-9]+` in the `users` regex. You
must change it to `.+`, as the new `lid` identifiers are bridged as
`lid-<number>` instead of just `<phone number>`.
* Added fallbacks for various business message types.
* Added support for bridging invites, kicks and leaves in groups.
* Re-added `invite-link`, `join` and `sync` commands for groups.
* Fixed bridging chats with Meta AI.
# v0.11.4 (2025-03-16) # v0.11.4 (2025-03-16)
* Fixed edits being bridged multiple times if a single chat had multiple * Fixed edits being bridged multiple times if a single chat had multiple

View file

@ -1 +0,0 @@
See <https://docs.mau.fi/bridges/general/contributing.html>

View file

@ -1,4 +1,4 @@
FROM golang:1-alpine3.24 AS builder FROM golang:1-alpine3.21 AS builder
RUN apk add --no-cache git ca-certificates build-base su-exec olm-dev RUN apk add --no-cache git ca-certificates build-base su-exec olm-dev
@ -6,12 +6,12 @@ COPY . /build
WORKDIR /build WORKDIR /build
RUN ./build.sh RUN ./build.sh
FROM alpine:3.24 FROM alpine:3.21
ENV UID=1337 \ ENV UID=1337 \
GID=1337 GID=1337
RUN apk add --no-cache ffmpeg su-exec ca-certificates olm bash jq curl yq-go lottieconverter RUN apk add --no-cache ffmpeg su-exec ca-certificates olm bash jq yq curl
COPY --from=builder /build/mautrix-whatsapp /usr/bin/mautrix-whatsapp COPY --from=builder /build/mautrix-whatsapp /usr/bin/mautrix-whatsapp
COPY --from=builder /build/docker-run.sh /docker-run.sh COPY --from=builder /build/docker-run.sh /docker-run.sh

View file

@ -1,11 +1,9 @@
ARG DOCKER_HUB="docker.io" FROM alpine:3.21
FROM ${DOCKER_HUB}/alpine:3.24
ENV UID=1337 \ ENV UID=1337 \
GID=1337 GID=1337
RUN apk add --no-cache ffmpeg su-exec ca-certificates bash jq curl yq-go lottieconverter RUN apk add --no-cache ffmpeg su-exec ca-certificates bash jq curl yq-go
ARG EXECUTABLE=./mautrix-whatsapp ARG EXECUTABLE=./mautrix-whatsapp
COPY $EXECUTABLE /usr/bin/mautrix-whatsapp COPY $EXECUTABLE /usr/bin/mautrix-whatsapp

View file

@ -14,10 +14,10 @@
* [x] Typing notifications * [x] Typing notifications
* [x] Read receipts * [x] Read receipts
* [ ] Power level * [ ] Power level
* [x] Membership actions * [ ] Membership actions
* [x] Invite * [ ] Invite
* [x] Leave * [ ] Leave
* [x] Kick * [ ] Kick
* [ ] Room metadata changes * [ ] Room metadata changes
* [ ] Name * [ ] Name
* [ ] Avatar * [ ] Avatar

View file

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

View file

@ -0,0 +1,70 @@
package main
import (
_ "embed"
"strings"
up "go.mau.fi/util/configupgrade"
"go.mau.fi/util/dbutil/litestream"
"maunium.net/go/mautrix/bridgev2/bridgeconfig"
)
const legacyMigrateRenameTables = `
ALTER TABLE backfill_queue RENAME TO backfill_queue_old;
ALTER TABLE backfill_state RENAME TO backfill_state_old;
ALTER TABLE disappearing_message RENAME TO disappearing_message_old;
ALTER TABLE history_sync_message RENAME TO history_sync_message_old;
ALTER TABLE history_sync_conversation RENAME TO history_sync_conversation_old;
ALTER TABLE media_backfill_requests RENAME TO media_backfill_requests_old;
ALTER TABLE poll_option_id RENAME TO poll_option_id_old;
ALTER TABLE user_portal RENAME TO user_portal_old;
ALTER TABLE portal RENAME TO portal_old;
ALTER TABLE puppet RENAME TO puppet_old;
ALTER TABLE message RENAME TO message_old;
ALTER TABLE reaction RENAME TO reaction_old;
ALTER TABLE "user" RENAME TO user_old;
`
//go:embed legacymigrate.sql
var legacyMigrateCopyData string
func init() {
litestream.Functions["split_part"] = func(input, delimiter string, partNum int) string {
// split_part is 1-indexed
partNum--
parts := strings.Split(input, delimiter)
if len(parts) <= partNum {
return ""
}
return parts[partNum]
}
}
func migrateLegacyConfig(helper up.Helper) {
helper.Set(up.Str, "maunium.net/go/mautrix-whatsapp", "encryption", "pickle_key")
bridgeconfig.CopyToOtherLocation(helper, up.Str, []string{"whatsapp", "os_name"}, []string{"network", "os_name"})
bridgeconfig.CopyToOtherLocation(helper, up.Str, []string{"whatsapp", "browser_name"}, []string{"network", "browser_name"})
bridgeconfig.CopyToOtherLocation(helper, up.Str, []string{"whatsapp", "proxy"}, []string{"network", "proxy"})
bridgeconfig.CopyToOtherLocation(helper, up.Str, []string{"whatsapp", "get_proxy_url"}, []string{"network", "get_proxy_url"})
bridgeconfig.CopyToOtherLocation(helper, up.Bool, []string{"whatsapp", "proxy_only_login"}, []string{"network", "proxy_only_login"})
bridgeconfig.CopyToOtherLocation(helper, up.Str, []string{"bridge", "displayname_template"}, []string{"network", "displayname_template"})
bridgeconfig.CopyToOtherLocation(helper, up.Bool, []string{"bridge", "call_start_notices"}, []string{"network", "call_start_notices"})
bridgeconfig.CopyToOtherLocation(helper, up.Bool, []string{"bridge", "send_presence_on_typing"}, []string{"network", "send_presence_on_typing"})
bridgeconfig.CopyToOtherLocation(helper, up.Bool, []string{"bridge", "enable_status_broadcast"}, []string{"network", "enable_status_broadcast"})
bridgeconfig.CopyToOtherLocation(helper, up.Bool, []string{"bridge", "disable_status_broadcast_send"}, []string{"network", "disable_status_broadcast_send"})
bridgeconfig.CopyToOtherLocation(helper, up.Bool, []string{"bridge", "mute_status_broadcast"}, []string{"network", "mute_status_broadcast"})
bridgeconfig.CopyToOtherLocation(helper, up.Str, []string{"bridge", "status_broadcast_tag"}, []string{"network", "status_broadcast_tag"})
bridgeconfig.CopyToOtherLocation(helper, up.Bool, []string{"bridge", "whatsapp_thumbnail"}, []string{"network", "whatsapp_thumbnail"})
bridgeconfig.CopyToOtherLocation(helper, up.Bool, []string{"bridge", "url_previews"}, []string{"network", "url_previews"})
bridgeconfig.CopyToOtherLocation(helper, up.Bool, []string{"bridge", "extev_polls"}, []string{"network", "extev_polls"})
bridgeconfig.CopyToOtherLocation(helper, up.Bool, []string{"bridge", "force_active_delivery_receipts"}, []string{"network", "force_active_delivery_receipts"})
bridgeconfig.CopyToOtherLocation(helper, up.Int, []string{"bridge", "history_sync", "max_initial_conversations"}, []string{"network", "history_sync", "max_initial_conversations"})
bridgeconfig.CopyToOtherLocation(helper, up.Bool, []string{"bridge", "history_sync", "request_full_sync"}, []string{"network", "history_sync", "request_full_sync"})
bridgeconfig.CopyToOtherLocation(helper, up.Int, []string{"bridge", "history_sync", "full_sync_config", "days_limit"}, []string{"network", "history_sync", "full_sync_config", "days_limit"})
bridgeconfig.CopyToOtherLocation(helper, up.Int, []string{"bridge", "history_sync", "full_sync_config", "size_limit_mb"}, []string{"network", "history_sync", "full_sync_config", "size_limit_mb"})
bridgeconfig.CopyToOtherLocation(helper, up.Int, []string{"bridge", "history_sync", "full_sync_config", "storage_quota_mb"}, []string{"network", "history_sync", "full_sync_config", "storage_quota_mb"})
bridgeconfig.CopyToOtherLocation(helper, up.Bool, []string{"bridge", "history_sync", "media_requests", "auto_request_media"}, []string{"network", "history_sync", "media_requests", "auto_request_media"})
bridgeconfig.CopyToOtherLocation(helper, up.Str, []string{"bridge", "history_sync", "media_requests", "request_method"}, []string{"network", "history_sync", "media_requests", "request_method"})
bridgeconfig.CopyToOtherLocation(helper, up.Int, []string{"bridge", "history_sync", "media_requests", "request_local_time"}, []string{"network", "history_sync", "media_requests", "request_local_time"})
bridgeconfig.CopyToOtherLocation(helper, up.Int, []string{"bridge", "history_sync", "media_requests", "max_async_handle"}, []string{"network", "history_sync", "media_requests", "max_async_handle"})
}

View file

@ -0,0 +1,214 @@
INSERT INTO "user" (bridge_id, mxid, management_room, access_token)
SELECT '', mxid, management_room, ''
FROM user_old;
UPDATE "user" SET access_token=COALESCE((SELECT access_token FROM puppet_old WHERE custom_mxid="user".mxid AND access_token<>'' LIMIT 1), '');
INSERT INTO user_login (bridge_id, user_mxid, id, remote_name, space_room, metadata, remote_profile)
SELECT
'', -- bridge_id
mxid, -- user_mxid
username, -- id
'+' || username, -- remote_name
space_room,
-- only: postgres
jsonb_build_object
-- only: sqlite (line commented)
-- json_object
(
'wa_device_id', device,
'phone_last_seen', phone_last_seen,
'phone_last_pinged', phone_last_pinged,
'timezone', timezone
), -- metadata
'{}' -- remote_profile
FROM user_old
WHERE username<>'' AND device<>0;
INSERT INTO ghost (
bridge_id, id, name, avatar_id, avatar_hash, avatar_mxc,
name_set, avatar_set, contact_info_set, is_bot, identifiers, metadata
)
SELECT
'', -- bridge_id
username, -- id
COALESCE(displayname, ''), -- name
COALESCE(avatar, ''), -- avatar_id
'', -- avatar_hash
COALESCE(avatar_url, ''), -- avatar_mxc
name_set,
avatar_set,
contact_info_set,
false, -- is_bot
'[]', -- identifiers
-- only: postgres
jsonb_build_object
-- only: sqlite (line commented)
-- json_object
(
'last_sync', last_sync
-- TODO name quality
) -- metadata
FROM puppet_old;
-- Some messages don't have senders, so insert an empty ghost to match the foreign key constraint.
INSERT INTO ghost (bridge_id, id, name, avatar_id, avatar_hash, avatar_mxc, name_set, avatar_set, contact_info_set, is_bot, identifiers, metadata)
VALUES ('', '', '', '', '', '', false, false, false, false, '[]', '{}')
ON CONFLICT (bridge_id, id) DO NOTHING;
DELETE FROM portal_old WHERE jid LIKE '%@s.whatsapp.net' AND (receiver='' OR receiver IS NULL) and mxid IS NULL;
INSERT INTO portal (
bridge_id, id, receiver, mxid, parent_id, parent_receiver, relay_bridge_id, relay_login_id, other_user_id,
name, topic, avatar_id, avatar_hash, avatar_mxc, name_set, avatar_set, topic_set,
name_is_custom, in_space, room_type, disappear_type, disappear_timer, metadata
)
SELECT
'', -- bridge_id
jid, -- id
CASE WHEN receiver LIKE '%@s.whatsapp.net' THEN replace(receiver, '@s.whatsapp.net', '') ELSE '' END, -- receiver
mxid,
CASE WHEN EXISTS(SELECT 1 FROM portal_old WHERE jid=parent_group) THEN parent_group ELSE NULL END, -- parent_id
'', -- parent_receiver
CASE WHEN relay_user_id<>'' THEN '' END, -- relay_bridge_id
(SELECT id FROM user_login WHERE user_mxid=relay_user_id), -- relay_login_id
CASE WHEN jid LIKE '%@s.whatsapp.net' THEN replace(jid, '@s.whatsapp.net', '') ELSE '' END, -- other_user_id
name,
topic,
avatar, -- avatar_id
'', -- avatar_hash
COALESCE(avatar_url, ''), -- avatar_mxc
name_set,
avatar_set,
topic_set,
jid NOT LIKE '%@s.whatsapp.net', -- name_is_custom
in_space,
CASE
WHEN is_parent THEN 'space'
WHEN jid LIKE '%@s.whatsapp.net' THEN 'dm'
ELSE ''
END, -- room_type
CASE WHEN expiration_time>0 THEN 'after_read' END, -- disappear_type
CASE WHEN expiration_time > 0 THEN expiration_time * 1000000000 END, -- disappear_timer
-- only: postgres
jsonb_build_object
-- only: sqlite (line commented)
-- json_object
(
'last_sync', last_sync
) -- metadata
FROM portal_old;
-- only: sqlite
DELETE FROM user_portal_old WHERE rowid IN (SELECT rowid FROM pragma_foreign_key_check('user_portal_old'));
INSERT INTO user_portal (bridge_id, user_mxid, login_id, portal_id, portal_receiver, in_space, preferred, last_read)
SELECT
'', -- bridge_id
user_mxid,
(SELECT id FROM user_login WHERE user_login.user_mxid=user_portal_old.user_mxid), -- login_id
portal_jid, -- portal_id
CASE WHEN portal_receiver LIKE '%@s.whatsapp.net' THEN replace(portal_receiver, '@s.whatsapp.net', '') ELSE '' END, -- portal_receiver
in_space,
false, -- preferred
last_read_ts * 1000000000 -- last_read
FROM user_portal_old WHERE EXISTS(SELECT 1 FROM user_login WHERE user_login.user_mxid=user_portal_old.user_mxid);
ALTER TABLE message_old ADD COLUMN combined_id TEXT;
DELETE FROM message_old WHERE sender IS NULL;
UPDATE message_old SET combined_id = chat_jid || ':' || (
CASE WHEN sender LIKE '%:%@s.whatsapp.net'
THEN (split_part(replace(sender, '@s.whatsapp.net', ''), ':', 1) || '@s.whatsapp.net')
ELSE sender
END
) || ':' || jid;
DELETE FROM message_old WHERE timestamp<0;
-- only: sqlite for next 2 lines
DELETE FROM message_old WHERE rowid IN (SELECT rowid FROM pragma_foreign_key_check('message_old'));
DELETE FROM reaction_old WHERE rowid IN (SELECT rowid FROM pragma_foreign_key_check('reaction_old'));
DELETE FROM message_old WHERE sender NOT LIKE '%@s.whatsapp.net' AND sender<>chat_jid;
DELETE FROM reaction_old WHERE sender NOT LIKE '%@s.whatsapp.net';
DELETE FROM reaction_old WHERE NOT EXISTS(SELECT 1 FROM puppet_old WHERE username=replace(sender, '@s.whatsapp.net', ''));
INSERT INTO message (
bridge_id, id, part_id, mxid, room_id, room_receiver, sender_id, sender_mxid, timestamp, edit_count, metadata
)
SELECT
'', -- bridge_id
combined_id, -- id
'', -- part_id
mxid,
chat_jid, -- room_id
CASE WHEN chat_receiver LIKE '%@s.whatsapp.net' THEN replace(chat_receiver, '@s.whatsapp.net', '') ELSE '' END, -- room_receiver
CASE WHEN sender=chat_jid AND sender NOT LIKE '%@s.whatsapp.net'
THEN ''
ELSE split_part(split_part(replace(sender, '@s.whatsapp.net', ''), ':', 1), '.', 1)
END, -- sender_id
sender_mxid, -- sender_mxid
timestamp * 1000000000, -- timestamp
0, -- edit_count
-- only: postgres
jsonb_build_object
-- only: sqlite (line commented)
-- json_object
(
'sender_device_id', CAST(nullif(split_part(replace(sender, '@s.whatsapp.net', ''), ':', 2), '') AS INTEGER),
'broadcast_list_jid', broadcast_list_jid,
'error', CAST(error AS TEXT)
) -- metadata
FROM message_old;
INSERT INTO reaction (
bridge_id, message_id, message_part_id, sender_id, emoji_id, room_id, room_receiver, mxid, timestamp, emoji, metadata
)
SELECT
'', -- bridge_id
message_old.combined_id, -- message_id
'', -- message_part_id
replace(reaction_old.sender, '@s.whatsapp.net', ''), -- sender_id
'', -- emoji_id
reaction_old.chat_jid, -- room_id
CASE WHEN reaction_old.chat_receiver LIKE '%@s.whatsapp.net' THEN replace(reaction_old.chat_receiver, '@s.whatsapp.net', '') ELSE '' END, -- room_receiver
reaction_old.mxid,
0, -- timestamp
'', -- emoji
-- only: postgres
jsonb_build_object
-- only: sqlite (line commented)
-- json_object
(
'sender_device_id', CAST(nullif(split_part(replace(reaction_old.sender, '@s.whatsapp.net', ''), ':', 2), '') AS INTEGER)
) -- metadata
FROM reaction_old
LEFT JOIN message_old
ON reaction_old.chat_jid = message_old.chat_jid
AND reaction_old.chat_receiver = message_old.chat_receiver
AND reaction_old.target_jid = message_old.jid;
INSERT INTO disappearing_message (bridge_id, mx_room, mxid, type, timer, disappear_at)
SELECT
'', -- bridge_id
room_id,
event_id,
'after_read',
expire_in * 1000000, -- timer
expire_at * 1000000 -- disappear_at
FROM disappearing_message_old;
INSERT INTO whatsapp_poll_option_id (bridge_id, msg_mxid, opt_id, opt_hash)
SELECT '', msg_mxid, opt_id, opt_hash
FROM poll_option_id_old;
DROP TABLE backfill_queue_old;
DROP TABLE backfill_state_old;
DROP TABLE disappearing_message_old;
DROP TABLE history_sync_message_old;
DROP TABLE history_sync_conversation_old;
DROP TABLE media_backfill_requests_old;
DROP TABLE poll_option_id_old;
DROP TABLE user_portal_old;
DROP TABLE reaction_old;
DROP TABLE message_old;
DROP TABLE puppet_old;
DROP TABLE portal_old;
DROP TABLE user_old;

View file

@ -0,0 +1,308 @@
package main
import (
"context"
"errors"
"net/http"
"regexp"
"strings"
"github.com/gorilla/mux"
"github.com/gorilla/websocket"
"github.com/rs/zerolog/hlog"
"go.mau.fi/util/exhttp"
"go.mau.fi/whatsmeow/types"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/matrix"
"maunium.net/go/mautrix/bridgev2/status"
"maunium.net/go/mautrix/id"
"go.mau.fi/mautrix-whatsapp/pkg/connector"
"go.mau.fi/mautrix-whatsapp/pkg/waid"
)
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true
},
Subprotocols: []string{"net.maunium.whatsapp.login"},
}
func legacyProvAuth(r *http.Request) string {
if !strings.HasSuffix(r.URL.Path, "/v1/login") {
return ""
}
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-") {
return strings.TrimPrefix(part, "net.maunium.whatsapp.auth-")
}
}
return ""
}
type OtherUserInfo struct {
MXID id.UserID `json:"mxid"`
JID types.JID `json:"jid"`
Name string `json:"displayname"`
Avatar id.ContentURIString `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"`
}
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 respondWebsocketWithError(conn *websocket.Conn, err error, message string) {
var mautrixRespErr mautrix.RespError
var bv2RespErr bridgev2.RespError
if errors.As(err, &bv2RespErr) {
mautrixRespErr = mautrix.RespError(bv2RespErr)
} else if !errors.As(err, &mautrixRespErr) {
mautrixRespErr = mautrix.RespError{
Err: message,
ErrCode: "M_UNKNOWN",
StatusCode: http.StatusInternalServerError,
}
}
_ = conn.WriteJSON(&mautrixRespErr)
}
var notNumbers = regexp.MustCompile("[^0-9]")
func legacyProvLogin(w http.ResponseWriter, r *http.Request) {
log := hlog.FromRequest(r)
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Err(err).Msg("Failed to upgrade connection to websocket")
return
}
defer func() {
err := conn.Close()
if err != nil {
log.Debug().Err(err).Msg("Error closing websocket")
}
}()
go func() {
// Read everything so SetCloseHandler() works
for {
_, _, err = conn.ReadMessage()
if err != nil {
break
}
}
}()
ctx, cancel := context.WithCancel(context.Background())
conn.SetCloseHandler(func(code int, text string) error {
log.Debug().Int("close_code", code).Msg("Login websocket closed, cancelling login")
cancel()
return nil
})
user := m.Matrix.Provisioning.GetUser(r)
loginFlowID := connector.LoginFlowIDQR
phoneNum := r.URL.Query().Get("phone_number")
if phoneNum != "" {
phoneNum = notNumbers.ReplaceAllString(phoneNum, "")
if len(phoneNum) < 7 || strings.HasPrefix(phoneNum, "0") {
errorMsg := "Invalid phone number"
if len(phoneNum) > 6 {
errorMsg = "Please enter the phone number in international format"
}
_ = conn.WriteJSON(Error{
Error: errorMsg,
ErrCode: "invalid phone number",
})
return
}
loginFlowID = connector.LoginFlowIDPhone
}
login, err := c.CreateLogin(ctx, user, loginFlowID)
if err != nil {
log.Err(err).Msg("Failed to create login")
respondWebsocketWithError(conn, err, "Failed to create login")
return
}
waLogin := login.(*connector.WALogin)
waLogin.Timezone = r.URL.Query().Get("tz")
step, err := waLogin.Start(ctx)
if err != nil {
log.Err(err).Msg("Failed to start login")
respondWebsocketWithError(conn, err, "Failed to start login")
return
}
if phoneNum != "" {
if step.StepID != connector.LoginStepIDPhoneNumber {
respondWebsocketWithError(conn, errors.New("unexpected step"), "Unexpected step while starting phone number login")
waLogin.Cancel()
return
}
step, err = waLogin.SubmitUserInput(ctx, map[string]string{"phone_number": phoneNum})
if err != nil {
log.Err(err).Msg("Failed to submit phone number")
respondWebsocketWithError(conn, err, "Failed to start phone code login")
return
} else if step.StepID != connector.LoginStepIDCode {
respondWebsocketWithError(conn, errors.New("unexpected step"), "Unexpected step after submitting phone number")
waLogin.Cancel()
return
}
_ = conn.WriteJSON(map[string]any{
"pairing_code": step.DisplayAndWaitParams.Data,
"timeout": 180,
})
} else if step.StepID != connector.LoginStepIDQR {
respondWebsocketWithError(conn, errors.New("unexpected step"), "Unexpected step while starting QR login")
waLogin.Cancel()
return
} else {
_ = conn.WriteJSON(map[string]any{
"code": step.DisplayAndWaitParams.Data,
"timeout": 60,
})
}
for {
step, err = waLogin.Wait(ctx)
if err != nil {
log.Err(err).Msg("Failed to wait for login")
respondWebsocketWithError(conn, err, "Failed to wait for login")
} else if step.StepID == connector.LoginStepIDQR {
_ = conn.WriteJSON(map[string]any{
"code": step.DisplayAndWaitParams.Data,
"timeout": 20,
})
continue
} else if step.StepID != connector.LoginStepIDComplete {
respondWebsocketWithError(conn, errors.New("unexpected step"), "Unexpected step while waiting for login")
waLogin.Cancel()
} else {
// TODO delete old logins
_ = conn.WriteJSON(map[string]any{
"success": true,
"jid": waid.ParseUserLoginID(step.CompleteParams.UserLoginID, step.CompleteParams.UserLogin.Metadata.(*waid.UserLoginMetadata).WADeviceID).String(),
"platform": step.CompleteParams.UserLogin.Client.(*connector.WhatsAppClient).Device.Platform,
"phone": step.CompleteParams.UserLogin.RemoteProfile.Phone,
})
go handleLoginComplete(context.WithoutCancel(ctx), user, step.CompleteParams.UserLogin)
}
break
}
}
func handleLoginComplete(ctx context.Context, user *bridgev2.User, newLogin *bridgev2.UserLogin) {
allLogins := user.GetUserLogins()
for _, login := range allLogins {
if login.ID != newLogin.ID {
login.Delete(ctx, status.BridgeState{StateEvent: status.StateLoggedOut, Reason: "LOGIN_OVERRIDDEN"}, bridgev2.DeleteOpts{})
}
}
}
func legacyProvLogout(w http.ResponseWriter, r *http.Request) {
user := m.Matrix.Provisioning.GetUser(r)
allLogins := user.GetUserLogins()
if len(allLogins) == 0 {
exhttp.WriteJSONResponse(w, http.StatusOK, Error{
Error: "You're not logged in",
ErrCode: "not logged in",
})
return
}
for _, login := range allLogins {
// Intentionally don't delete the user login, only logout remote
login.Client.(*connector.WhatsAppClient).LogoutRemote(r.Context())
}
exhttp.WriteJSONResponse(w, http.StatusOK, Response{true, "Logged out successfully"})
}
func legacyProvContacts(w http.ResponseWriter, r *http.Request) {
userLogin := m.Matrix.Provisioning.GetLoginForRequest(w, r)
if userLogin == nil {
return
}
if contacts, err := userLogin.Client.(*connector.WhatsAppClient).Device.Contacts.GetAllContacts(); err != nil {
hlog.FromRequest(r).Err(err).Msg("Failed to fetch all contacts")
exhttp.WriteJSONResponse(w, http.StatusInternalServerError, Error{
Error: "Internal server error while fetching contact list",
ErrCode: "failed to get contacts",
})
} else {
augmentedContacts := map[types.JID]any{}
for jid, contact := range contacts {
var avatarURL id.ContentURIString
if puppet, _ := m.Bridge.GetExistingGhostByID(r.Context(), waid.MakeUserID(jid)); puppet != nil {
avatarURL = puppet.AvatarMXC
}
augmentedContacts[jid] = map[string]interface{}{
"Found": contact.Found,
"FirstName": contact.FirstName,
"FullName": contact.FullName,
"PushName": contact.PushName,
"BusinessName": contact.BusinessName,
"AvatarURL": avatarURL,
}
}
exhttp.WriteJSONResponse(w, http.StatusOK, augmentedContacts)
}
}
func legacyProvResolveIdentifier(w http.ResponseWriter, r *http.Request) {
number := mux.Vars(r)["number"]
userLogin := m.Matrix.Provisioning.GetLoginForRequest(w, r)
if userLogin == nil {
return
}
startChat := strings.Contains(r.URL.Path, "/v1/pm/")
resp, err := userLogin.Client.(*connector.WhatsAppClient).ResolveIdentifier(r.Context(), number, startChat)
if err != nil {
hlog.FromRequest(r).Warn().Err(err).Str("identifier", number).Msg("Failed to resolve identifier")
matrix.RespondWithError(w, err, "Internal error resolving identifier")
return
}
var portal *bridgev2.Portal
if startChat {
portal, err = m.Bridge.GetPortalByKey(r.Context(), resp.Chat.PortalKey)
if err != nil {
hlog.FromRequest(r).Warn().Err(err).Stringer("portal_key", resp.Chat.PortalKey).Msg("Failed to get portal by key")
matrix.RespondWithError(w, err, "Internal error getting portal by key")
return
}
err = portal.CreateMatrixRoom(r.Context(), userLogin, nil)
if err != nil {
hlog.FromRequest(r).Warn().Err(err).Stringer("portal_key", resp.Chat.PortalKey).Msg("Failed to create matrix room for portal")
matrix.RespondWithError(w, err, "Internal error creating matrix room for portal")
return
}
} else {
portal, _ = m.Bridge.GetExistingPortalByKey(r.Context(), resp.Chat.PortalKey)
}
var roomID id.RoomID
if portal != nil {
roomID = portal.MXID
}
exhttp.WriteJSONResponse(w, http.StatusOK, PortalInfo{
RoomID: roomID,
OtherUser: &OtherUserInfo{
JID: waid.ParseUserID(resp.UserID),
MXID: resp.Ghost.Intent.GetMXID(),
Name: resp.Ghost.Name,
Avatar: resp.Ghost.AvatarMXC,
},
})
}

View file

@ -1,9 +1,13 @@
package main package main
import ( import (
"net/http"
"maunium.net/go/mautrix/bridgev2/bridgeconfig"
"maunium.net/go/mautrix/bridgev2/matrix/mxmain" "maunium.net/go/mautrix/bridgev2/matrix/mxmain"
"go.mau.fi/mautrix-whatsapp/pkg/connector" "go.mau.fi/mautrix-whatsapp/pkg/connector"
"go.mau.fi/mautrix-whatsapp/pkg/connector/wadb/upgrades"
) )
// Information to find out exactly which commit the bridge was built from. // Information to find out exactly which commit the bridge was built from.
@ -14,16 +18,39 @@ var (
BuildTime = "unknown" BuildTime = "unknown"
) )
var c = &connector.WhatsAppConnector{}
var m = mxmain.BridgeMain{ var m = mxmain.BridgeMain{
Name: "mautrix-whatsapp", Name: "mautrix-whatsapp",
URL: "https://github.com/mautrix/whatsapp", URL: "https://github.com/mautrix/whatsapp",
Description: "A Matrix-WhatsApp puppeting bridge.", Description: "A Matrix-WhatsApp puppeting bridge.",
Version: "26.10", Version: "0.11.4",
SemCalVer: true, Connector: c,
Connector: &connector.WhatsAppConnector{},
} }
func main() { func main() {
bridgeconfig.HackyMigrateLegacyNetworkConfig = migrateLegacyConfig
m.PostInit = func() {
m.CheckLegacyDB(
57,
"v0.8.6",
"v0.11.0",
m.LegacyMigrateWithAnotherUpgrader(
legacyMigrateRenameTables, legacyMigrateCopyData, 17,
upgrades.Table, "whatsapp_version", 3,
),
true,
)
}
m.PostStart = func() {
if m.Matrix.Provisioning != nil {
m.Matrix.Provisioning.Router.HandleFunc("/v1/login", legacyProvLogin).Methods(http.MethodGet)
m.Matrix.Provisioning.Router.HandleFunc("/v1/logout", legacyProvLogout).Methods(http.MethodPost)
m.Matrix.Provisioning.Router.HandleFunc("/v1/contacts", legacyProvContacts).Methods(http.MethodGet)
m.Matrix.Provisioning.Router.HandleFunc("/v1/resolve_identifier/{number}", legacyProvResolveIdentifier).Methods(http.MethodGet)
m.Matrix.Provisioning.Router.HandleFunc("/v1/pm/{number}", legacyProvResolveIdentifier).Methods(http.MethodPost)
m.Matrix.Provisioning.GetAuthFromRequest = legacyProvAuth
}
}
m.InitVersion(Tag, Commit, BuildTime) m.InitVersion(Tag, Commit, BuildTime)
m.Run() m.Run()
} }

61
go.mod
View file

@ -1,53 +1,48 @@
module go.mau.fi/mautrix-whatsapp module go.mau.fi/mautrix-whatsapp
go 1.26.0 go 1.23.0
toolchain go1.27.2 toolchain go1.24.1
tool go.mau.fi/util/cmd/maubuild
require ( require (
github.com/google/uuid v1.6.0 github.com/gorilla/mux v1.8.0
github.com/lib/pq v1.12.3 github.com/gorilla/websocket v1.5.0
github.com/rs/zerolog v1.35.1 github.com/lib/pq v1.10.9
github.com/tidwall/gjson v1.20.0 github.com/rs/zerolog v1.34.0
github.com/yuin/goldmark v1.8.6 go.mau.fi/util v0.8.6
go.mau.fi/util v0.11.0 go.mau.fi/webp v0.2.0
go.mau.fi/webp v0.3.0 go.mau.fi/whatsmeow v0.0.0-20250411192951-5ab78fadbf91
go.mau.fi/whatsmeow v0.0.0-20261026125351-1fbae988179a golang.org/x/image v0.26.0
golang.org/x/image v0.47.0 golang.org/x/net v0.39.0
golang.org/x/net v0.61.0 golang.org/x/sync v0.13.0
golang.org/x/sync v0.24.0 google.golang.org/protobuf v1.36.6
google.golang.org/protobuf v1.36.12
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
maunium.net/go/mautrix v0.32.1-0.20261105141340-842796acf445 maunium.net/go/mautrix v0.23.3-0.20250405234116-e675a3c09c38
) )
require ( require (
filippo.io/edwards25519 v1.2.0 // indirect filippo.io/edwards25519 v1.1.0 // indirect
github.com/beeper/argo-go v1.1.2 // indirect github.com/coreos/go-systemd/v22 v22.5.0 // indirect
github.com/coder/websocket v1.8.15 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/coreos/go-systemd/v22 v22.7.0 // indirect
github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect
github.com/kr/pretty v0.3.1 // indirect github.com/kr/pretty v0.3.1 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.52 // indirect github.com/mattn/go-sqlite3 v1.14.24 // indirect
github.com/petermattis/goid v0.0.0-20260918085751-abfca077860b // indirect github.com/petermattis/goid v0.0.0-20250303134427-723919f7f203 // indirect
github.com/rogpeppe/go-internal v1.10.0 // indirect github.com/rogpeppe/go-internal v1.10.0 // indirect
github.com/rs/xid v1.6.0 // indirect github.com/rs/xid v1.6.0 // indirect
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect
github.com/tidwall/match v1.2.0 // indirect github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect github.com/tidwall/sjson v1.2.5 // indirect
github.com/vektah/gqlparser/v2 v2.5.27 // indirect github.com/yuin/goldmark v1.7.8 // indirect
go.mau.fi/libsignal v0.2.2 // indirect go.mau.fi/libsignal v0.1.2 // indirect
go.mau.fi/zeroconfig v0.2.0 // indirect go.mau.fi/zeroconfig v0.1.3 // indirect
golang.org/x/crypto v0.58.0 // indirect golang.org/x/crypto v0.37.0 // indirect
golang.org/x/exp v0.0.0-20261014143641-f0251eb4aa3e // indirect golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
golang.org/x/mod v0.42.0 // indirect golang.org/x/sys v0.32.0 // indirect
golang.org/x/sys v0.49.0 // indirect golang.org/x/text v0.24.0 // indirect
golang.org/x/text v0.43.0 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
maunium.net/go/mauflag v1.0.0 // indirect maunium.net/go/mauflag v1.0.0 // indirect

130
go.sum
View file

@ -1,24 +1,21 @@
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= 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 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM= github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs=
github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ=
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
github.com/beeper/argo-go v1.1.2 h1:UQI2G8F+NLfGTOmTUI0254pGKx/HUU/etbUGTJv91Fs=
github.com/beeper/argo-go v1.1.2/go.mod h1:M+LJAnyowKVQ6Rdj6XYGEn+qcVFkb3R/MUpqkGR0hM4=
github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/coreos/go-systemd/v22 v22.7.0 h1:LAEzFkke61DFROc7zNLX/WA2i5J8gYqe0rSj9KI28KA=
github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/elliotchance/orderedmap/v3 v3.1.0 h1:j4DJ5ObEmMBt/lcwIecKcoRxIQUEnw0L804lXYDt/pg= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/elliotchance/orderedmap/v3 v3.1.0/go.mod h1:G+Hc2RwaZvJMcS4JpGCOyViCnGeKf0bTYCGTO4uhjSo= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
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/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= 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 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
@ -26,74 +23,75 @@ 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.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 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.52 h1:wVbm2Qnf4OXkqhBTSPuCRZDRnxfbVrrmiCEroVdog8U= github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
github.com/mattn/go-sqlite3 v1.14.52/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w= github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/petermattis/goid v0.0.0-20260918085751-abfca077860b h1:OzNsuVdSWGwXvWKTtChx9ve89k4cFJ6NxYM4Aw4/f+E= github.com/petermattis/goid v0.0.0-20250303134427-723919f7f203 h1:E7Kmf11E4K7B5hDti2K2NqPb1nlYlGYsu02S1JNd/Bs=
github.com/petermattis/goid v0.0.0-20260918085751-abfca077860b/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= github.com/petermattis/goid v0.0.0-20250303134427-723919f7f203/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= 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/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 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 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI= github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw= github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
github.com/stretchr/testify v1.12.0 h1:K6Mr6jO9JICuend/5xzTM03ydSV3vdNRYAdPSukj8uI= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.12.0/go.mod h1:bOYBZb5qJ00vPzWfIqBUZPaxK8jWiXc6d3ErP4Ca9Gw= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.20.0 h1:+agJ3rEzKcCXKDKo0ml26UROcpcAlnZyjq+TjbY5Fto= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.20.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc= github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM=
github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/vektah/gqlparser/v2 v2.5.27 h1:RHPD3JOplpk5mP5JGX8RKZkt2/Vwj/PZv0HxTdwFp0s= github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
github.com/vektah/gqlparser/v2 v2.5.27/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo= github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
github.com/yuin/goldmark v1.8.6 h1:d0VcaP1sx9GkFVkoW+KtggpGi2KZ965i14b0+bDQST4= go.mau.fi/libsignal v0.1.2 h1:Vs16DXWxSKyzVtI+EEXLCSy5pVWzzCzp/2eqFGvLyP0=
github.com/yuin/goldmark v1.8.6/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= go.mau.fi/libsignal v0.1.2/go.mod h1:JpnLSSJptn/s1sv7I56uEMywvz8x4YzxeF5OzdPb6PE=
go.mau.fi/libsignal v0.2.2 h1:QV+XdzQkm3x3aSG7FcqfGSZuFXz83pRZPBFaPygHbOU= go.mau.fi/util v0.8.6 h1:AEK13rfgtiZJL2YsNK+W4ihhYCuukcRom8WPP/w/L54=
go.mau.fi/libsignal v0.2.2/go.mod h1:CRlIQg2J8uYTfDFvNoO8/KcZjs5cey0vbc6oj/bssY0= go.mau.fi/util v0.8.6/go.mod h1:uNB3UTXFbkpp7xL1M/WvQks90B/L4gvbLpbS0603KOE=
go.mau.fi/util v0.11.0 h1:JNEpkVRxfTdJUC0DIGKiRlLKzl4G/qLX/eVzoP7QuwY= go.mau.fi/webp v0.2.0 h1:QVMenHw7JDb4vall5sV75JNBQj9Hw4u8AKbi1QetHvg=
go.mau.fi/util v0.11.0/go.mod h1:04AEFVsc4zNdIkDC1gkdqbg/nZFolHY50pyXh2DSy7s= go.mau.fi/webp v0.2.0/go.mod h1:VSg9MyODn12Mb5pyG0NIyNFhujrmoFSsZBs8syOZD1Q=
go.mau.fi/webp v0.3.0 h1:gVHQZtz21Ziwj+CDuklbX9mqpsnDIFKxs/BJyV7iZzA= go.mau.fi/whatsmeow v0.0.0-20250411192951-5ab78fadbf91 h1:eziiUdmaGyhM7Fxn4IMRClBrX1b2sCAFGSBE2Z57yNg=
go.mau.fi/webp v0.3.0/go.mod h1:rlZFTev+dYxhvk+XNBP/5GcTt4gXmzAB4DU0aGUYIQo= go.mau.fi/whatsmeow v0.0.0-20250411192951-5ab78fadbf91/go.mod h1:aj6MpceCuJ6m712lPEMAh8iAw2TkABx6TnPjMcdG0eE=
go.mau.fi/whatsmeow v0.0.0-20261026125351-1fbae988179a h1:ZyCV/mWfRkutxs5R0K3v+oyTbF4huprSlPZg/LEFQ7k= go.mau.fi/zeroconfig v0.1.3 h1:As9wYDKmktjmNZW5i1vn8zvJlmGKHeVxHVIBMXsm4kM=
go.mau.fi/whatsmeow v0.0.0-20261026125351-1fbae988179a/go.mod h1:iY4lm61nTLWsNiIE1EmHUZwhKGBeoV4dhnVE2uMtNoQ= go.mau.fi/zeroconfig v0.1.3/go.mod h1:NcSJkf180JT+1IId76PcMuLTNa1CzsFFZ0nBygIQM70=
go.mau.fi/zeroconfig v0.2.0 h1:e/OGEERqVRRKlgaro7E6bh8xXiKFSXB3eNNIud7FUjU= golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
go.mau.fi/zeroconfig v0.2.0/go.mod h1:J0Vn0prHNOm493oZoQ84kq83ZaNCYZnq+noI1b1eN8w= golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54= golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw=
golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w= golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM=
golang.org/x/exp v0.0.0-20261014143641-f0251eb4aa3e h1:HgI6W1OolKK6tcV74is+rl9SMT0njsHlp0TbQiGXI/A= golang.org/x/image v0.26.0 h1:4XjIFEZWQmCZi6Wv8BoxsDhRU3RVnLX04dToTDAEPlY=
golang.org/x/exp v0.0.0-20261014143641-f0251eb4aa3e/go.mod h1:DBoxwWhjaW8DJJw/YeM4zpeOKMC1FMXSblsYy+1mFSw= golang.org/x/image v0.26.0/go.mod h1:lcxbMFAovzpnJxzXS3nyL83K27tmqtKzIJpctK8YO5c=
golang.org/x/image v0.47.0 h1:za/8gVz++PPZqmZJBNSISICN5Rrj5zixP/8K++fgmqo= golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
golang.org/x/image v0.47.0/go.mod h1:IkN5o4A7vq55vXN8FWjjnhg9Rs2GOtffqsSi9mWAKYE= golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
golang.org/x/mod v0.42.0 h1:ICeL3t2L30ljSPHHxDnR4fqVcBt/zR2zJAb9juavmQg= golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
golang.org/x/mod v0.42.0/go.mod h1:u5fzIrRInkL8MaO1cbRkUHT64v303YIT5KEqpNr7jRs= golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/net v0.61.0 h1:RR+6j/BTBCrggOLugxa+66R8FvoN6MHlzDlZIbN9YFw= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/net v0.61.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI=
golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc= golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s= golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
@ -103,5 +101,5 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
maunium.net/go/mauflag v1.0.0 h1:YiaRc0tEI3toYtJMRIfjP+jklH45uDHtT80nUamyD4M= maunium.net/go/mauflag v1.0.0 h1:YiaRc0tEI3toYtJMRIfjP+jklH45uDHtT80nUamyD4M=
maunium.net/go/mauflag v1.0.0/go.mod h1:nLivPOpTpHnpzEh8jEdSL9UqO9+/KBJFmNRlwKfkPeA= maunium.net/go/mauflag v1.0.0/go.mod h1:nLivPOpTpHnpzEh8jEdSL9UqO9+/KBJFmNRlwKfkPeA=
maunium.net/go/mautrix v0.32.1-0.20261105141340-842796acf445 h1:ZI9Y2YEmjqQDloOCiciHeynTRYjUwK0ZZbNcRH/5NOc= maunium.net/go/mautrix v0.23.3-0.20250405234116-e675a3c09c38 h1:fIe2+kYndm3Mm/DwQ4FsODk2DjrLeEeW7tKtZjyERqM=
maunium.net/go/mautrix v0.32.1-0.20261105141340-842796acf445/go.mod h1:tZSwPzw+/0pa0dxGufkh0uJqM84BNxB6e2I0+S0UyjU= maunium.net/go/mautrix v0.23.3-0.20250405234116-e675a3c09c38/go.mod h1:pCYLHmo02Jauak/9VlTkbGPrBMvLXsGqTGMNOx+L2PE=

View file

@ -28,9 +28,6 @@ import (
) )
func (wa *WhatsAppClient) obfuscateJID(jid types.JID) string { func (wa *WhatsAppClient) obfuscateJID(jid types.JID) string {
if jid.Server == types.HiddenUserServer {
return jid.String()
}
// Turn the first 4 bytes of HMAC-SHA256(user_mxid, phone) into a number and replace the middle of the actual phone with that deterministic random number. // Turn the first 4 bytes of HMAC-SHA256(user_mxid, phone) into a number and replace the middle of the actual phone with that deterministic random number.
randomNumber := binary.BigEndian.Uint32(hmac.New(sha256.New, []byte(wa.UserLogin.UserMXID)).Sum([]byte(jid.User))[:4]) randomNumber := binary.BigEndian.Uint32(hmac.New(sha256.New, []byte(wa.UserLogin.UserMXID)).Sum([]byte(jid.User))[:4])
return fmt.Sprintf("+%s-%d-%s:%d", jid.User[:1], randomNumber, jid.User[len(jid.User)-2:], jid.Device) return fmt.Sprintf("+%s-%d-%s:%d", jid.User[:1], randomNumber, jid.User[len(jid.User)-2:], jid.Device)

View file

@ -4,17 +4,13 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"runtime/debug"
"slices" "slices"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
"sync/atomic"
"time" "time"
"github.com/rs/zerolog" "github.com/rs/zerolog"
"go.mau.fi/util/exerrors"
"go.mau.fi/util/exmaps"
"go.mau.fi/util/ptr" "go.mau.fi/util/ptr"
"go.mau.fi/whatsmeow" "go.mau.fi/whatsmeow"
"go.mau.fi/whatsmeow/proto/waE2E" "go.mau.fi/whatsmeow/proto/waE2E"
@ -32,176 +28,35 @@ import (
var _ bridgev2.BackfillingNetworkAPI = (*WhatsAppClient)(nil) var _ bridgev2.BackfillingNetworkAPI = (*WhatsAppClient)(nil)
func (wa *WhatsAppClient) historySyncLoop(ctx context.Context) { const historySyncDispatchWait = 30 * time.Second
dispatchTimer := time.NewTimer(wa.Main.Config.HistorySync.DispatchWait)
func (wa *WhatsAppClient) historySyncLoop(ctx context.Context) {
dispatchTimer := time.NewTimer(historySyncDispatchWait)
var timerPending atomic.Bool
if !wa.isNewLogin && wa.UserLogin.Metadata.(*waid.UserLoginMetadata).HistorySyncPortalsNeedCreating { if !wa.isNewLogin && wa.UserLogin.Metadata.(*waid.UserLoginMetadata).HistorySyncPortalsNeedCreating {
dispatchTimer.Reset(5 * time.Second) dispatchTimer.Reset(5 * time.Second)
timerPending.Store(true)
} else { } else {
dispatchTimer.Stop() dispatchTimer.Stop()
} }
if wa.Client.ManualHistorySyncDownload { wa.UserLogin.Log.Debug().Msg("Starting history sync loop")
// Wake up the queue once to check if there are pending notifications
select {
case wa.historySyncWakeup <- struct{}{}:
default:
}
}
wa.UserLogin.Log.Debug().Msg("Starting history sync loops")
// Separate loop for creating portals to ensure it doesn't block processing new history sync payloads.
go func() {
for { for {
select { select {
case evt := <-wa.historySyncs:
dispatchTimer.Stop()
wa.handleWAHistorySync(ctx, evt)
dispatchTimer.Reset(historySyncDispatchWait)
case <-dispatchTimer.C: case <-dispatchTimer.C:
timerPending.Store(false)
wa.createPortalsFromHistorySync(ctx) wa.createPortalsFromHistorySync(ctx)
case <-ctx.Done(): case <-ctx.Done():
wa.UserLogin.Log.Debug().Msg("Stopping portal creation history sync loop") wa.UserLogin.Log.Debug().Msg("Stopping history sync loop")
return return
} }
} }
}()
for {
var resetTimer, errored bool
select {
case <-wa.historySyncWakeup:
dispatchTimer.Stop()
notif, rowid, err := wa.Main.DB.HSNotif.GetNext(ctx, wa.UserLogin.ID)
if err != nil {
wa.UserLogin.Log.Err(err).Msg("Failed to get next history sync notification")
} else if notif == nil {
wa.UserLogin.Log.Debug().Msg("No more queued history sync notifications")
} else {
resetTimer, errored = wa.downloadAndSaveWAHistorySyncData(ctx, notif, rowid)
// Continue waking up the loop until all queued notifications are processed
select {
case wa.historySyncWakeup <- struct{}{}:
default:
}
if errored {
wa.UserLogin.Log.Debug().Msg("Temporarily stopping history sync loop after error")
select {
case <-time.After(1 * time.Minute):
case <-ctx.Done():
wa.UserLogin.Log.Debug().Msg("Stopping main history sync loop")
return
}
}
}
case <-ctx.Done():
wa.UserLogin.Log.Debug().Msg("Stopping main history sync loop")
return
}
if resetTimer {
timerPending.Store(true)
}
if timerPending.Load() {
dispatchTimer.Reset(wa.Main.Config.HistorySync.DispatchWait)
}
}
} }
func (wa *WhatsAppClient) saveWAHistorySyncNotification(ctx context.Context, evt *waE2E.HistorySyncNotification) { func (wa *WhatsAppClient) handleWAHistorySync(ctx context.Context, evt *waHistorySync.HistorySync) {
err := wa.Main.DB.HSNotif.Put(ctx, wa.UserLogin.ID, evt)
if err != nil {
wa.UserLogin.Log.Err(err).Msg("Failed to store history sync notification in queue")
return
}
wa.UserLogin.Log.Debug().
Stringer("sync_type", evt.GetSyncType()).
Uint32("chunk_order", evt.GetChunkOrder()).
Uint32("progress", evt.GetProgress()).
Msg("Stored history sync notification in queue")
select {
case wa.historySyncWakeup <- struct{}{}:
default:
}
}
func (wa *WhatsAppClient) downloadAndSaveWAHistorySyncData(ctx context.Context, evt *waE2E.HistorySyncNotification, rowid int) (resetTimer, errored bool) {
log := wa.UserLogin.Log.With().
Str("action", "download history sync").
Stringer("sync_type", evt.GetSyncType()).
Uint32("chunk_order", evt.GetChunkOrder()).
Uint32("progress", evt.GetProgress()).
Logger()
defer func() {
if v := recover(); v != nil {
log.Err(exerrors.RecoverToError(v)).
Bytes(zerolog.ErrorStackFieldName, debug.Stack()).
Msg("History sync saving panicked")
errored = true
}
}()
log.Debug().
Int64("oldest_msg_in_chunk_ts", evt.GetOldestMsgInChunkTimestampSec()).
Any("full_request_meta", evt.GetFullHistorySyncOnDemandRequestMetadata()).
Any("access_status", evt.GetMessageAccessStatus()).
Str("peer_data_request_session_id", evt.GetPeerDataRequestSessionID()).
Msg("Downloading history sync")
blob, err := wa.Client.DownloadHistorySync(log.WithContext(ctx), evt, true)
if errors.Is(err, whatsmeow.ErrMediaDownloadFailedWith403) || errors.Is(err, whatsmeow.ErrMediaDownloadFailedWith404) || errors.Is(err, whatsmeow.ErrMediaDownloadFailedWith410) {
log.Err(err).Msg("History sync blob is no longer available, dropping notification")
if err = wa.Main.DB.HSNotif.Delete(ctx, rowid); err != nil {
log.Err(err).Msg("Failed to delete queued history sync notification")
errored = true
}
return
} else if err != nil {
log.Err(err).Msg("Failed to download history sync")
errored = true
return
}
if blob.GetSyncType() == waHistorySync.HistorySync_ON_DEMAND {
wa.finishOnDemandHistorySyncRequest(evt.GetPeerDataRequestSessionID())
wa.handleOnDemandHistorySync(ctx, blob)
if err = wa.Main.DB.HSNotif.Delete(ctx, rowid); err != nil {
log.Err(err).Msg("Failed to delete queued on-demand history sync notification")
} else if err = wa.Client.DeleteMedia(ctx, whatsmeow.MediaHistory, evt.GetDirectPath(), evt.GetFileEncSHA256(), evt.GetEncHandle()); err != nil {
log.Err(err).Msg("Failed to delete history sync blob from server")
} else {
log.Debug().Msg("Finished handling on-demand history sync and deleted history sync blob from server")
}
return
}
err = wa.Main.DB.DoTxn(ctx, nil, func(ctx context.Context) (innerErr error) {
innerErr = wa.handleWAHistorySync(ctx, evt, blob, true)
if innerErr != nil {
return
}
innerErr = wa.Main.DB.HSNotif.Delete(ctx, rowid)
if innerErr != nil {
innerErr = fmt.Errorf("failed to delete queued history sync notification: %w", innerErr)
}
return
})
if err != nil {
log.Err(err).Msg("Failed to store history sync notification data")
errored = true
} else {
resetTimer = blob.GetSyncType() == waHistorySync.HistorySync_INITIAL_BOOTSTRAP ||
blob.GetSyncType() == waHistorySync.HistorySync_RECENT ||
blob.GetSyncType() == waHistorySync.HistorySync_FULL
err = wa.Client.DeleteMedia(ctx, whatsmeow.MediaHistory, evt.GetDirectPath(), evt.GetFileEncSHA256(), evt.GetEncHandle())
if err != nil {
log.Err(err).Msg("Failed to delete history sync blob from server")
} else {
log.Debug().Msg("Deleted history sync blob from server")
}
}
return
}
func (wa *WhatsAppClient) handleWAHistorySync(
ctx context.Context,
notif *waE2E.HistorySyncNotification,
evt *waHistorySync.HistorySync,
stopOnError bool,
) error {
if evt == nil || evt.SyncType == nil { if evt == nil || evt.SyncType == nil {
return nil return
} }
log := wa.UserLogin.Log.With(). log := wa.UserLogin.Log.With().
Str("action", "store history sync"). Str("action", "store history sync").
@ -213,12 +68,7 @@ func (wa *WhatsAppClient) handleWAHistorySync(
if evt.GetGlobalSettings() != nil { if evt.GetGlobalSettings() != nil {
log.Debug().Interface("global_settings", evt.GetGlobalSettings()).Msg("Got global settings in history sync") log.Debug().Interface("global_settings", evt.GetGlobalSettings()).Msg("Got global settings in history sync")
} }
if evt.GetSyncType() == waHistorySync.HistorySync_INITIAL_STATUS_V3 || if evt.GetSyncType() == waHistorySync.HistorySync_INITIAL_STATUS_V3 || evt.GetSyncType() == waHistorySync.HistorySync_PUSH_NAME || evt.GetSyncType() == waHistorySync.HistorySync_NON_BLOCKING_DATA {
evt.GetSyncType() == waHistorySync.HistorySync_PUSH_NAME ||
evt.GetSyncType() == waHistorySync.HistorySync_NON_BLOCKING_DATA {
if evt.GetSyncType() == waHistorySync.HistorySync_PUSH_NAME {
wa.pushNamesSynced.Set()
}
log.Debug(). log.Debug().
Int("conversation_count", len(evt.GetConversations())). Int("conversation_count", len(evt.GetConversations())).
Int("pushname_count", len(evt.GetPushnames())). Int("pushname_count", len(evt.GetPushnames())).
@ -226,71 +76,40 @@ func (wa *WhatsAppClient) handleWAHistorySync(
Int("recent_sticker_count", len(evt.GetRecentStickers())). Int("recent_sticker_count", len(evt.GetRecentStickers())).
Int("past_participant_count", len(evt.GetPastParticipants())). Int("past_participant_count", len(evt.GetPastParticipants())).
Msg("Ignoring history sync") Msg("Ignoring history sync")
return nil return
} }
log.Info(). log.Info().
Int("conversation_count", len(evt.GetConversations())). Int("conversation_count", len(evt.GetConversations())).
Int("past_participant_count", len(evt.GetPastParticipants())). Int("past_participant_count", len(evt.GetPastParticipants())).
Dict("notification_metadata", zerolog.Dict().
Int64("oldest_msg_in_chunk_ts", notif.GetOldestMsgInChunkTimestampSec()).
Any("full_request_meta", notif.GetFullHistorySyncOnDemandRequestMetadata()).
Any("access_status", notif.GetMessageAccessStatus()).
Str("peer_data_request_session_id", notif.GetPeerDataRequestSessionID())).
Msg("Storing history sync") Msg("Storing history sync")
start := time.Now()
successfullySavedTotal := 0 successfullySavedTotal := 0
failedToSaveTotal := 0 failedToSaveTotal := 0
totalMessageCount := 0 totalMessageCount := 0
for _, conv := range evt.GetConversations() { for _, conv := range evt.GetConversations() {
log := log.With().
Int("msg_count", len(conv.GetMessages())).
Logger()
jid, err := types.ParseJID(conv.GetID()) jid, err := types.ParseJID(conv.GetID())
if err != nil { if err != nil {
totalMessageCount += len(conv.GetMessages()) totalMessageCount += len(conv.GetMessages())
log.Warn().Err(err). log.Warn().Err(err).
Str("chat_jid", conv.GetID()). Str("chat_jid", conv.GetID()).
Int("msg_count", len(conv.GetMessages())).
Msg("Failed to parse chat JID in history sync") Msg("Failed to parse chat JID in history sync")
continue continue
} else if jid.Server == types.BroadcastServer { } else if jid.Server == types.BroadcastServer {
log.Debug().Stringer("chat_jid", jid).Msg("Skipping broadcast list in history sync") log.Debug().Stringer("chat_jid", jid).Msg("Skipping broadcast list in history sync")
continue continue
} else if jid == types.PSAJID { }
// New messages in the official updates chat aren't bridged because they use a different path,
// so drop history as well. This should be removed if the PSA chat gets properly bridged in the future.
log.Debug().Stringer("chat_jid", jid).Msg("Skipping PSA in history sync")
continue
} else {
totalMessageCount += len(conv.GetMessages()) totalMessageCount += len(conv.GetMessages())
} log := log.With().
if jid == types.MetaAIJID { Stringer("chat_jid", jid).
jid = types.NewMetaAIJID Int("msg_count", len(conv.GetMessages())).
} else if jid.Server == types.DefaultUserServer { Logger()
lid, err := wa.GetStore().LIDs.GetLIDForPN(ctx, jid)
if err != nil {
log.Err(err).Stringer("pn", jid).Msg("Failed to get LID for phone number in history sync")
} else if lid.IsEmpty() {
log.Warn().Stringer("pn", jid).Msg("No LID found for phone number in history sync")
} else {
log.Debug().
Stringer("lid", lid).
Stringer("pn", jid).
Msg("Rerouting phone number DM to LID in history sync")
jid = lid
}
}
log.UpdateContext(func(c zerolog.Context) zerolog.Context {
return c.Stringer("chat_jid", jid)
})
var firstItemTime, lastItemTime time.Time var minTime, maxTime time.Time
var minTimeIndex, maxTimeIndex int
ignoredTypes := 0 ignoredTypes := 0
rawMessages := conv.GetMessages() messages := make([]*wadb.HistorySyncMessageTuple, 0, len(conv.GetMessages()))
messages := make([]*wadb.HistorySyncMessageTuple, 0, len(rawMessages)) for i, rawMsg := range conv.GetMessages() {
allowClamp := conv.GetCommentsCount() == 0
var newerTS uint64
for i, rawMsg := range rawMessages {
// Don't store messages that will just be skipped. // Don't store messages that will just be skipped.
msgEvt, err := wa.Client.ParseWebMessage(jid, rawMsg.GetMessage()) msgEvt, err := wa.Client.ParseWebMessage(jid, rawMsg.GetMessage())
if err != nil { if err != nil {
@ -298,43 +117,23 @@ func (wa *WhatsAppClient) handleWAHistorySync(
Int("msg_index", i). Int("msg_index", i).
Str("msg_id", rawMsg.GetMessage().GetKey().GetID()). Str("msg_id", rawMsg.GetMessage().GetKey().GetID()).
Uint64("msg_time_seconds", rawMsg.GetMessage().GetMessageTimestamp()). Uint64("msg_time_seconds", rawMsg.GetMessage().GetMessageTimestamp()).
Stringer("stub_type", rawMsg.GetMessage().GetMessageStubType()).
Str("message_type", getMessageType(rawMsg.GetMessage().GetMessage())).
Bool("from_me", rawMsg.GetMessage().GetKey().GetFromMe()).
Msg("Dropping historical message due to parse error") Msg("Dropping historical message due to parse error")
log.Trace().
Any("web_message_info", rawMsg.GetMessage()).
Msg("Content of event that failed to parse")
continue continue
} }
if firstItemTime.IsZero() { if minTime.IsZero() || msgEvt.Info.Timestamp.Before(minTime) {
firstItemTime = msgEvt.Info.Timestamp minTime = msgEvt.Info.Timestamp
minTimeIndex = i
}
if maxTime.IsZero() || msgEvt.Info.Timestamp.After(maxTime) {
maxTime = msgEvt.Info.Timestamp
maxTimeIndex = i
} }
lastItemTime = msgEvt.Info.Timestamp
msgType := getMessageType(msgEvt.Message) msgType := getMessageType(msgEvt.Message)
if msgType == "ignore" || strings.HasPrefix(msgType, "unknown_protocol_") { if msgType == "ignore" || strings.HasPrefix(msgType, "unknown_protocol_") {
ignoredTypes++ ignoredTypes++
continue continue
} }
// Comments (replies) in announcement groups are not ordered by timestamp, so don't clamp them.
if rawMsg.GetMessage().GetCommentMetadata().GetCommentParentKey() != nil {
allowClamp = false
}
// WhatsApp has bugs where some random messages will have timestamps decades in the future.
// To ensure they don't mess up our ordering, require timestamps of older messages to be
// before the previous (newer) message.
if currentTS := rawMsg.GetMessage().GetMessageTimestamp(); newerTS > 0 && allowClamp && currentTS > newerTS {
log.Warn().
Time("current_ts", time.Unix(int64(currentTS), 0)).
Time("prev_ts", time.Unix(int64(newerTS), 0)).
Int("msg_index", i).
Str("msg_id", rawMsg.GetMessage().GetKey().GetID()).
Msg("Clamping message timestamp")
rawMsg.Message.MessageTimestamp = ptr.Ptr(newerTS)
msgEvt.Info.Timestamp = time.Unix(int64(newerTS), 0)
}
newerTS = rawMsg.GetMessage().GetMessageTimestamp()
marshaled, err := proto.Marshal(rawMsg) marshaled, err := proto.Marshal(rawMsg)
if err != nil { if err != nil {
log.Warn().Err(err). log.Warn().Err(err).
@ -349,83 +148,56 @@ func (wa *WhatsAppClient) handleWAHistorySync(
log.Debug(). log.Debug().
Int("wrapped_count", len(messages)). Int("wrapped_count", len(messages)).
Int("ignored_msg_type_count", ignoredTypes). Int("ignored_msg_type_count", ignoredTypes).
Time("first_item_time", firstItemTime). Time("lowest_time", minTime).
Time("last_item_time", lastItemTime). Int("lowest_time_index", minTimeIndex).
Time("highest_time", maxTime).
Int("highest_time_index", maxTimeIndex).
Dict("metadata", zerolog.Dict(). Dict("metadata", zerolog.Dict().
Uint32("ephemeral_expiration", conv.GetEphemeralExpiration()). Uint32("ephemeral_expiration", conv.GetEphemeralExpiration()).
Int64("ephemeral_setting_timestamp", conv.GetEphemeralSettingTimestamp()). Int64("ephemeral_setting_timestamp", conv.GetEphemeralSettingTimestamp()).
Uint64("last_message_ts", conv.GetLastMsgTimestamp()).
Bool("marked_unread", conv.GetMarkedAsUnread()). Bool("marked_unread", conv.GetMarkedAsUnread()).
Bool("archived", conv.GetArchived()). Bool("archived", conv.GetArchived()).
Uint32("pinned", conv.GetPinned()). Uint32("pinned", conv.GetPinned()).
Uint64("mute_end", conv.GetMuteEndTime()). Uint64("mute_end", conv.GetMuteEndTime()).
Uint32("unread_count", conv.GetUnreadCount()). Uint32("unread_count", conv.GetUnreadCount()),
Bool("end_of_history", conv.GetEndOfHistoryTransfer()).
Stringer("end_of_history_type", conv.GetEndOfHistoryTransferType()),
). ).
Msg("Collected messages to save from history sync conversation") Msg("Collected messages to save from history sync conversation")
if len(messages) > 0 { if len(messages) > 0 {
err = wa.Main.DB.Conversation.Put(ctx, wadb.NewConversation(wa.UserLogin.ID, jid, conv, firstItemTime)) err = wa.Main.DB.Conversation.Put(ctx, wadb.NewConversation(wa.UserLogin.ID, jid, conv))
if err != nil { if err != nil {
if stopOnError {
return fmt.Errorf("failed to save conversation metadata for %s: %w", jid, err)
}
log.Err(err).Msg("Failed to save conversation metadata") log.Err(err).Msg("Failed to save conversation metadata")
continue continue
} }
err = wa.Main.DB.Message.Put(ctx, wa.UserLogin.ID, jid, messages) err = wa.Main.DB.Message.Put(ctx, wa.UserLogin.ID, jid, messages)
if err != nil { if err != nil {
if stopOnError {
return fmt.Errorf("failed to save messages in %s: %w", jid, err)
}
log.Err(err).Msg("Failed to save messages") log.Err(err).Msg("Failed to save messages")
failedToSaveTotal += len(messages) failedToSaveTotal += len(messages)
} else { } else {
successfullySavedTotal += len(messages) successfullySavedTotal += len(messages)
} }
err = wa.Main.Bridge.DB.BackfillTask.MarkNotDone(ctx, wa.makeWAPortalKey(jid), wa.UserLogin.ID)
if err != nil {
if stopOnError {
return fmt.Errorf("failed to mark backfill task as not done for %s: %w", jid, err)
}
log.Err(err).Msg("Failed to mark backfill task as not done")
}
} }
} }
log.Info(). log.Info().
Int("total_saved_count", successfullySavedTotal). Int("total_saved_count", successfullySavedTotal).
Int("total_failed_count", failedToSaveTotal). Int("total_failed_count", failedToSaveTotal).
Int("total_message_count", totalMessageCount). Int("total_message_count", totalMessageCount).
Dur("duration", time.Since(start)).
Msg("Finished storing history sync") Msg("Finished storing history sync")
return nil
} }
func (wa *WhatsAppClient) createPortalsFromHistorySync(ctx context.Context) { func (wa *WhatsAppClient) createPortalsFromHistorySync(ctx context.Context) {
log := wa.UserLogin.Log.With(). log := wa.UserLogin.Log.With().
Str("action", "create portals from history sync"). Str("action", "create portals from history sync").
Logger() Logger()
defer func() {
if v := recover(); v != nil {
log.Err(exerrors.RecoverToError(v)).
Bytes(zerolog.ErrorStackFieldName, debug.Stack()).
Msg("History sync portal creation panicked")
}
}()
ctx = log.WithContext(ctx) ctx = log.WithContext(ctx)
limit := wa.Main.Config.HistorySync.MaxInitialConversations limit := wa.Main.Config.HistorySync.MaxInitialConversations
loginTS := wa.UserLogin.Metadata.(*waid.UserLoginMetadata).LoggedInAt log.Info().Int("limit", limit).Msg("Creating portals from history sync")
conversations, err := wa.Main.DB.Conversation.GetRecent(ctx, wa.UserLogin.ID, limit, loginTS) conversations, err := wa.Main.DB.Conversation.GetRecent(ctx, wa.UserLogin.ID, limit)
if err != nil { if err != nil {
log.Err(err).Msg("Failed to get recent conversations from database") log.Err(err).Msg("Failed to get recent conversations from database")
return return
} }
log.Info(). log.Info().Int("conversation_count", len(conversations)).Msg("Creating portals from history sync")
Int("limit", limit).
Int("conversation_count", len(conversations)).
Int64("login_timestamp", loginTS.Unix()).
Msg("Creating portals from history sync")
rateLimitErrors := 0 rateLimitErrors := 0
var wg sync.WaitGroup var wg sync.WaitGroup
wg.Add(len(conversations)) wg.Add(len(conversations))
@ -441,21 +213,12 @@ func (wa *WhatsAppClient) createPortalsFromHistorySync(ctx context.Context) {
if conv.ChatJID == types.StatusBroadcastJID && !wa.Main.Config.EnableStatusBroadcast { if conv.ChatJID == types.StatusBroadcastJID && !wa.Main.Config.EnableStatusBroadcast {
wg.Done() wg.Done()
continue continue
} else if conv.ChatJID == types.PSAJID || conv.ChatJID == types.LegacyPSAJID {
// We don't currently support new PSAs, so don't bother backfilling them either
wg.Done()
continue
} }
// TODO can the chat info fetch be avoided entirely? // TODO can the chat info fetch be avoided entirely?
select { time.Sleep(time.Duration(rateLimitErrors) * time.Second)
case <-time.After(time.Duration(rateLimitErrors) * time.Second): wrappedInfo, err := wa.getChatInfo(ctx, conv.ChatJID, conv)
case <-ctx.Done(): if errors.Is(err, whatsmeow.ErrNotInGroup) {
log.Warn().Err(ctx.Err()).Msg("Context cancelled, stopping history sync portal creation") log.Debug().Stringer("chat_jid", conv.ChatJID).
return
}
wrappedInfo, err := wa.getChatInfo(ctx, conv.ChatJID, conv, true)
if errors.Is(err, whatsmeow.ErrNotInGroup) || errors.Is(err, whatsmeow.ErrGroupNotFound) || errors.Is(err, ErrNewsletterNotFound) {
log.Debug().Err(err).Stringer("chat_jid", conv.ChatJID).
Msg("Skipping creating room because the user is not a participant") Msg("Skipping creating room because the user is not a participant")
//err = wa.Main.DB.Message.DeleteAllInChat(ctx, wa.UserLogin.ID, conv.ChatJID) //err = wa.Main.DB.Message.DeleteAllInChat(ctx, wa.UserLogin.ID, conv.ChatJID)
//if err != nil { //if err != nil {
@ -473,30 +236,21 @@ func (wa *WhatsAppClient) createPortalsFromHistorySync(ctx context.Context) {
log.Err(err).Stringer("chat_jid", conv.ChatJID). log.Err(err).Stringer("chat_jid", conv.ChatJID).
Int("error_count", rateLimitErrors). Int("error_count", rateLimitErrors).
Msg("Ratelimit error getting chat info, retrying after sleep") Msg("Ratelimit error getting chat info, retrying after sleep")
select { time.Sleep(time.Duration(rateLimitErrors) * time.Minute)
case <-time.After(time.Duration(rateLimitErrors) * time.Second):
case <-ctx.Done():
log.Warn().Err(ctx.Err()).Msg("Context cancelled, stopping history sync portal creation")
return
}
continue continue
} else if err != nil { } else if err != nil {
log.Err(err).Stringer("chat_jid", conv.ChatJID).Msg("Failed to get chat info") log.Err(err).Stringer("chat_jid", conv.ChatJID).Msg("Failed to get chat info")
wg.Done() wg.Done()
continue continue
} }
res := wa.UserLogin.QueueRemoteEvent(&simplevent.ChatResync{ wa.Main.Bridge.QueueRemoteEvent(wa.UserLogin, &simplevent.ChatResync{
EventMeta: simplevent.EventMeta{ EventMeta: simplevent.EventMeta{
Type: bridgev2.RemoteEventChatResync, Type: bridgev2.RemoteEventChatResync,
LogContext: func(c zerolog.Context) zerolog.Context { LogContext: nil,
return c.
Stringer("chat_jid", conv.ChatJID).
Time("latest_message_ts", conv.LastMessageTimestamp)
},
PortalKey: wa.makeWAPortalKey(conv.ChatJID), PortalKey: wa.makeWAPortalKey(conv.ChatJID),
CreatePortal: true, CreatePortal: true,
PostHandleFunc: func(ctx context.Context, portal *bridgev2.Portal) { PostHandleFunc: func(ctx context.Context, portal *bridgev2.Portal) {
err := wa.Main.DB.Conversation.MarkSynced(ctx, wa.UserLogin.ID, conv.ChatJID, loginTS) err := wa.Main.DB.Conversation.MarkBridged(ctx, wa.UserLogin.ID, conv.ChatJID)
if err != nil { if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to mark conversation as bridged") zerolog.Ctx(ctx).Err(err).Msg("Failed to mark conversation as bridged")
} }
@ -506,10 +260,6 @@ func (wa *WhatsAppClient) createPortalsFromHistorySync(ctx context.Context) {
ChatInfo: wrappedInfo, ChatInfo: wrappedInfo,
LatestMessageTS: conv.LastMessageTimestamp, LatestMessageTS: conv.LastMessageTimestamp,
}) })
if !res.Success {
log.Debug().Msg("Cancelling history sync portal creation loop")
return
}
} }
log.Info().Int("conversation_count", len(conversations)).Msg("Finished creating portals from history sync") log.Info().Int("conversation_count", len(conversations)).Msg("Finished creating portals from history sync")
go func() { go func() {
@ -528,73 +278,40 @@ func (wa *WhatsAppClient) FetchMessages(ctx context.Context, params bridgev2.Fet
if err != nil { if err != nil {
return nil, err return nil, err
} }
if portalJID.Server == types.DefaultUserServer {
zerolog.Ctx(ctx).Warn().Stringer("portal_jid", portalJID).Msg("FetchMessages called for phone number portal")
}
var markRead bool var markRead bool
var startTime, endTime *time.Time var startTime, endTime *time.Time
var conv *wadb.Conversation
if params.Forward || wa.Main.Config.HistorySync.BackwardsOnDemand {
conv, err = wa.Main.DB.Conversation.Get(ctx, wa.UserLogin.ID, portalJID)
if err != nil {
return nil, fmt.Errorf("failed to get conversation from database: %w", err)
}
}
if params.Forward { if params.Forward {
if params.AnchorMessage != nil { if params.AnchorMessage != nil {
startTime = ptr.Ptr(params.AnchorMessage.Timestamp) startTime = ptr.Ptr(params.AnchorMessage.Timestamp)
} }
if conv != nil { conv, err := wa.Main.DB.Conversation.Get(ctx, wa.UserLogin.ID, portalJID)
if err != nil {
return nil, fmt.Errorf("failed to get conversation from database: %w", err)
} else if conv != nil {
markRead = !ptr.Val(conv.MarkedAsUnread) && ptr.Val(conv.UnreadCount) == 0 markRead = !ptr.Val(conv.MarkedAsUnread) && ptr.Val(conv.UnreadCount) == 0
} }
} else { } else if params.Cursor != "" {
if params.AnchorMessage != nil {
endTime = ptr.Ptr(params.AnchorMessage.Timestamp)
}
if params.Cursor != "" {
endTimeUnix, err := strconv.ParseInt(string(params.Cursor), 10, 64) endTimeUnix, err := strconv.ParseInt(string(params.Cursor), 10, 64)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to parse cursor: %w", err) return nil, fmt.Errorf("failed to parse cursor: %w", err)
} }
cursorTime := time.Unix(endTimeUnix, 0) endTime = ptr.Ptr(time.Unix(endTimeUnix, 0))
if endTime == nil || cursorTime.Before(*endTime) { } else if params.AnchorMessage != nil {
endTime = &cursorTime endTime = ptr.Ptr(params.AnchorMessage.Timestamp)
}
}
}
var anchorID types.MessageID
if params.AnchorMessage != nil {
parsedID, _ := waid.ParseMessageID(params.AnchorMessage.ID)
if parsedID != nil {
anchorID = parsedID.ID
}
}
var hasMore bool
if !params.Forward && wa.Main.Config.HistorySync.BackwardsOnDemand {
hasMore = conv != nil && ptr.Val(conv.EndOfHistoryTransferType) == waHistorySync.Conversation_COMPLETE_BUT_MORE_MESSAGES_REMAIN_ON_PRIMARY
} }
messages, err := wa.Main.DB.Message.GetBetween(ctx, wa.UserLogin.ID, portalJID, startTime, endTime, params.Count+1) messages, err := wa.Main.DB.Message.GetBetween(ctx, wa.UserLogin.ID, portalJID, startTime, endTime, params.Count+1)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to load messages from database: %w", err) return nil, fmt.Errorf("failed to load messages from database: %w", err)
} else if len(messages) == 0 || (len(messages) == 1 && anchorID != "" && messages[0].GetKey().GetID() == anchorID) { } else if len(messages) == 0 {
wa.deleteHistorySyncMessages(ctx, portalJID, 0, 0)
hasMore = hasMore && params.AnchorMessage != nil
if hasMore && !params.AllowSlowFetch {
return &bridgev2.FetchMessagesResponse{
MoreRequiresSlowFetch: true,
HasMore: true,
Forward: params.Forward,
}, nil
} else if hasMore {
return wa.fetchMessagesFromPhone(ctx, portalJID, params)
}
return &bridgev2.FetchMessagesResponse{ return &bridgev2.FetchMessagesResponse{
HasMore: false, HasMore: false,
Forward: params.Forward, Forward: params.Forward,
}, nil }, nil
} }
if len(messages) > params.Count { hasMore := false
oldestTS := messages[len(messages)-1].GetMessageTimestamp() oldestTS := messages[len(messages)-1].GetMessageTimestamp()
newestTS := messages[0].GetMessageTimestamp()
if len(messages) > params.Count {
hasMore = true hasMore = true
// For safety, cut off messages with the oldest timestamp in the response. // For safety, cut off messages with the oldest timestamp in the response.
// Otherwise, if there are multiple messages with the same timestamp, the next fetch may miss some. // Otherwise, if there are multiple messages with the same timestamp, the next fetch may miss some.
@ -605,118 +322,17 @@ func (wa *WhatsAppClient) FetchMessages(ctx context.Context, params bridgev2.Fet
} }
} }
} }
resp, err := wa.convertHistorySyncMessages(ctx, params.Portal, portalJID, messages, true, !params.Forward && !hasMore) convertedMessages := make([]*bridgev2.BackfillMessage, len(messages))
if err != nil {
return nil, fmt.Errorf("failed to convert messages: %w", err)
}
// TODO might be better to always set true here and let GetBetween return an empty result to mark as false?
resp.HasMore = hasMore
resp.Forward = params.Forward
resp.MarkRead = markRead
if params.Forward && !markRead && conv != nil && !ptr.Val(conv.MarkedAsUnread) {
resp.ReadUpTo = historySyncReadUpTo(resp.Messages, int(ptr.Val(conv.UnreadCount)))
}
return resp, nil
}
func historySyncReadUpTo(messages []*bridgev2.BackfillMessage, unreadCount int) time.Time {
if unreadCount <= 0 {
return time.Time{}
}
for i := len(messages) - 1; i >= 0; i-- {
if messages[i].Sender.IsFromMe {
continue
}
unreadCount--
if unreadCount == 0 {
return messages[i].Timestamp.Add(-time.Nanosecond)
}
}
return time.Time{}
}
func (wa *WhatsAppClient) deleteHistorySyncMessages(ctx context.Context, portalJID types.JID, newestTS, oldestTS uint64) {
var err error
var rows int64
if (newestTS == 0 && oldestTS == 0) || !wa.Main.Bridge.Config.Backfill.Queue.AnyEnabled() {
// If the backfill queue isn't enabled, delete all messages after backfilling a batch.
rows, err = wa.Main.DB.Message.DeleteAllInChat(ctx, wa.UserLogin.ID, portalJID)
} else {
// Otherwise just delete the messages that got backfilled
rows, err = wa.Main.DB.Message.DeleteBetween(ctx, wa.UserLogin.ID, portalJID, newestTS, oldestTS)
}
if err != nil {
zerolog.Ctx(ctx).Warn().Err(err).
Stringer("portal_jid", portalJID).
Uint64("newest_ts", newestTS).
Uint64("oldest_ts", oldestTS).
Msg("Failed to delete messages from database after backfill")
} else {
zerolog.Ctx(ctx).Debug().
Stringer("portal_jid", portalJID).
Uint64("newest_ts", newestTS).
Uint64("oldest_ts", oldestTS).
Int64("rows_affected", rows).
Msg("Deleted history sync messages from database")
}
}
func (wa *WhatsAppClient) convertHistorySyncMessages(
ctx context.Context,
portal *bridgev2.Portal,
portalJID types.JID,
messages []*waWeb.WebMessageInfo,
explodeOnError bool,
deleteEverything bool,
) (*bridgev2.FetchMessagesResponse, error) {
cli := wa.Client
if cli == nil {
return nil, bridgev2.ErrNotLoggedIn
}
oldestTS := messages[len(messages)-1].GetMessageTimestamp()
newestTS := messages[0].GetMessageTimestamp()
convertedMessages := make([]*bridgev2.BackfillMessage, 0, len(messages))
var mediaRequests []*wadb.MediaRequest var mediaRequests []*wadb.MediaRequest
dups := make(exmaps.Set[networkid.MessageID])
for i, msg := range messages { for i, msg := range messages {
evt, err := cli.ParseWebMessage(portalJID, msg) evt, err := wa.Client.ParseWebMessage(portalJID, msg)
if err != nil { if err != nil {
if explodeOnError {
// This should never happen because the info is already parsed once before being stored in the database // This should never happen because the info is already parsed once before being stored in the database
return nil, fmt.Errorf("failed to parse info of message %s: %w", msg.GetKey().GetID(), err) return nil, fmt.Errorf("failed to parse info of message %s: %w", msg.GetKey().GetID(), err)
} }
zerolog.Ctx(ctx).Warn().Err(err). var mediaReq *wadb.MediaRequest
Int("msg_index", i).
Str("msg_id", msg.GetKey().GetID()).
Uint64("msg_time_seconds", msg.GetMessageTimestamp()).
Msg("Dropping historical message due to parse error")
zerolog.Ctx(ctx).Trace().
Any("web_message_info", msg.GetMessage()).
Msg("Content of event that failed to parse")
continue
}
if !explodeOnError {
msgType := getMessageType(evt.Message)
if msgType == "ignore" || strings.HasPrefix(msgType, "unknown_protocol_") {
continue
}
}
if !wa.ensureAltJIDs(ctx, &evt.Info.MessageSource, false) {
return nil, fmt.Errorf("failed to ensure alt JIDs for message %s", evt.Info.ID)
}
if evt.Info.IsFromMe {
wa.addPreviousOwnLID(ctx, evt.Info.Sender)
}
isViewOnce := evt.IsViewOnce || evt.IsViewOnceV2 || evt.IsViewOnceV2Extension isViewOnce := evt.IsViewOnce || evt.IsViewOnceV2 || evt.IsViewOnceV2Extension
converted, mediaReq := wa.convertHistorySyncMessage( convertedMessages[i], mediaReq = wa.convertHistorySyncMessage(ctx, params.Portal, &evt.Info, evt.Message, isViewOnce, msg.Reactions)
ctx, cli, portal, &evt.Info, evt.Message, evt.RawMessage, isViewOnce, msg.Reactions,
)
// This is a hack to remove duplicates where the same message is inserted with both the LID and phone number sender
// TODO prevent those being inserted in the first place instead of hacking around it here
if !dups.Add(converted.ID) {
continue
}
convertedMessages = append(convertedMessages, converted)
if mediaReq != nil { if mediaReq != nil {
mediaRequests = append(mediaRequests, mediaReq) mediaRequests = append(mediaRequests, mediaReq)
} }
@ -725,11 +341,23 @@ func (wa *WhatsAppClient) convertHistorySyncMessages(
return &bridgev2.FetchMessagesResponse{ return &bridgev2.FetchMessagesResponse{
Messages: convertedMessages, Messages: convertedMessages,
Cursor: networkid.PaginationCursor(strconv.FormatUint(oldestTS, 10)), Cursor: networkid.PaginationCursor(strconv.FormatUint(oldestTS, 10)),
HasMore: hasMore,
Forward: endTime == nil,
MarkRead: markRead,
// TODO set remaining or total count
CompleteCallback: func() { CompleteCallback: func() {
if deleteEverything { // TODO this only deletes after backfilling. If there's no need for backfill after a relogin,
wa.deleteHistorySyncMessages(ctx, portalJID, 1<<62, oldestTS) // the messages will be stuck in the database
var err error
if !wa.Main.Bridge.Config.Backfill.Queue.Enabled && !wa.Main.Bridge.Config.Backfill.WillPaginateManually {
// If the backfill queue isn't enabled, delete all messages after backfilling a batch.
err = wa.Main.DB.Message.DeleteAllInChat(ctx, wa.UserLogin.ID, portalJID)
} else { } else {
wa.deleteHistorySyncMessages(ctx, portalJID, newestTS, oldestTS) // Otherwise just delete the messages that got backfilled
err = wa.Main.DB.Message.DeleteBetween(ctx, wa.UserLogin.ID, portalJID, newestTS, oldestTS)
}
if err != nil {
zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to delete messages from database after backfill")
} }
if len(mediaRequests) > 0 { if len(mediaRequests) > 0 {
go func(ctx context.Context) { go func(ctx context.Context) {
@ -748,201 +376,39 @@ func (wa *WhatsAppClient) convertHistorySyncMessages(
}, nil }, nil
} }
func (wa *WhatsAppClient) fetchMessagesFromPhone(ctx context.Context, portalJID types.JID, params bridgev2.FetchMessagesParams) (*bridgev2.FetchMessagesResponse, error) {
if params.AnchorMessage == nil {
return nil, fmt.Errorf("anchor message is required to fetch messages from phone")
}
anchor := params.AnchorMessage
parsed, err := waid.ParseMessageID(anchor.ID)
if err != nil {
anchor, err = wa.Main.Bridge.DB.Message.GetFirstPortalMessageExcludingIDPattern(ctx, params.Portal.PortalKey, "fake:%")
if err != nil {
return nil, fmt.Errorf("failed to get first non-fake portal message: %w", err)
} else if anchor == nil {
return &bridgev2.FetchMessagesResponse{}, nil
}
parsed, err = waid.ParseMessageID(anchor.ID)
if err != nil {
return nil, fmt.Errorf("failed to parse anchor message ID: %w", err)
}
}
msgID := wa.Client.GenerateMessageID()
if !wa.startOnDemandHistorySyncRequest(ctx, portalJID, msgID) {
return &bridgev2.FetchMessagesResponse{
HasMore: true,
Pending: true,
}, nil
}
reqData := wa.Client.BuildHistorySyncRequest(&types.MessageInfo{
MessageSource: types.MessageSource{
Chat: portalJID,
Sender: parsed.Sender,
IsFromMe: wa.IsOwnJID(parsed.Sender),
IsGroup: parsed.Chat.Server == types.GroupServer,
},
ID: parsed.ID,
Timestamp: anchor.Timestamp,
}, 50)
zerolog.Ctx(ctx).Debug().
Str("request_msg_id", msgID).
Stringer("portal_jid", portalJID).
Any("anchor_msg_parsed", parsed).
Any("request_data", reqData).
Msg("Sending history sync request")
_, err = wa.Client.SendMessage(ctx, wa.JID.ToNonAD(), reqData, whatsmeow.SendRequestExtra{
ID: msgID,
Peer: true,
})
if err != nil {
wa.onDemandHistorySyncLock.Lock()
delete(wa.onDemandHistorySyncRequests, portalJID)
wa.onDemandHistorySyncLock.Unlock()
return nil, fmt.Errorf("failed to send history sync request: %w", err)
}
return &bridgev2.FetchMessagesResponse{
HasMore: true,
Pending: true,
}, nil
}
type onDemandHistorySyncRequest struct {
ID types.MessageID
SentAt time.Time
}
const onDemandHistorySyncRequestTimeout = 1 * time.Hour
func (wa *WhatsAppClient) startOnDemandHistorySyncRequest(ctx context.Context, portalJID types.JID, msgID types.MessageID) bool {
wa.onDemandHistorySyncLock.Lock()
defer wa.onDemandHistorySyncLock.Unlock()
if req, inFlight := wa.onDemandHistorySyncRequests[portalJID]; inFlight && time.Since(req.SentAt) < onDemandHistorySyncRequestTimeout {
zerolog.Ctx(ctx).Debug().
Str("in_flight_request_msg_id", req.ID).
Stringer("portal_jid", portalJID).
Time("sent_at", req.SentAt).
Msg("Not sending history sync request, previous request is still in flight")
return false
}
wa.onDemandHistorySyncRequests[portalJID] = onDemandHistorySyncRequest{ID: msgID, SentAt: time.Now()}
return true
}
func (wa *WhatsAppClient) finishOnDemandHistorySyncRequest(sessionID string) {
wa.onDemandHistorySyncLock.Lock()
defer wa.onDemandHistorySyncLock.Unlock()
for chatJID, req := range wa.onDemandHistorySyncRequests {
if req.ID == sessionID {
delete(wa.onDemandHistorySyncRequests, chatJID)
}
}
}
func (wa *WhatsAppClient) handleOnDemandHistorySync(ctx context.Context, blob *waHistorySync.HistorySync) {
if len(blob.GetConversations()) > 1 {
zerolog.Ctx(ctx).Warn().
Int("conversation_count", len(blob.GetConversations())).
Msg("Received on-demand history sync with multiple conversations")
}
for _, conv := range blob.GetConversations() {
portalJID, err := types.ParseJID(conv.GetID())
if err != nil {
zerolog.Ctx(ctx).Err(err).Str("jid", conv.GetID()).Msg("Failed to parse portal JID")
continue
}
if portalJID == types.MetaAIJID {
portalJID = types.NewMetaAIJID
} else if portalJID.Server == types.DefaultUserServer {
lid, err := wa.GetStore().LIDs.GetLIDForPN(ctx, portalJID)
if err != nil {
zerolog.Ctx(ctx).Err(err).Stringer("lid", portalJID).Msg("Failed to get LID for phone number in on-demand history sync")
} else if lid.IsEmpty() {
zerolog.Ctx(ctx).Warn().Stringer("lid", portalJID).Msg("No LID found for phone number in on-demand history sync")
} else {
zerolog.Ctx(ctx).Debug().
Stringer("lid", lid).
Stringer("pn", portalJID).
Msg("Rerouting phone number DM to LID in on-demand history sync")
portalJID = lid
}
}
portal, err := wa.Main.Bridge.GetPortalByKey(ctx, wa.makeWAPortalKey(portalJID))
if err != nil {
zerolog.Ctx(ctx).Err(err).Stringer("portal_jid", portalJID).Msg("Failed to get portal for on-demand history sync")
continue
}
ctx := zerolog.Ctx(ctx).With().
Str("portal_id", string(portal.ID)).
Str("portal_receiver", string(portal.Receiver)).
Stringer("portal_mxid", portal.MXID).
Logger().WithContext(ctx)
portal.HandleRemoteBackfill(ctx, wa.UserLogin, &simplevent.Backfill{
EventMeta: simplevent.EventMeta{
Type: bridgev2.RemoteEventBackfill,
PortalKey: portal.PortalKey,
},
GetDataFunc: func(ctx context.Context, portal *bridgev2.Portal) (*bridgev2.FetchMessagesResponse, error) {
if len(conv.GetMessages()) == 0 {
return &bridgev2.FetchMessagesResponse{}, nil
}
messages := make([]*waWeb.WebMessageInfo, len(conv.GetMessages()))
for i, rawMsg := range conv.GetMessages() {
messages[i] = rawMsg.Message
}
zerolog.Ctx(ctx).Debug().
Int("message_count", len(messages)).
Stringer("end_of_history_type", conv.GetEndOfHistoryTransferType()).
Msg("Converting messages to bridge from on-demand history sync")
resp, err := wa.convertHistorySyncMessages(ctx, portal, portalJID, messages, false, false)
if err != nil {
return nil, err
}
resp.HasMore = conv.GetEndOfHistoryTransferType() == waHistorySync.Conversation_COMPLETE_BUT_MORE_MESSAGES_REMAIN_ON_PRIMARY
return resp, nil
},
})
}
}
func (wa *WhatsAppClient) convertHistorySyncMessage( func (wa *WhatsAppClient) convertHistorySyncMessage(
ctx context.Context, cli *whatsmeow.Client, portal *bridgev2.Portal, info *types.MessageInfo, msg, rawMsg *waE2E.Message, isViewOnce bool, reactions []*waWeb.Reaction, ctx context.Context, portal *bridgev2.Portal, info *types.MessageInfo, msg *waE2E.Message, isViewOnce bool, reactions []*waWeb.Reaction,
) (*bridgev2.BackfillMessage, *wadb.MediaRequest) { ) (*bridgev2.BackfillMessage, *wadb.MediaRequest) {
// New messages turn these into edits, but in backfill we only have the last version,
// so no need to do the edit thing. Instead, just unwrap the message.
if msg.GetAssociatedChildMessage().GetMessage() != nil {
msg = msg.GetAssociatedChildMessage().GetMessage()
}
// TODO use proper intent // TODO use proper intent
intent := wa.Main.Bridge.Bot intent := wa.Main.Bridge.Bot
msgID := waid.MakeMessageIDWithAltSender(info.Chat, info.Sender, info.SenderAlt, info.ID)
wrapped := &bridgev2.BackfillMessage{ wrapped := &bridgev2.BackfillMessage{
ConvertedMessage: wa.Main.MsgConv.ToMatrix(ctx, portal, cli, intent, msg, rawMsg, info, isViewOnce, true, nil), ConvertedMessage: wa.Main.MsgConv.ToMatrix(ctx, portal, wa.Client, intent, msg, info, isViewOnce, nil),
Sender: wa.makeEventSender(ctx, pickLID(info.Sender, info.SenderAlt)), Sender: wa.makeEventSender(info.Sender),
ID: msgID, ID: waid.MakeMessageID(info.Chat, info.Sender, info.ID),
TxnID: networkid.TransactionID(msgID), TxnID: networkid.TransactionID(waid.MakeMessageID(info.Chat, info.Sender, info.ID)),
Timestamp: info.Timestamp, Timestamp: info.Timestamp,
StreamOrder: info.Timestamp.Unix(), StreamOrder: info.Timestamp.Unix(),
Reactions: make([]*bridgev2.BackfillReaction, 0, len(reactions)), Reactions: make([]*bridgev2.BackfillReaction, len(reactions)),
} }
mediaReq := wa.processFailedMedia(ctx, portal.PortalKey, wrapped.ID, wrapped.ConvertedMessage, true) mediaReq := wa.processFailedMedia(ctx, portal.PortalKey, wrapped.ID, wrapped.ConvertedMessage, true)
for _, reaction := range reactions { for i, reaction := range reactions {
var sender types.JID var sender types.JID
if reaction.GetKey().GetFromMe() { if reaction.GetKey().GetFromMe() {
sender = wa.GetLID() sender = wa.JID
} else if reaction.GetKey().GetParticipant() != "" { } else if reaction.GetKey().GetParticipant() != "" {
sender, _ = types.ParseJID(*reaction.Key.Participant) sender, _ = types.ParseJID(*reaction.Key.Participant)
} else if info.Chat.Server == types.DefaultUserServer || info.Chat.Server == types.HiddenUserServer || info.Chat.Server == types.BotServer { } else if info.Chat.Server == types.DefaultUserServer || info.Chat.Server == types.BotServer {
sender = info.Chat sender = info.Chat
} }
if sender.IsEmpty() { if sender.IsEmpty() {
continue continue
} }
wrapped.Reactions = append(wrapped.Reactions, &bridgev2.BackfillReaction{ wrapped.Reactions[i] = &bridgev2.BackfillReaction{
TargetPart: ptr.Ptr(networkid.PartID("")), TargetPart: ptr.Ptr(networkid.PartID("")),
Timestamp: time.UnixMilli(reaction.GetSenderTimestampMS()), Timestamp: time.UnixMilli(reaction.GetSenderTimestampMS()),
Sender: wa.makeEventSender(ctx, sender), Sender: wa.makeEventSender(sender),
Emoji: reaction.GetText(), Emoji: reaction.GetText(),
}) }
} }
return wrapped, mediaReq return wrapped, mediaReq
} }

Some files were not shown because too many files have changed in this diff Show more