1
0
Fork 0
mirror of https://github.com/mautrix/whatsapp.git synced 2026-05-15 10:16:52 -04:00

Compare commits

..

1 commit

Author SHA1 Message Date
Tulir Asokan
44611aa850 dependencies: update mautrix-go 2025-05-05 23:39:10 +03:00
60 changed files with 1312 additions and 4049 deletions

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.25", "1.26"] go-version: ["1.23", "1.24"]
name: Lint ${{ matrix.go-version == '1.26' && '(latest)' || '(old)' }} name: Lint ${{ matrix.go-version == '1.24' && '(latest)' || '(old)' }}
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
- name: Set up Go - name: Set up Go
uses: actions/setup-go@v6 uses: actions/setup-go@v5
with: with:
go-version: ${{ matrix.go-version }} go-version: ${{ matrix.go-version }}
cache: true cache: true

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:

View file

@ -1,146 +1,7 @@
# 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) # v0.12.0 (2025-04-16)
* Migrated Signal session store to use new `@lid` identifiers to support future * Migrated Signal session store to use new `@lid` identifiers to support future
chats that don't expose phone numbers. 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 fallbacks for various business message types.
* Added support for bridging invites, kicks and leaves in groups. * Added support for bridging invites, kicks and leaves in groups.
* Re-added `invite-link`, `join` and `sync` commands for groups. * Re-added `invite-link`, `join` and `sync` commands for groups.

View file

@ -1,4 +1,4 @@
FROM golang:1-alpine3.23 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.23 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.23
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

@ -1,23 +1,48 @@
package main package main
import ( import (
"context"
"errors"
"net/http" "net/http"
"regexp"
"strings" "strings"
"github.com/gorilla/mux"
"github.com/gorilla/websocket"
"github.com/rs/zerolog/hlog" "github.com/rs/zerolog/hlog"
"go.mau.fi/util/exhttp" "go.mau.fi/util/exhttp"
"go.mau.fi/whatsmeow"
"go.mau.fi/whatsmeow/appstate"
"go.mau.fi/whatsmeow/types" "go.mau.fi/whatsmeow/types"
"maunium.net/go/mautrix" "maunium.net/go/mautrix"
"maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/matrix" "maunium.net/go/mautrix/bridgev2/matrix"
"maunium.net/go/mautrix/bridgev2/status"
"maunium.net/go/mautrix/id" "maunium.net/go/mautrix/id"
"go.mau.fi/mautrix-whatsapp/pkg/connector" "go.mau.fi/mautrix-whatsapp/pkg/connector"
"go.mau.fi/mautrix-whatsapp/pkg/waid" "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 { type OtherUserInfo struct {
MXID id.UserID `json:"mxid"` MXID id.UserID `json:"mxid"`
JID types.JID `json:"jid"` JID types.JID `json:"jid"`
@ -38,12 +63,180 @@ type Error struct {
ErrCode string `json:"errcode"` 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) { func legacyProvContacts(w http.ResponseWriter, r *http.Request) {
userLogin := m.Matrix.Provisioning.GetLoginForRequest(w, r) userLogin := m.Matrix.Provisioning.GetLoginForRequest(w, r)
if userLogin == nil { if userLogin == nil {
return return
} }
if contacts, err := userLogin.Client.(*connector.WhatsAppClient).GetStore().Contacts.GetAllContacts(r.Context()); err != nil { if contacts, err := userLogin.Client.(*connector.WhatsAppClient).Device.Contacts.GetAllContacts(); err != nil {
hlog.FromRequest(r).Err(err).Msg("Failed to fetch all contacts") hlog.FromRequest(r).Err(err).Msg("Failed to fetch all contacts")
exhttp.WriteJSONResponse(w, http.StatusInternalServerError, Error{ exhttp.WriteJSONResponse(w, http.StatusInternalServerError, Error{
Error: "Internal server error while fetching contact list", Error: "Internal server error while fetching contact list",
@ -70,7 +263,7 @@ func legacyProvContacts(w http.ResponseWriter, r *http.Request) {
} }
func legacyProvResolveIdentifier(w http.ResponseWriter, r *http.Request) { func legacyProvResolveIdentifier(w http.ResponseWriter, r *http.Request) {
number := r.PathValue("number") number := mux.Vars(r)["number"]
userLogin := m.Matrix.Provisioning.GetLoginForRequest(w, r) userLogin := m.Matrix.Provisioning.GetLoginForRequest(w, r)
if userLogin == nil { if userLogin == nil {
return return
@ -113,41 +306,3 @@ func legacyProvResolveIdentifier(w http.ResponseWriter, r *http.Request) {
}, },
}) })
} }
func provAppStateDebug(w http.ResponseWriter, r *http.Request) {
userLogin := m.Matrix.Provisioning.GetLoginForRequest(w, r)
if userLogin == nil {
return
}
client := userLogin.Client.(*connector.WhatsAppClient)
if client.Client == nil {
mautrix.MNotFound.WithMessage("WhatsApp client not connected").Write(w)
return
}
client.Client.AppStateDebugLogs = true
err := client.Client.FetchAppState(r.Context(), appstate.WAPatchName(r.PathValue("patch")), r.URL.Query().Get("full") == "1", false)
client.Client.AppStateDebugLogs = false
if err != nil {
mautrix.MUnknown.WithMessage("Failed to fetch app state: %v", err).Write(w)
} else {
exhttp.WriteEmptyJSONResponse(w, http.StatusOK)
}
}
func provRecoverAppStateDebug(w http.ResponseWriter, r *http.Request) {
userLogin := m.Matrix.Provisioning.GetLoginForRequest(w, r)
if userLogin == nil {
return
}
client := userLogin.Client.(*connector.WhatsAppClient)
if client.Client == nil {
mautrix.MNotFound.WithMessage("WhatsApp client not connected").Write(w)
return
}
resp, err := client.Client.SendPeerMessage(r.Context(), whatsmeow.BuildAppStateRecoveryRequest(appstate.WAPatchName(r.PathValue("patch"))))
if err != nil {
mautrix.MUnknown.WithMessage("Failed to send app state recovery request: %v", err).Write(w)
} else {
exhttp.WriteJSONResponse(w, http.StatusOK, resp)
}
}

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,23 +18,37 @@ 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.04", Version: "0.12.0",
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() { m.PostStart = func() {
if m.Matrix.Provisioning != nil { if m.Matrix.Provisioning != nil {
m.Matrix.Provisioning.Router.HandleFunc("GET /v1/contacts", legacyProvContacts) m.Matrix.Provisioning.Router.HandleFunc("/v1/login", legacyProvLogin).Methods(http.MethodGet)
m.Matrix.Provisioning.Router.HandleFunc("GET /v1/resolve_identifier/{number}", legacyProvResolveIdentifier) m.Matrix.Provisioning.Router.HandleFunc("/v1/logout", legacyProvLogout).Methods(http.MethodPost)
m.Matrix.Provisioning.Router.HandleFunc("POST /v1/pm/{number}", legacyProvResolveIdentifier) m.Matrix.Provisioning.Router.HandleFunc("/v1/contacts", legacyProvContacts).Methods(http.MethodGet)
m.Matrix.Provisioning.Router.HandleFunc("POST /v1/debug/appstate/{patch}", provAppStateDebug) m.Matrix.Provisioning.Router.HandleFunc("/v1/resolve_identifier/{number}", legacyProvResolveIdentifier).Methods(http.MethodGet)
m.Matrix.Provisioning.Router.HandleFunc("POST /v1/debug/recover-appstate/{patch}", provRecoverAppStateDebug) 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)

View file

@ -1,24 +0,0 @@
//go:build amd64 && cgo && !noplugin
package main
import (
"fmt"
"os"
"plugin"
"go.mau.fi/util/exerrors"
"go.mau.fi/mautrix-whatsapp/pkg/connector"
)
func init() {
path := os.Getenv("WM_PLUGIN_PATH")
if path == "" {
return
}
fmt.Println("Loading plugin from", path)
plug := exerrors.Must(plugin.Open(path))
sym := exerrors.Must(plug.Lookup("NewClient"))
connector.NewMC = sym.(connector.NewMCFunc)
}

57
go.mod
View file

@ -1,53 +1,48 @@
module go.mau.fi/mautrix-whatsapp module go.mau.fi/mautrix-whatsapp
go 1.25.0 go 1.23.0
toolchain go1.26.2 toolchain go1.24.2
tool go.mau.fi/util/cmd/maubuild
require ( require (
github.com/lib/pq v1.12.3 github.com/gorilla/mux v1.8.0
github.com/rs/zerolog v1.35.1 github.com/gorilla/websocket v1.5.0
github.com/tidwall/gjson v1.18.0 github.com/lib/pq v1.10.9
go.mau.fi/util v0.9.9-0.20260511124621-9241e81bdf25 github.com/rs/zerolog v1.34.0
go.mau.fi/util v0.8.7-0.20250427215252-d2d18a7e463c
go.mau.fi/webp v0.2.0 go.mau.fi/webp v0.2.0
go.mau.fi/whatsmeow v0.0.0-20260513140310-c551a4055c0f go.mau.fi/whatsmeow v0.0.0-20250501130609-4c93ee4e6efa
golang.org/x/image v0.39.0 golang.org/x/image v0.26.0
golang.org/x/net v0.53.0 golang.org/x/net v0.39.0
golang.org/x/sync v0.20.0 golang.org/x/sync v0.13.0
google.golang.org/protobuf v1.36.11 google.golang.org/protobuf v1.36.6
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
maunium.net/go/mautrix v0.27.1-0.20260513120123-5fba7e3afae4 maunium.net/go/mautrix v0.23.4-0.20250505203826-970ea996a2f4
) )
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.14 // indirect
github.com/coreos/go-systemd/v22 v22.7.0 // indirect
github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.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.44 // indirect github.com/mattn/go-sqlite3 v1.14.27 // indirect
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 // indirect github.com/petermattis/goid v0.0.0-20250319124200-ccd6737f222a // 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.10 // indirect
github.com/yuin/goldmark v1.8.2 // indirect go.mau.fi/libsignal v0.1.2 // indirect
go.mau.fi/libsignal v0.2.1 // indirect go.mau.fi/zeroconfig v0.1.3 // indirect
go.mau.fi/zeroconfig v0.2.0 // indirect golang.org/x/crypto v0.37.0 // indirect
golang.org/x/crypto v0.50.0 // indirect golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect golang.org/x/sys v0.32.0 // indirect
golang.org/x/mod v0.35.0 // indirect golang.org/x/text v0.24.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/text v0.36.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

118
go.sum
View file

@ -1,26 +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.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
github.com/coder/websocket v1.8.14/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/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/elliotchance/orderedmap/v3 v3.1.0 h1:j4DJ5ObEmMBt/lcwIecKcoRxIQUEnw0L804lXYDt/pg= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/elliotchance/orderedmap/v3 v3.1.0/go.mod h1:G+Hc2RwaZvJMcS4JpGCOyViCnGeKf0bTYCGTO4uhjSo= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
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=
@ -28,17 +23,21 @@ 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.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8= github.com/mattn/go-sqlite3 v1.14.27 h1:drZCnuvf37yPfs95E5jd9s3XhdVWLal+6BOK6qrv6IU=
github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= github.com/mattn/go-sqlite3 v1.14.27/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 h1:WDsQxOJDy0N1VRAjXLpi8sCEZRSGarLWQevDxpTBRrM= github.com/petermattis/goid v0.0.0-20250319124200-ccd6737f222a h1:S+AGcmAESQ0pXCUNnRH7V+bOUIgkSX5qVt2cNKCrm0Q=
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= github.com/petermattis/goid v0.0.0-20250319124200-ccd6737f222a/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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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=
@ -46,58 +45,53 @@ github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjR
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.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 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.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= 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.10 h1:S+LrtBjRmqMac2UdtB6yyCEJm+UILZ2fefI4p7o0QpI=
github.com/vektah/gqlparser/v2 v2.5.27/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo= github.com/yuin/goldmark v1.7.10/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE= go.mau.fi/libsignal v0.1.2 h1:Vs16DXWxSKyzVtI+EEXLCSy5pVWzzCzp/2eqFGvLyP0=
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= go.mau.fi/libsignal v0.1.2/go.mod h1:JpnLSSJptn/s1sv7I56uEMywvz8x4YzxeF5OzdPb6PE=
go.mau.fi/libsignal v0.2.1 h1:vRZG4EzTn70XY6Oh/pVKrQGuMHBkAWlGRC22/85m9L0= go.mau.fi/util v0.8.7-0.20250427215252-d2d18a7e463c h1:qfJyMZq1pPyuXKoVWwHs6OmR9CzO3pHFRPYT/QpaaaA=
go.mau.fi/libsignal v0.2.1/go.mod h1:iVvjrHyfQqWajOUaMEsIfo3IqgVMrhWcPiiEzk7NgoU= go.mau.fi/util v0.8.7-0.20250427215252-d2d18a7e463c/go.mod h1:uNB3UTXFbkpp7xL1M/WvQks90B/L4gvbLpbS0603KOE=
go.mau.fi/util v0.9.9-0.20260511124621-9241e81bdf25 h1:YPEmc+li7TF6C9AdRTcSLMb6yCHdF27/wNT7kFLIVNg=
go.mau.fi/util v0.9.9-0.20260511124621-9241e81bdf25/go.mod h1:jE9FfhbgEgAwxei6lomO9v8zdCIATcquONUu4vjRwSs=
go.mau.fi/webp v0.2.0 h1:QVMenHw7JDb4vall5sV75JNBQj9Hw4u8AKbi1QetHvg= go.mau.fi/webp v0.2.0 h1:QVMenHw7JDb4vall5sV75JNBQj9Hw4u8AKbi1QetHvg=
go.mau.fi/webp v0.2.0/go.mod h1:VSg9MyODn12Mb5pyG0NIyNFhujrmoFSsZBs8syOZD1Q= go.mau.fi/webp v0.2.0/go.mod h1:VSg9MyODn12Mb5pyG0NIyNFhujrmoFSsZBs8syOZD1Q=
go.mau.fi/whatsmeow v0.0.0-20260513140310-c551a4055c0f h1:icWtsD1MH5nlo8mEpHMPZ9+1kgHkjmXQroYi0lHXKZ0= go.mau.fi/whatsmeow v0.0.0-20250501130609-4c93ee4e6efa h1:+bQKfMtnhX2jVoCSaneH4Ctk51IVT1K2gvjyqfFjVW0=
go.mau.fi/whatsmeow v0.0.0-20260513140310-c551a4055c0f/go.mod h1:ijfkzOXauA/Vz/htXEMfOAJSUgglribW5oQeYC9tSSg= go.mau.fi/whatsmeow v0.0.0-20250501130609-4c93ee4e6efa/go.mod h1:NlPtoLdpX3RnltqCTCZQ6kIUfprqLirtSK1gHvwoNx0=
go.mau.fi/zeroconfig v0.2.0 h1:e/OGEERqVRRKlgaro7E6bh8xXiKFSXB3eNNIud7FUjU= go.mau.fi/zeroconfig v0.1.3 h1:As9wYDKmktjmNZW5i1vn8zvJlmGKHeVxHVIBMXsm4kM=
go.mau.fi/zeroconfig v0.2.0/go.mod h1:J0Vn0prHNOm493oZoQ84kq83ZaNCYZnq+noI1b1eN8w= go.mau.fi/zeroconfig v0.1.3/go.mod h1:NcSJkf180JT+1IId76PcMuLTNa1CzsFFZ0nBygIQM70=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww= golang.org/x/image v0.26.0 h1:4XjIFEZWQmCZi6Wv8BoxsDhRU3RVnLX04dToTDAEPlY=
golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA= golang.org/x/image v0.26.0/go.mod h1:lcxbMFAovzpnJxzXS3nyL83K27tmqtKzIJpctK8YO5c=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
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.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
google.golang.org/protobuf v1.36.11/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=
@ -107,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.27.1-0.20260513120123-5fba7e3afae4 h1:zNC9eVAhw8FhKpM3AxNAh/iy75UEYX91uJUvqqAYlvo= maunium.net/go/mautrix v0.23.4-0.20250505203826-970ea996a2f4 h1:cia1//Az4ApDJVg15RxVX6j5LRs7ap3lGbD3IltEGyQ=
maunium.net/go/mautrix v0.27.1-0.20260513120123-5fba7e3afae4/go.mod h1:3sOGhXi3P1V6/NruTA0gujkvTypXVUraWktCuTGyDuM= maunium.net/go/mautrix v0.23.4-0.20250505203826-970ea996a2f4/go.mod h1:pT4G5RZQ+nLfKzsmeDa4NhHghOVTrasLLwY9tZ2mO08=

View file

@ -8,7 +8,6 @@ import (
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
"sync/atomic"
"time" "time"
"github.com/rs/zerolog" "github.com/rs/zerolog"
@ -29,149 +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 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 = wa.downloadAndSaveWAHistorySyncData(ctx, notif, rowid)
// Continue waking up the loop until all queued notifications are processed
select {
case wa.historySyncWakeup <- struct{}{}:
default:
}
}
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 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()
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 err != nil {
log.Err(err).Msg("Failed to download history sync")
return
}
if blob.GetSyncType() == waHistorySync.HistorySync_ON_DEMAND {
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")
} 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").
@ -183,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())).
@ -196,57 +76,35 @@ 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 { }
totalMessageCount += len(conv.GetMessages()) totalMessageCount += len(conv.GetMessages())
} log := log.With().
if jid.Server == types.HiddenUserServer { Stringer("chat_jid", jid).
pn, err := wa.GetStore().LIDs.GetPNForLID(ctx, jid) Int("msg_count", len(conv.GetMessages())).
if err != nil { Logger()
log.Err(err).Stringer("lid", jid).Msg("Failed to get PN for LID in history sync")
} else if pn.IsEmpty() {
log.Warn().Stringer("lid", jid).Msg("No PN found for LID in history sync")
} else {
log.Debug().
Stringer("lid", jid).
Stringer("pn", pn).
Msg("Rerouting LID DM to phone number in history sync")
jid = pn
}
}
log.UpdateContext(func(c zerolog.Context) zerolog.Context {
return c.Stringer("chat_jid", jid)
})
var minTime, maxTime, firstItemTime, lastItemTime time.Time var minTime, maxTime time.Time
var minTimeIndex, maxTimeIndex int var minTimeIndex, maxTimeIndex int
ignoredTypes := 0 ignoredTypes := 0
@ -262,10 +120,6 @@ func (wa *WhatsAppClient) handleWAHistorySync(
Msg("Dropping historical message due to parse error") Msg("Dropping historical message due to parse error")
continue continue
} }
if firstItemTime.IsZero() {
firstItemTime = msgEvt.Info.Timestamp
}
lastItemTime = msgEvt.Info.Timestamp
if minTime.IsZero() || msgEvt.Info.Timestamp.Before(minTime) { if minTime.IsZero() || msgEvt.Info.Timestamp.Before(minTime) {
minTime = msgEvt.Info.Timestamp minTime = msgEvt.Info.Timestamp
minTimeIndex = i minTimeIndex = i
@ -298,9 +152,6 @@ func (wa *WhatsAppClient) handleWAHistorySync(
Int("lowest_time_index", minTimeIndex). Int("lowest_time_index", minTimeIndex).
Time("highest_time", maxTime). Time("highest_time", maxTime).
Int("highest_time_index", maxTimeIndex). Int("highest_time_index", maxTimeIndex).
Time("first_item_time", firstItemTime).
Time("last_item_time", lastItemTime).
Bool("highest_time_mismatch", firstItemTime != maxTime).
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()).
@ -309,47 +160,30 @@ func (wa *WhatsAppClient) handleWAHistorySync(
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, maxTime)) err = wa.Main.DB.Conversation.Put(ctx, wadb.NewConversation(wa.UserLogin.ID, jid, conv, maxTime))
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) {
@ -358,17 +192,13 @@ func (wa *WhatsAppClient) createPortalsFromHistorySync(ctx context.Context) {
Logger() Logger()
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))
@ -384,19 +214,14 @@ 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 { } else if conv.ChatJID == types.PSAJID {
// We don't currently support new PSAs, so don't bother backfilling them either // We don't currently support new PSAs, so don't bother backfilling them either
wg.Done() wg.Done()
continue 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():
log.Warn().Err(ctx.Err()).Msg("Context cancelled, stopping history sync portal creation")
return
}
wrappedInfo, err := wa.getChatInfo(ctx, conv.ChatJID, conv, true)
if errors.Is(err, whatsmeow.ErrNotInGroup) { if errors.Is(err, whatsmeow.ErrNotInGroup) {
log.Debug().Stringer("chat_jid", conv.ChatJID). log.Debug().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")
@ -416,19 +241,14 @@ 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: func(c zerolog.Context) zerolog.Context {
@ -439,7 +259,7 @@ func (wa *WhatsAppClient) createPortalsFromHistorySync(ctx context.Context) {
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")
} }
@ -449,10 +269,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() {
@ -473,67 +289,38 @@ func (wa *WhatsAppClient) FetchMessages(ctx context.Context, params bridgev2.Fet
} }
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)
if hasMore && !params.AllowSlowFetch {
return &bridgev2.FetchMessagesResponse{
MoreRequiresSlowFetch: true,
HasMore: true,
Forward: params.Forward,
}, nil
} else if hasMore {
return wa.fetchMessagesFromPhone(ctx, 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.
@ -544,78 +331,17 @@ func (wa *WhatsAppClient) FetchMessages(ctx context.Context, params bridgev2.Fet
} }
} }
} }
resp, err := wa.convertHistorySyncMessages(ctx, params.Portal, portalJID, messages, true) convertedMessages := make([]*bridgev2.BackfillMessage, len(messages))
if err != nil {
return nil, fmt.Errorf("failed to convert messages: %w", err)
}
resp.HasMore = hasMore
resp.Forward = params.Forward
resp.MarkRead = markRead
return resp, nil
}
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,
) (*bridgev2.FetchMessagesResponse, error) {
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
for i, msg := range messages { for i, msg := range messages {
evt, err := wa.Client.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")
continue
}
if !explodeOnError {
msgType := getMessageType(evt.Message)
if msgType == "ignore" || strings.HasPrefix(msgType, "unknown_protocol_") {
continue
}
}
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, portal, &evt.Info, evt.Message, evt.RawMessage, isViewOnce, msg.Reactions,
)
convertedMessages = append(convertedMessages, converted)
if mediaReq != nil { if mediaReq != nil {
mediaRequests = append(mediaRequests, mediaReq) mediaRequests = append(mediaRequests, mediaReq)
} }
@ -624,10 +350,24 @@ 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() {
// TODO this only deletes after backfilling. If there's no need for backfill after a relogin, // TODO this only deletes after backfilling. If there's no need for backfill after a relogin,
// the messages will be stuck in the database // the messages will be stuck in the database
wa.deleteHistorySyncMessages(ctx, portalJID, newestTS, oldestTS) 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 {
// 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) {
for _, req := range mediaRequests { for _, req := range mediaRequests {
@ -645,115 +385,22 @@ func (wa *WhatsAppClient) convertHistorySyncMessages(
}, nil }, nil
} }
func (wa *WhatsAppClient) fetchMessagesFromPhone(ctx context.Context, params bridgev2.FetchMessagesParams) (*bridgev2.FetchMessagesResponse, error) {
if params.AnchorMessage == nil {
return nil, fmt.Errorf("anchor message is required to fetch messages from phone")
}
parsed, err := waid.ParseMessageID(params.AnchorMessage.ID)
if err != nil {
return nil, fmt.Errorf("failed to parse anchor message ID: %w", err)
}
msgID := wa.Client.GenerateMessageID()
reqData := wa.Client.BuildHistorySyncRequest(&types.MessageInfo{
MessageSource: types.MessageSource{
Chat: parsed.Chat,
Sender: parsed.Sender,
IsFromMe: parsed.Sender.ToNonAD() == wa.JID.ToNonAD() || parsed.Sender.ToNonAD() == wa.Device.GetLID().ToNonAD(),
IsGroup: parsed.Chat.Server == types.GroupServer,
},
ID: parsed.ID,
Timestamp: params.AnchorMessage.Timestamp,
}, 50)
zerolog.Ctx(ctx).Debug().
Str("request_msg_id", msgID).
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 {
return nil, fmt.Errorf("failed to send history sync request: %w", err)
}
return &bridgev2.FetchMessagesResponse{
HasMore: true,
Pending: true,
}, nil
}
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
}
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)
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, 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
wrapped := &bridgev2.BackfillMessage{ wrapped := &bridgev2.BackfillMessage{
ConvertedMessage: wa.Main.MsgConv.ToMatrix(ctx, portal, wa.Client, 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, info.Sender), Sender: wa.makeEventSender(info.Sender),
ID: waid.MakeMessageID(info.Chat, info.Sender, info.ID), ID: waid.MakeMessageID(info.Chat, info.Sender, info.ID),
TxnID: networkid.TransactionID(waid.MakeMessageID(info.Chat, info.Sender, info.ID)), 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.JID sender = wa.JID
@ -765,12 +412,12 @@ func (wa *WhatsAppClient) convertHistorySyncMessage(
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
} }

View file

@ -8,7 +8,6 @@ import (
"go.mau.fi/util/jsontime" "go.mau.fi/util/jsontime"
"go.mau.fi/util/ptr" "go.mau.fi/util/ptr"
"maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/database"
"maunium.net/go/mautrix/event" "maunium.net/go/mautrix/event"
"go.mau.fi/mautrix-whatsapp/pkg/waid" "go.mau.fi/mautrix-whatsapp/pkg/waid"
@ -17,34 +16,6 @@ import (
var WhatsAppGeneralCaps = &bridgev2.NetworkGeneralCapabilities{ var WhatsAppGeneralCaps = &bridgev2.NetworkGeneralCapabilities{
DisappearingMessages: true, DisappearingMessages: true,
AggressiveUpdateInfo: true, AggressiveUpdateInfo: true,
ImplicitReadReceipts: true,
Provisioning: bridgev2.ProvisioningCapabilities{
ImagePackImport: true,
ResolveIdentifier: bridgev2.ResolveIdentifierCapabilities{
CreateDM: true,
LookupPhone: true,
ContactList: true,
},
GroupCreation: map[string]bridgev2.GroupTypeCapabilities{
"group": {
TypeDescription: "a group chat",
Name: bridgev2.GroupFieldCapability{Allowed: true, MaxLength: 100},
Disappear: bridgev2.GroupFieldCapability{Allowed: true, DisappearSettings: waDisappearingCap},
Participants: bridgev2.GroupFieldCapability{Allowed: true, Required: true, MinLength: 1},
Parent: bridgev2.GroupFieldCapability{Allowed: true},
},
},
},
}
var waDisappearingCap = &event.DisappearingTimerCapability{
Types: []event.DisappearingType{event.DisappearingTypeAfterSend},
Timers: []jsontime.Milliseconds{
jsontime.MS(24 * time.Hour), // 24 hours
jsontime.MS(7 * 24 * time.Hour), // 7 days
jsontime.MS(90 * 24 * time.Hour), // 90 days
},
} }
func (wa *WhatsAppConnector) GetCapabilities() *bridgev2.NetworkGeneralCapabilities { func (wa *WhatsAppConnector) GetCapabilities() *bridgev2.NetworkGeneralCapabilities {
@ -52,7 +23,7 @@ func (wa *WhatsAppConnector) GetCapabilities() *bridgev2.NetworkGeneralCapabilit
} }
func (wa *WhatsAppConnector) GetBridgeInfoVersion() (info, caps int) { func (wa *WhatsAppConnector) GetBridgeInfoVersion() (info, caps int) {
return 1, 8 return 1, 1
} }
const WAMaxFileSize = 2000 * 1024 * 1024 const WAMaxFileSize = 2000 * 1024 * 1024
@ -67,7 +38,7 @@ func supportedIfFFmpeg() event.CapabilitySupportLevel {
} }
func capID() string { func capID() string {
base := "fi.mau.whatsapp.capabilities.2026_05_12" base := "fi.mau.whatsapp.capabilities.2025_01_10"
if ffmpeg.Supported() { if ffmpeg.Supported() {
return base + "+ffmpeg" return base + "+ffmpeg"
} }
@ -95,8 +66,8 @@ var whatsappCaps = &event.RoomFeatures{
File: map[event.CapabilityMsgType]*event.FileFeatures{ File: map[event.CapabilityMsgType]*event.FileFeatures{
event.MsgImage: { event.MsgImage: {
MimeTypes: map[string]event.CapabilitySupportLevel{ MimeTypes: map[string]event.CapabilitySupportLevel{
"image/png": event.CapLevelFullySupported,
"image/jpeg": event.CapLevelFullySupported, "image/jpeg": event.CapLevelFullySupported,
"image/png": event.CapLevelPartialSupport,
"image/webp": event.CapLevelPartialSupport, "image/webp": event.CapLevelPartialSupport,
"image/gif": supportedIfFFmpeg(), "image/gif": supportedIfFFmpeg(),
}, },
@ -126,10 +97,10 @@ var whatsappCaps = &event.RoomFeatures{
event.CapMsgSticker: { event.CapMsgSticker: {
MimeTypes: map[string]event.CapabilitySupportLevel{ MimeTypes: map[string]event.CapabilitySupportLevel{
"image/webp": event.CapLevelFullySupported, "image/webp": event.CapLevelFullySupported,
// TODO see if sending lottie is possible
//"video/lottie+json": event.CapLevelFullySupported,
"image/png": event.CapLevelPartialSupport, "image/png": event.CapLevelPartialSupport,
"image/jpeg": event.CapLevelPartialSupport, "image/jpeg": event.CapLevelPartialSupport,
// This will only be accepted if it was imported from WhatsApp
"video/lottie+json": event.CapLevelPartialSupport,
}, },
Caption: event.CapLevelDropped, Caption: event.CapLevelDropped,
MaxSize: WAMaxFileSize, MaxSize: WAMaxFileSize,
@ -148,7 +119,6 @@ var whatsappCaps = &event.RoomFeatures{
"video/mp4": event.CapLevelFullySupported, "video/mp4": event.CapLevelFullySupported,
"video/3gpp": event.CapLevelFullySupported, "video/3gpp": event.CapLevelFullySupported,
"video/webm": supportedIfFFmpeg(), "video/webm": supportedIfFFmpeg(),
"video/quicktime": supportedIfFFmpeg(),
}, },
Caption: event.CapLevelFullySupported, Caption: event.CapLevelFullySupported,
MaxCaptionLength: MaxTextLength, MaxCaptionLength: MaxTextLength,
@ -163,22 +133,12 @@ var whatsappCaps = &event.RoomFeatures{
MaxSize: WAMaxFileSize, MaxSize: WAMaxFileSize,
}, },
}, },
State: event.StateFeatureMap{
event.StateRoomName.Type: {Level: event.CapLevelFullySupported},
event.StateRoomAvatar.Type: {Level: event.CapLevelFullySupported},
event.StateTopic.Type: {Level: event.CapLevelFullySupported},
event.StateBeeperDisappearingTimer.Type: {Level: event.CapLevelFullySupported},
},
MemberActions: event.MemberFeatureMap{
event.MemberActionInvite: event.CapLevelFullySupported,
event.MemberActionKick: event.CapLevelFullySupported,
event.MemberActionLeave: event.CapLevelFullySupported,
},
MaxTextLength: MaxTextLength, MaxTextLength: MaxTextLength,
LocationMessage: event.CapLevelFullySupported, LocationMessage: event.CapLevelFullySupported,
Poll: event.CapLevelFullySupported, Poll: event.CapLevelFullySupported,
Reply: event.CapLevelFullySupported, Reply: event.CapLevelFullySupported,
Edit: event.CapLevelFullySupported, Edit: event.CapLevelFullySupported,
EditMaxCount: 10,
EditMaxAge: ptr.Ptr(jsontime.S(EditMaxAge)), EditMaxAge: ptr.Ptr(jsontime.S(EditMaxAge)),
Delete: event.CapLevelFullySupported, Delete: event.CapLevelFullySupported,
DeleteForMe: false, DeleteForMe: false,
@ -187,20 +147,11 @@ var whatsappCaps = &event.RoomFeatures{
ReactionCount: 1, ReactionCount: 1,
ReadReceipts: true, ReadReceipts: true,
TypingNotifications: true, TypingNotifications: true,
DisappearingTimer: waDisappearingCap,
DeleteChat: true,
} }
var whatsappDMCaps *event.RoomFeatures
var whatsappCAGCaps *event.RoomFeatures var whatsappCAGCaps *event.RoomFeatures
func init() { func init() {
whatsappDMCaps = ptr.Clone(whatsappCaps)
whatsappDMCaps.ID = capID() + "+dm"
whatsappDMCaps.State = event.StateFeatureMap{
event.StateBeeperDisappearingTimer.Type: {Level: event.CapLevelFullySupported},
}
whatsappDMCaps.MemberActions = nil
whatsappCAGCaps = ptr.Clone(whatsappCaps) whatsappCAGCaps = ptr.Clone(whatsappCaps)
whatsappCAGCaps.ID = capID() + "+cag" whatsappCAGCaps.ID = capID() + "+cag"
whatsappCAGCaps.Reply = event.CapLevelUnsupported whatsappCAGCaps.Reply = event.CapLevelUnsupported
@ -210,8 +161,6 @@ func init() {
func (wa *WhatsAppClient) GetCapabilities(ctx context.Context, portal *bridgev2.Portal) *event.RoomFeatures { func (wa *WhatsAppClient) GetCapabilities(ctx context.Context, portal *bridgev2.Portal) *event.RoomFeatures {
if portal.Metadata.(*waid.PortalMetadata).CommunityAnnouncementGroup { if portal.Metadata.(*waid.PortalMetadata).CommunityAnnouncementGroup {
return whatsappCAGCaps return whatsappCAGCaps
} else if portal.RoomType == database.RoomTypeDM {
return whatsappDMCaps
} }
return whatsappCaps return whatsappCaps
} }

View file

@ -26,41 +26,40 @@ func (wa *WhatsAppClient) GetChatInfo(ctx context.Context, portal *bridgev2.Port
if err != nil { if err != nil {
return nil, err return nil, err
} }
return wa.getChatInfo(ctx, portalJID, nil, portal.MXID == "") return wa.getChatInfo(ctx, portalJID, nil)
} }
func (wa *WhatsAppClient) getChatInfo(ctx context.Context, portalJID types.JID, conv *wadb.Conversation, isNew bool) (wrapped *bridgev2.ChatInfo, err error) { func (wa *WhatsAppClient) getChatInfo(ctx context.Context, portalJID types.JID, conv *wadb.Conversation) (wrapped *bridgev2.ChatInfo, err error) {
switch portalJID.Server { switch portalJID.Server {
case types.DefaultUserServer, types.HiddenUserServer, types.BotServer: case types.DefaultUserServer, types.HiddenUserServer, types.BotServer:
wrapped = wa.wrapDMInfo(ctx, portalJID) wrapped = wa.wrapDMInfo(portalJID)
case types.BroadcastServer: case types.BroadcastServer:
if portalJID == types.StatusBroadcastJID { if portalJID == types.StatusBroadcastJID {
wrapped = wa.wrapStatusBroadcastInfo(ctx) wrapped = wa.wrapStatusBroadcastInfo()
} else { } else {
return nil, fmt.Errorf("broadcast list bridging is currently not supported") return nil, fmt.Errorf("broadcast list bridging is currently not supported")
} }
case types.GroupServer: case types.GroupServer:
info, err := wa.Client.GetGroupInfo(ctx, portalJID) info, err := wa.Client.GetGroupInfo(portalJID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
wrapped = wa.wrapGroupInfo(ctx, info) wrapped = wa.wrapGroupInfo(info)
wrapped.ExtraUpdates = bridgev2.MergeExtraUpdaters(wrapped.ExtraUpdates, updatePortalLastSyncAt) wrapped.ExtraUpdates = bridgev2.MergeExtraUpdaters(wrapped.ExtraUpdates, updatePortalLastSyncAt)
case types.NewsletterServer: case types.NewsletterServer:
info, err := wa.Client.GetNewsletterInfo(ctx, portalJID) info, err := wa.Client.GetNewsletterInfo(portalJID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
wrapped = wa.wrapNewsletterInfo(ctx, info) wrapped = wa.wrapNewsletterInfo(info)
default: default:
return nil, fmt.Errorf("unsupported server %s", portalJID.Server) return nil, fmt.Errorf("unsupported server %s", portalJID.Server)
} }
wa.addExtrasToWrapped(ctx, portalJID, wrapped, conv, isNew) wa.addExtrasToWrapped(ctx, portalJID, wrapped, conv)
return wrapped, nil return wrapped, nil
} }
func (wa *WhatsAppClient) addExtrasToWrapped(ctx context.Context, portalJID types.JID, wrapped *bridgev2.ChatInfo, conv *wadb.Conversation, isNew bool) { func (wa *WhatsAppClient) addExtrasToWrapped(ctx context.Context, portalJID types.JID, wrapped *bridgev2.ChatInfo, conv *wadb.Conversation) {
if isNew {
if conv == nil { if conv == nil {
var err error var err error
conv, err = wa.Main.DB.Conversation.Get(ctx, wa.UserLogin.ID, portalJID) conv, err = wa.Main.DB.Conversation.Get(ctx, wa.UserLogin.ID, portalJID)
@ -71,13 +70,12 @@ func (wa *WhatsAppClient) addExtrasToWrapped(ctx context.Context, portalJID type
if conv != nil { if conv != nil {
wa.applyHistoryInfo(wrapped, conv) wa.applyHistoryInfo(wrapped, conv)
} }
}
wa.applyChatSettings(ctx, portalJID, wrapped) wa.applyChatSettings(ctx, portalJID, wrapped)
} }
func updatePortalLastSyncAt(_ context.Context, portal *bridgev2.Portal) bool { func updatePortalLastSyncAt(_ context.Context, portal *bridgev2.Portal) bool {
meta := portal.Metadata.(*waid.PortalMetadata) meta := portal.Metadata.(*waid.PortalMetadata)
forceSave := ResyncMinInterval < 24*time.Hour || time.Since(meta.LastSync.Time) > 24*time.Hour forceSave := time.Since(meta.LastSync.Time) > 24*time.Hour
meta.LastSync = jsontime.UnixNow() meta.LastSync = jsontime.UnixNow()
return forceSave return forceSave
} }
@ -94,7 +92,7 @@ func updateDisappearingTimerSetAt(ts int64) bridgev2.ExtraUpdater[*bridgev2.Port
} }
func (wa *WhatsAppClient) applyChatSettings(ctx context.Context, chatID types.JID, info *bridgev2.ChatInfo) { func (wa *WhatsAppClient) applyChatSettings(ctx context.Context, chatID types.JID, info *bridgev2.ChatInfo) {
chat, err := wa.GetStore().ChatSettings.GetChatSettings(ctx, chatID) chat, err := wa.GetStore().ChatSettings.GetChatSettings(chatID)
if err != nil { if err != nil {
zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to get chat settings") zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to get chat settings")
return return
@ -124,7 +122,7 @@ func (wa *WhatsAppClient) applyHistoryInfo(info *bridgev2.ChatInfo, conv *wadb.C
} }
if info.Disappear == nil && ptr.Val(conv.EphemeralExpiration) > 0 { if info.Disappear == nil && ptr.Val(conv.EphemeralExpiration) > 0 {
info.Disappear = &database.DisappearingSetting{ info.Disappear = &database.DisappearingSetting{
Type: event.DisappearingTypeAfterSend, Type: database.DisappearingTypeAfterRead,
Timer: time.Duration(*conv.EphemeralExpiration) * time.Second, Timer: time.Duration(*conv.EphemeralExpiration) * time.Second,
} }
if conv.EphemeralSettingTimestamp != nil { if conv.EphemeralSettingTimestamp != nil {
@ -140,7 +138,7 @@ const UnnamedBroadcastName = "Unnamed broadcast list"
const PrivateChatTopic = "WhatsApp private chat" const PrivateChatTopic = "WhatsApp private chat"
const BotChatTopic = "WhatsApp chat with a bot" const BotChatTopic = "WhatsApp chat with a bot"
func (wa *WhatsAppClient) wrapDMInfo(ctx context.Context, jid types.JID) *bridgev2.ChatInfo { func (wa *WhatsAppClient) wrapDMInfo(jid types.JID) *bridgev2.ChatInfo {
info := &bridgev2.ChatInfo{ info := &bridgev2.ChatInfo{
Topic: ptr.Ptr(PrivateChatTopic), Topic: ptr.Ptr(PrivateChatTopic),
Members: &bridgev2.ChatMemberList{ Members: &bridgev2.ChatMemberList{
@ -148,17 +146,10 @@ func (wa *WhatsAppClient) wrapDMInfo(ctx context.Context, jid types.JID) *bridge
TotalMemberCount: 2, TotalMemberCount: 2,
OtherUserID: waid.MakeUserID(jid), OtherUserID: waid.MakeUserID(jid),
MemberMap: map[networkid.UserID]bridgev2.ChatMember{ MemberMap: map[networkid.UserID]bridgev2.ChatMember{
waid.MakeUserID(jid): {EventSender: wa.makeEventSender(ctx, jid)}, waid.MakeUserID(jid): {EventSender: wa.makeEventSender(jid)},
waid.MakeUserID(wa.JID): {EventSender: wa.makeEventSender(ctx, wa.JID)}, waid.MakeUserID(wa.JID): {EventSender: wa.makeEventSender(wa.JID)},
},
PowerLevels: &bridgev2.PowerLevelOverrides{
Events: map[event.Type]int{
event.StateRoomName: 0,
event.StateRoomAvatar: 0,
event.StateTopic: 0,
event.StateBeeperDisappearingTimer: 0,
},
}, },
PowerLevels: nil,
}, },
Type: ptr.Ptr(database.RoomTypeDM), Type: ptr.Ptr(database.RoomTypeDM),
} }
@ -175,7 +166,7 @@ func (wa *WhatsAppClient) wrapDMInfo(ctx context.Context, jid types.JID) *bridge
return info return info
} }
func (wa *WhatsAppClient) wrapStatusBroadcastInfo(ctx context.Context) *bridgev2.ChatInfo { func (wa *WhatsAppClient) wrapStatusBroadcastInfo() *bridgev2.ChatInfo {
userLocal := &bridgev2.UserLocalPortalInfo{} userLocal := &bridgev2.UserLocalPortalInfo{}
if wa.Main.Config.MuteStatusBroadcast { if wa.Main.Config.MuteStatusBroadcast {
userLocal.MutedUntil = ptr.Ptr(event.MutedForever) userLocal.MutedUntil = ptr.Ptr(event.MutedForever)
@ -189,7 +180,7 @@ func (wa *WhatsAppClient) wrapStatusBroadcastInfo(ctx context.Context) *bridgev2
Members: &bridgev2.ChatMemberList{ Members: &bridgev2.ChatMemberList{
IsFull: false, IsFull: false,
MemberMap: map[networkid.UserID]bridgev2.ChatMember{ MemberMap: map[networkid.UserID]bridgev2.ChatMember{
waid.MakeUserID(wa.JID): {EventSender: wa.makeEventSender(ctx, wa.JID)}, waid.MakeUserID(wa.JID): {EventSender: wa.makeEventSender(wa.JID)},
}, },
}, },
Type: ptr.Ptr(database.RoomTypeDefault), Type: ptr.Ptr(database.RoomTypeDefault),
@ -227,18 +218,7 @@ func setAddressingMode(mode types.AddressingMode) bridgev2.ExtraUpdater[*bridgev
} }
} }
func setTopicID(id, topic string) bridgev2.ExtraUpdater[*bridgev2.Portal] { func (wa *WhatsAppClient) wrapGroupInfo(info *types.GroupInfo) *bridgev2.ChatInfo {
return func(_ context.Context, portal *bridgev2.Portal) bool {
meta := portal.Metadata.(*waid.PortalMetadata)
if meta.TopicID != id && portal.Topic == topic {
meta.TopicID = id
return true
}
return false
}
}
func (wa *WhatsAppClient) wrapGroupInfo(ctx context.Context, info *types.GroupInfo) *bridgev2.ChatInfo {
sendEventPL := defaultPL sendEventPL := defaultPL
if info.IsAnnounce && !info.IsDefaultSubGroup { if info.IsAnnounce && !info.IsDefaultSubGroup {
sendEventPL = adminPL sendEventPL = adminPL
@ -251,7 +231,6 @@ func (wa *WhatsAppClient) wrapGroupInfo(ctx context.Context, info *types.GroupIn
wa.makePortalAvatarFetcher("", types.EmptyJID, time.Time{}), wa.makePortalAvatarFetcher("", types.EmptyJID, time.Time{}),
setDefaultSubGroupFlag(info.IsDefaultSubGroup && info.IsAnnounce), setDefaultSubGroupFlag(info.IsDefaultSubGroup && info.IsAnnounce),
setAddressingMode(info.AddressingMode), setAddressingMode(info.AddressingMode),
setTopicID(info.TopicID, info.Topic),
) )
wrapped := &bridgev2.ChatInfo{ wrapped := &bridgev2.ChatInfo{
Name: ptr.Ptr(info.Name), Name: ptr.Ptr(info.Name),
@ -271,22 +250,19 @@ func (wa *WhatsAppClient) wrapGroupInfo(ctx context.Context, info *types.GroupIn
event.StateTopic: metaChangePL, event.StateTopic: metaChangePL,
event.EventReaction: defaultPL, event.EventReaction: defaultPL,
event.EventRedaction: defaultPL, event.EventRedaction: defaultPL,
event.StateBeeperDisappearingTimer: metaChangePL,
// TODO always allow poll responses // TODO always allow poll responses
}, },
}, },
}, },
ExcludeChangesFromTimeline: true,
Disappear: &database.DisappearingSetting{ Disappear: &database.DisappearingSetting{
Type: event.DisappearingTypeAfterSend, Type: database.DisappearingTypeAfterRead,
Timer: time.Duration(info.DisappearingTimer) * time.Second, Timer: time.Duration(info.DisappearingTimer) * time.Second,
}, },
ExtraUpdates: extraUpdater, ExtraUpdates: extraUpdater,
} }
for _, pcp := range info.Participants { for _, pcp := range info.Participants {
member := bridgev2.ChatMember{ member := bridgev2.ChatMember{
EventSender: wa.makeEventSender(ctx, pcp.JID), EventSender: wa.makeEventSender(pcp.JID),
Membership: event.MembershipJoin, Membership: event.MembershipJoin,
} }
if pcp.IsSuperAdmin { if pcp.IsSuperAdmin {
@ -296,20 +272,7 @@ func (wa *WhatsAppClient) wrapGroupInfo(ctx context.Context, info *types.GroupIn
} else { } else {
member.PowerLevel = ptr.Ptr(defaultPL) member.PowerLevel = ptr.Ptr(defaultPL)
} }
member.MemberEventExtra = map[string]any{
"com.beeper.exclude_from_timeline": true,
}
wrapped.Members.MemberMap[waid.MakeUserID(pcp.JID)] = member wrapped.Members.MemberMap[waid.MakeUserID(pcp.JID)] = member
if pcp.JID.Server == types.HiddenUserServer && !pcp.PhoneNumber.IsEmpty() {
wrapped.Members.MemberMap[waid.MakeUserID(pcp.PhoneNumber)] = bridgev2.ChatMember{
EventSender: bridgev2.EventSender{Sender: waid.MakeUserID(pcp.PhoneNumber)},
Membership: event.MembershipLeave,
PrevMembership: event.MembershipJoin,
MemberEventExtra: map[string]any{
"com.beeper.exclude_from_timeline": true,
},
}
}
} }
if !info.LinkedParentJID.IsEmpty() { if !info.LinkedParentJID.IsEmpty() {
@ -323,7 +286,7 @@ func (wa *WhatsAppClient) wrapGroupInfo(ctx context.Context, info *types.GroupIn
return wrapped return wrapped
} }
func (wa *WhatsAppClient) wrapGroupInfoChange(ctx context.Context, evt *events.GroupInfo) *bridgev2.ChatInfoChange { func (wa *WhatsAppClient) wrapGroupInfoChange(evt *events.GroupInfo) *bridgev2.ChatInfoChange {
var changes *bridgev2.ChatInfo var changes *bridgev2.ChatInfo
if evt.Name != nil || evt.Topic != nil || evt.Ephemeral != nil || evt.Unlink != nil || evt.Link != nil { if evt.Name != nil || evt.Topic != nil || evt.Ephemeral != nil || evt.Unlink != nil || evt.Link != nil {
changes = &bridgev2.ChatInfo{} changes = &bridgev2.ChatInfo{}
@ -332,11 +295,10 @@ func (wa *WhatsAppClient) wrapGroupInfoChange(ctx context.Context, evt *events.G
} }
if evt.Topic != nil { if evt.Topic != nil {
changes.Topic = &evt.Topic.Topic changes.Topic = &evt.Topic.Topic
changes.ExtraUpdates = bridgev2.MergeExtraUpdaters(changes.ExtraUpdates, setTopicID(evt.Topic.TopicID, evt.Topic.Topic))
} }
if evt.Ephemeral != nil { if evt.Ephemeral != nil {
changes.Disappear = &database.DisappearingSetting{ changes.Disappear = &database.DisappearingSetting{
Type: event.DisappearingTypeAfterSend, Type: database.DisappearingTypeAfterRead,
Timer: time.Duration(evt.Ephemeral.DisappearingTimer) * time.Second, Timer: time.Duration(evt.Ephemeral.DisappearingTimer) * time.Second,
} }
if !evt.Ephemeral.IsEphemeral { if !evt.Ephemeral.IsEphemeral {
@ -358,24 +320,24 @@ func (wa *WhatsAppClient) wrapGroupInfoChange(ctx context.Context, evt *events.G
} }
for _, userID := range evt.Join { for _, userID := range evt.Join {
memberChanges.MemberMap[waid.MakeUserID(userID)] = bridgev2.ChatMember{ memberChanges.MemberMap[waid.MakeUserID(userID)] = bridgev2.ChatMember{
EventSender: wa.makeEventSender(ctx, userID), EventSender: wa.makeEventSender(userID),
} }
} }
for _, userID := range evt.Promote { for _, userID := range evt.Promote {
memberChanges.MemberMap[waid.MakeUserID(userID)] = bridgev2.ChatMember{ memberChanges.MemberMap[waid.MakeUserID(userID)] = bridgev2.ChatMember{
EventSender: wa.makeEventSender(ctx, userID), EventSender: wa.makeEventSender(userID),
PowerLevel: ptr.Ptr(adminPL), PowerLevel: ptr.Ptr(adminPL),
} }
} }
for _, userID := range evt.Demote { for _, userID := range evt.Demote {
memberChanges.MemberMap[waid.MakeUserID(userID)] = bridgev2.ChatMember{ memberChanges.MemberMap[waid.MakeUserID(userID)] = bridgev2.ChatMember{
EventSender: wa.makeEventSender(ctx, userID), EventSender: wa.makeEventSender(userID),
PowerLevel: ptr.Ptr(defaultPL), PowerLevel: ptr.Ptr(defaultPL),
} }
} }
for _, userID := range evt.Leave { for _, userID := range evt.Leave {
memberChanges.MemberMap[waid.MakeUserID(userID)] = bridgev2.ChatMember{ memberChanges.MemberMap[waid.MakeUserID(userID)] = bridgev2.ChatMember{
EventSender: wa.makeEventSender(ctx, userID), EventSender: wa.makeEventSender(userID),
Membership: event.MembershipLeave, Membership: event.MembershipLeave,
} }
} }
@ -421,7 +383,7 @@ func (wa *WhatsAppClient) makePortalAvatarFetcher(avatarID string, sender types.
existingID = "" existingID = ""
} }
var wrappedAvatar *bridgev2.Avatar var wrappedAvatar *bridgev2.Avatar
avatar, err := wa.Client.GetProfilePictureInfo(ctx, jid, &whatsmeow.GetProfilePictureParams{ avatar, err := wa.Client.GetProfilePictureInfo(jid, &whatsmeow.GetProfilePictureParams{
ExistingID: existingID, ExistingID: existingID,
IsCommunity: portal.RoomType == database.RoomTypeSpace, IsCommunity: portal.RoomType == database.RoomTypeSpace,
}) })
@ -440,34 +402,25 @@ func (wa *WhatsAppClient) makePortalAvatarFetcher(avatarID string, sender types.
return false return false
} else if avatar == nil { } else if avatar == nil {
return false return false
} else if wa.Main.MsgConv.DirectMedia {
wrappedAvatar, err = wa.makeDirectMediaAvatar(ctx, jid, avatar, portal.RoomType == database.RoomTypeSpace)
if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to prepare direct media avatar")
return false
}
} else { } else {
wrappedAvatar = &bridgev2.Avatar{ wrappedAvatar = &bridgev2.Avatar{
ID: networkid.AvatarID(avatar.ID), ID: networkid.AvatarID(avatar.ID),
Get: func(ctx context.Context) ([]byte, error) { Get: func(ctx context.Context) ([]byte, error) {
return wa.Client.DownloadMediaWithPath(ctx, avatar.DirectPath, nil, nil, nil, 0, "", "") return wa.Client.DownloadMediaWithPath(avatar.DirectPath, nil, nil, nil, 0, "", "")
}, },
} }
} }
var evtSender bridgev2.EventSender var evtSender bridgev2.EventSender
if !sender.IsEmpty() { if !sender.IsEmpty() {
evtSender = wa.makeEventSender(ctx, sender) evtSender = wa.makeEventSender(sender)
}
senderIntent, ok := portal.GetIntentFor(ctx, evtSender, wa.UserLogin, bridgev2.RemoteEventChatInfoChange)
if !ok {
return false
} }
senderIntent := portal.GetIntentFor(ctx, evtSender, wa.UserLogin, bridgev2.RemoteEventChatInfoChange)
//lint:ignore SA1019 TODO invent a cleaner way to fetch avatar metadata before updating? //lint:ignore SA1019 TODO invent a cleaner way to fetch avatar metadata before updating?
return portal.Internal().UpdateAvatar(ctx, wrappedAvatar, senderIntent, ts, false) return portal.Internal().UpdateAvatar(ctx, wrappedAvatar, senderIntent, ts)
} }
} }
func (wa *WhatsAppClient) wrapNewsletterInfo(ctx context.Context, info *types.NewsletterMetadata) *bridgev2.ChatInfo { func (wa *WhatsAppClient) wrapNewsletterInfo(info *types.NewsletterMetadata) *bridgev2.ChatInfo {
ownPowerLevel := defaultPL ownPowerLevel := defaultPL
var mutedUntil *time.Time var mutedUntil *time.Time
if info.ViewerMeta != nil { if info.ViewerMeta != nil {
@ -485,22 +438,21 @@ func (wa *WhatsAppClient) wrapNewsletterInfo(ctx context.Context, info *types.Ne
} }
} }
avatar := &bridgev2.Avatar{} avatar := &bridgev2.Avatar{}
// TODO direct media for newsletter avatars
if info.ThreadMeta.Picture != nil { if info.ThreadMeta.Picture != nil {
avatar.ID = networkid.AvatarID(info.ThreadMeta.Picture.ID) avatar.ID = networkid.AvatarID(info.ThreadMeta.Picture.ID)
avatar.Get = func(ctx context.Context) ([]byte, error) { avatar.Get = func(ctx context.Context) ([]byte, error) {
return wa.Client.DownloadMediaWithPath(ctx, info.ThreadMeta.Picture.DirectPath, nil, nil, nil, 0, "", "") return wa.Client.DownloadMediaWithPath(info.ThreadMeta.Picture.DirectPath, nil, nil, nil, 0, "", "")
} }
} else if info.ThreadMeta.Preview.ID != "" { } else if info.ThreadMeta.Preview.ID != "" {
avatar.ID = networkid.AvatarID(info.ThreadMeta.Preview.ID) avatar.ID = networkid.AvatarID(info.ThreadMeta.Preview.ID)
avatar.Get = func(ctx context.Context) ([]byte, error) { avatar.Get = func(ctx context.Context) ([]byte, error) {
meta, err := wa.Client.GetNewsletterInfo(ctx, info.ID) meta, err := wa.Client.GetNewsletterInfo(info.ID)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to fetch full res avatar info: %w", err) return nil, fmt.Errorf("failed to fetch full res avatar info: %w", err)
} else if meta.ThreadMeta.Picture == nil { } else if meta.ThreadMeta.Picture == nil {
return nil, fmt.Errorf("full res avatar info is missing") return nil, fmt.Errorf("full res avatar info is missing")
} }
return wa.Client.DownloadMediaWithPath(ctx, meta.ThreadMeta.Picture.DirectPath, nil, nil, nil, 0, "", "") return wa.Client.DownloadMediaWithPath(meta.ThreadMeta.Picture.DirectPath, nil, nil, nil, 0, "", "")
} }
} else { } else {
avatar.ID = "remove" avatar.ID = "remove"
@ -517,7 +469,7 @@ func (wa *WhatsAppClient) wrapNewsletterInfo(ctx context.Context, info *types.Ne
TotalMemberCount: info.ThreadMeta.SubscriberCount, TotalMemberCount: info.ThreadMeta.SubscriberCount,
MemberMap: map[networkid.UserID]bridgev2.ChatMember{ MemberMap: map[networkid.UserID]bridgev2.ChatMember{
waid.MakeUserID(wa.JID): { waid.MakeUserID(wa.JID): {
EventSender: wa.makeEventSender(ctx, wa.JID), EventSender: wa.makeEventSender(wa.JID),
PowerLevel: &ownPowerLevel, PowerLevel: &ownPowerLevel,
}, },
}, },

View file

@ -25,10 +25,9 @@ import (
"time" "time"
"github.com/rs/zerolog" "github.com/rs/zerolog"
"go.mau.fi/util/exsync"
"go.mau.fi/whatsmeow" "go.mau.fi/whatsmeow"
"go.mau.fi/whatsmeow/appstate"
waBinary "go.mau.fi/whatsmeow/binary" waBinary "go.mau.fi/whatsmeow/binary"
"go.mau.fi/whatsmeow/proto/waHistorySync"
"go.mau.fi/whatsmeow/proto/waWa6" "go.mau.fi/whatsmeow/proto/waWa6"
"go.mau.fi/whatsmeow/store" "go.mau.fi/whatsmeow/store"
"go.mau.fi/whatsmeow/types" "go.mau.fi/whatsmeow/types"
@ -38,24 +37,19 @@ import (
"maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/networkid" "maunium.net/go/mautrix/bridgev2/networkid"
"maunium.net/go/mautrix/bridgev2/status" "maunium.net/go/mautrix/bridgev2/status"
"maunium.net/go/mautrix/event"
"go.mau.fi/mautrix-whatsapp/pkg/waid" "go.mau.fi/mautrix-whatsapp/pkg/waid"
) )
func (wa *WhatsAppConnector) LoadUserLogin(ctx context.Context, login *bridgev2.UserLogin) error { func (wa *WhatsAppConnector) LoadUserLogin(_ context.Context, login *bridgev2.UserLogin) error {
w := &WhatsAppClient{ w := &WhatsAppClient{
Main: wa, Main: wa,
UserLogin: login, UserLogin: login,
MC: noopMCInstance,
historySyncWakeup: make(chan struct{}, 1), historySyncs: make(chan *waHistorySync.HistorySync, 64),
resyncQueue: make(map[types.JID]resyncQueueItem), resyncQueue: make(map[types.JID]resyncQueueItem),
directMediaRetries: make(map[networkid.MessageID]*directMediaRetry), directMediaRetries: make(map[networkid.MessageID]*directMediaRetry),
mediaRetryLock: semaphore.NewWeighted(wa.Config.HistorySync.MediaRequests.MaxAsyncHandle), mediaRetryLock: semaphore.NewWeighted(wa.Config.HistorySync.MediaRequests.MaxAsyncHandle),
pushNamesSynced: exsync.NewEvent(),
createDedup: exsync.NewSet[types.MessageID](),
appStateFullSyncAttempted: make(map[appstate.WAPatchName]time.Time),
} }
login.Client = w login.Client = w
@ -66,7 +60,7 @@ func (wa *WhatsAppConnector) LoadUserLogin(ctx context.Context, login *bridgev2.
var err error var err error
w.JID = waid.ParseUserLoginID(login.ID, loginMetadata.WADeviceID) w.JID = waid.ParseUserLoginID(login.ID, loginMetadata.WADeviceID)
w.Device, err = wa.DeviceStore.GetDevice(ctx, w.JID) w.Device, err = wa.DeviceStore.GetDevice(w.JID)
if err != nil { if err != nil {
return err return err
} }
@ -74,18 +68,14 @@ func (wa *WhatsAppConnector) LoadUserLogin(ctx context.Context, login *bridgev2.
if w.Device != nil { if w.Device != nil {
log := w.UserLogin.Log.With().Str("component", "whatsmeow").Logger() log := w.UserLogin.Log.With().Str("component", "whatsmeow").Logger()
w.Client = whatsmeow.NewClient(w.Device, waLog.Zerolog(log)) w.Client = whatsmeow.NewClient(w.Device, waLog.Zerolog(log))
w.Client.AddEventHandlerWithSuccessStatus(w.handleWAEvent) w.Client.AddEventHandler(w.handleWAEvent)
if bridgev2.PortalEventBuffer == 0 {
w.Client.SynchronousAck = true w.Client.SynchronousAck = true
w.Client.EnableDecryptedEventBuffer = bridgev2.PortalEventBuffer == 0 }
w.Client.ManualHistorySyncDownload = true
w.Client.SendReportingTokens = true
w.Client.AutomaticMessageRerequestFromPhone = true w.Client.AutomaticMessageRerequestFromPhone = true
w.Client.GetMessageForRetry = w.trackNotFoundRetry w.Client.GetMessageForRetry = w.trackNotFoundRetry
w.Client.PreRetryCallback = w.trackFoundRetry w.Client.PreRetryCallback = w.trackFoundRetry
w.Client.BackgroundEventCtx = w.UserLogin.Log.WithContext(wa.Bridge.BackgroundCtx)
w.Client.SetForceActiveDeliveryReceipts(wa.Config.ForceActiveDeliveryReceipts) w.Client.SetForceActiveDeliveryReceipts(wa.Config.ForceActiveDeliveryReceipts)
w.Client.InitialAutoReconnect = wa.Config.InitialAutoReconnect
w.Client.UseRetryMessageStore = wa.Config.UseWhatsAppRetryStore
} else { } else {
w.UserLogin.Log.Warn().Stringer("jid", w.JID).Msg("No device found for user in whatsmeow store") w.UserLogin.Log.Warn().Stringer("jid", w.JID).Msg("No device found for user in whatsmeow store")
} }
@ -104,9 +94,8 @@ type WhatsAppClient struct {
Client *whatsmeow.Client Client *whatsmeow.Client
Device *store.Device Device *store.Device
JID types.JID JID types.JID
MC mClient
historySyncWakeup chan struct{} historySyncs chan *waHistorySync.HistorySync
stopLoops atomic.Pointer[context.CancelFunc] stopLoops atomic.Pointer[context.CancelFunc]
resyncQueue map[types.JID]resyncQueueItem resyncQueue map[types.JID]resyncQueueItem
resyncQueueLock sync.Mutex resyncQueueLock sync.Mutex
@ -114,22 +103,14 @@ type WhatsAppClient struct {
directMediaRetries map[networkid.MessageID]*directMediaRetry directMediaRetries map[networkid.MessageID]*directMediaRetry
directMediaLock sync.Mutex directMediaLock sync.Mutex
mediaRetryLock *semaphore.Weighted mediaRetryLock *semaphore.Weighted
offlineSyncWaiter atomic.Pointer[chan error] offlineSyncWaiter chan error
isNewLogin bool isNewLogin bool
pushNamesSynced *exsync.Event
lastPresence types.Presence
createDedup *exsync.Set[types.MessageID]
appStateRecoveryLock sync.Mutex
appStateFullSyncAttempted map[appstate.WAPatchName]time.Time
} }
var ( var (
_ bridgev2.NetworkAPI = (*WhatsAppClient)(nil) _ bridgev2.NetworkAPI = (*WhatsAppClient)(nil)
_ bridgev2.PushableNetworkAPI = (*WhatsAppClient)(nil) _ bridgev2.PushableNetworkAPI = (*WhatsAppClient)(nil)
_ bridgev2.BackgroundSyncingNetworkAPI = (*WhatsAppClient)(nil) _ bridgev2.BackgroundSyncingNetworkAPI = (*WhatsAppClient)(nil)
_ bridgev2.ChatViewingNetworkAPI = (*WhatsAppClient)(nil)
_ bridgev2.StickerImportingNetworkAPI = (*WhatsAppClient)(nil)
) )
var pushCfg = &bridgev2.PushConfig{ var pushCfg = &bridgev2.PushConfig{
@ -197,41 +178,23 @@ func (wa *WhatsAppClient) Connect(ctx context.Context) {
wa.UserLogin.BridgeState.Send(state) wa.UserLogin.BridgeState.Send(state)
return return
} }
wa.UserLogin.BridgeState.Send(status.BridgeState{StateEvent: status.StateConnecting})
wa.Main.firstClientConnectOnce.Do(wa.Main.onFirstClientConnect) wa.Main.firstClientConnectOnce.Do(wa.Main.onFirstClientConnect)
if err := wa.Main.updateProxy(ctx, wa.Client, false); err != nil { if err := wa.Main.updateProxy(ctx, wa.Client, false); err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to update proxy") zerolog.Ctx(ctx).Err(err).Msg("Failed to update proxy")
} }
if ctx.Err() != nil {
return
}
wa.initMC()
wa.startLoops() wa.startLoops()
wa.Client.BackgroundEventCtx = wa.UserLogin.Log.WithContext(wa.Main.Bridge.BackgroundCtx) if err := wa.Client.Connect(); err != nil {
zerolog.Ctx(ctx).Debug().Msg("Connecting to WhatsApp")
if err := wa.Client.ConnectContext(ctx); err != nil {
wa.callStopLoops()
zerolog.Ctx(ctx).Err(err).Msg("Failed to connect to WhatsApp")
state := status.BridgeState{ state := status.BridgeState{
StateEvent: status.StateUnknownError, StateEvent: status.StateUnknownError,
Error: WAConnectionFailed, Error: WAConnectionFailed,
Info: map[string]any{
"go_error": err.Error(),
},
} }
wa.UserLogin.BridgeState.Send(state) wa.UserLogin.BridgeState.Send(state)
} }
} }
func (wa *WhatsAppClient) notifyOfflineSyncWaiter(err error) { func (wa *WhatsAppClient) notifyOfflineSyncWaiter(err error) {
if ch := wa.offlineSyncWaiter.Load(); ch != nil { if wa.offlineSyncWaiter != nil {
select { wa.offlineSyncWaiter <- err
case *ch <- err:
default:
wa.UserLogin.Log.Warn().
AnErr("dropped_error", err).
Msg("Offline sync waiter channel was full, dropping input")
}
} }
} }
@ -252,11 +215,8 @@ func (wa *WhatsAppClient) ConnectBackground(ctx context.Context, params *bridgev
if wa.Client == nil { if wa.Client == nil {
return bridgev2.ErrNotLoggedIn return bridgev2.ErrNotLoggedIn
} }
wa.Client.BackgroundEventCtx = wa.UserLogin.Log.WithContext(wa.Main.Bridge.BackgroundCtx) wa.offlineSyncWaiter = make(chan error)
ch := make(chan error, 1) wa.Main.firstClientConnectOnce.Do(wa.Main.onFirstClientConnect)
wa.offlineSyncWaiter.Store(&ch)
defer wa.offlineSyncWaiter.Store(nil)
wa.Main.backgroundConnectOnce.Do(wa.Main.onFirstBackgroundConnect)
if err := wa.Main.updateProxy(ctx, wa.Client, false); err != nil { if err := wa.Main.updateProxy(ctx, wa.Client, false); err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to update proxy") zerolog.Ctx(ctx).Err(err).Msg("Failed to update proxy")
} }
@ -266,11 +226,9 @@ func (wa *WhatsAppClient) ConnectBackground(ctx context.Context, params *bridgev
return payload return payload
} }
defer func() { defer func() {
if cli := wa.Client; cli != nil { wa.Client.GetClientPayload = nil
cli.GetClientPayload = nil
}
}() }()
err := wa.Client.ConnectContext(ctx) err := wa.Client.Connect()
if err != nil { if err != nil {
return err return err
} }
@ -278,7 +236,7 @@ func (wa *WhatsAppClient) ConnectBackground(ctx context.Context, params *bridgev
select { select {
case <-ctx.Done(): case <-ctx.Done():
return ctx.Err() return ctx.Err()
case err = <-ch: case err = <-wa.offlineSyncWaiter:
if err == nil { if err == nil {
var data wrappedPushNotificationData var data wrappedPushNotificationData
err = json.Unmarshal(params.RawData, &data) err = json.Unmarshal(params.RawData, &data)
@ -295,7 +253,7 @@ func (wa *WhatsAppClient) ConnectBackground(ctx context.Context, params *bridgev
func (wa *WhatsAppClient) sendPNData(ctx context.Context, pn string) error { func (wa *WhatsAppClient) sendPNData(ctx context.Context, pn string) error {
//lint:ignore SA1019 this is supposed to be dangerous //lint:ignore SA1019 this is supposed to be dangerous
resp, err := wa.Client.DangerousInternals().SendIQ(ctx, whatsmeow.DangerousInfoQuery{ resp, err := wa.Client.DangerousInternals().SendIQ(whatsmeow.DangerousInfoQuery{
Namespace: "urn:xmpp:whatsapp:push", Namespace: "urn:xmpp:whatsapp:push",
Type: "get", Type: "get",
To: types.ServerJID, To: types.ServerJID,
@ -303,6 +261,7 @@ func (wa *WhatsAppClient) sendPNData(ctx context.Context, pn string) error {
Tag: "pn", Tag: "pn",
Content: pn, Content: pn,
}}, }},
Context: ctx,
}) })
if err != nil { if err != nil {
return fmt.Errorf("failed to send pn: %w", err) return fmt.Errorf("failed to send pn: %w", err)
@ -317,7 +276,7 @@ func (wa *WhatsAppClient) sendPNData(ctx context.Context, pn string) error {
} }
zerolog.Ctx(ctx).Debug().Str("cat_data", string(catContentBytes)).Msg("Received cat response from sending pn data") zerolog.Ctx(ctx).Debug().Str("cat_data", string(catContentBytes)).Msg("Received cat response from sending pn data")
//lint:ignore SA1019 this is supposed to be dangerous //lint:ignore SA1019 this is supposed to be dangerous
err = wa.Client.DangerousInternals().SendNode(ctx, waBinary.Node{ err = wa.Client.DangerousInternals().SendNode(waBinary.Node{
Tag: "ib", Tag: "ib",
Content: []waBinary.Node{{ Content: []waBinary.Node{{
Tag: "cat", Tag: "cat",
@ -332,12 +291,11 @@ func (wa *WhatsAppClient) sendPNData(ctx context.Context, pn string) error {
} }
func (wa *WhatsAppClient) startLoops() { func (wa *WhatsAppClient) startLoops() {
ctx, cancel := context.WithCancel(wa.Main.Bridge.BackgroundCtx) ctx, cancel := context.WithCancel(context.Background())
oldStop := wa.stopLoops.Swap(&cancel) oldStop := wa.stopLoops.Swap(&cancel)
if oldStop != nil { if oldStop != nil {
(*oldStop)() (*oldStop)()
} }
ctx = wa.UserLogin.Log.WithContext(ctx)
go wa.historySyncLoop(ctx) go wa.historySyncLoop(ctx)
go wa.ghostResyncLoop(ctx) go wa.ghostResyncLoop(ctx)
if mrc := wa.Main.Config.HistorySync.MediaRequests; mrc.AutoRequestMedia && mrc.RequestMethod == MediaRequestMethodLocalTime { if mrc := wa.Main.Config.HistorySync.MediaRequests; mrc.AutoRequestMedia && mrc.RequestMethod == MediaRequestMethodLocalTime {
@ -355,14 +313,10 @@ func (wa *WhatsAppClient) GetStore() *store.Device {
return store.NoopDevice return store.NoopDevice
} }
func (wa *WhatsAppClient) callStopLoops() { func (wa *WhatsAppClient) Disconnect() {
if stopHistorySyncLoop := wa.stopLoops.Swap(nil); stopHistorySyncLoop != nil { if stopHistorySyncLoop := wa.stopLoops.Swap(nil); stopHistorySyncLoop != nil {
(*stopHistorySyncLoop)() (*stopHistorySyncLoop)()
} }
}
func (wa *WhatsAppClient) Disconnect() {
wa.callStopLoops()
if cli := wa.Client; cli != nil { if cli := wa.Client; cli != nil {
cli.Disconnect() cli.Disconnect()
} }
@ -370,7 +324,7 @@ func (wa *WhatsAppClient) Disconnect() {
func (wa *WhatsAppClient) LogoutRemote(ctx context.Context) { func (wa *WhatsAppClient) LogoutRemote(ctx context.Context) {
if cli := wa.Client; cli != nil { if cli := wa.Client; cli != nil {
err := cli.Logout(ctx) err := cli.Logout()
if err != nil { if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to log out") zerolog.Ctx(ctx).Err(err).Msg("Failed to log out")
} }
@ -417,64 +371,3 @@ func (wa *WhatsAppClient) syncRemoteProfile(ctx context.Context, ghost *bridgev2
} }
zerolog.Ctx(ctx).Info().Msg("Remote profile updated") zerolog.Ctx(ctx).Info().Msg("Remote profile updated")
} }
func (wa *WhatsAppClient) HandleMatrixViewingChat(ctx context.Context, msg *bridgev2.MatrixViewingChat) error {
var presence types.Presence
if msg.Portal != nil {
presence = types.PresenceAvailable
} else {
presence = types.PresenceUnavailable
}
if wa.lastPresence != presence {
err := wa.updatePresence(ctx, presence)
if err != nil {
zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to set presence when viewing chat")
}
}
if msg.Portal == nil || msg.Portal.Metadata.(*waid.PortalMetadata).LastSync.Add(5*time.Minute).After(time.Now()) {
// If we resynced this portal within the last 5 minutes, don't do it again
return nil
}
// Reset, but don't save, portal last sync time for immediate sync now
msg.Portal.Metadata.(*waid.PortalMetadata).LastSync.Time = time.Time{}
// Enqueue for the sync, don't block on it completing
wa.EnqueuePortalResync(msg.Portal, true)
if msg.Portal.OtherUserID != "" {
// If this is a DM, also sync the ghost of the other user immediately
ghost, err := wa.Main.Bridge.GetExistingGhostByID(ctx, msg.Portal.OtherUserID)
if err != nil {
return fmt.Errorf("failed to get ghost for sync: %w", err)
} else if ghost == nil {
zerolog.Ctx(ctx).Warn().
Str("other_user_id", string(msg.Portal.OtherUserID)).
Msg("No ghost found for other user in portal")
} else {
// Reset, but don't save, portal last sync time for immediate sync now
ghost.Metadata.(*waid.GhostMetadata).LastSync.Time = time.Time{}
wa.EnqueueGhostResync(ghost)
}
}
return nil
}
func (wa *WhatsAppClient) updatePresence(ctx context.Context, presence types.Presence) error {
err := wa.Client.SendPresence(ctx, presence)
if err == nil {
wa.lastPresence = presence
}
return err
}
func (wa *WhatsAppClient) DownloadImagePack(ctx context.Context, url string) (*bridgev2.ImportedImagePack, error) {
return wa.Main.MsgConv.DownloadImagePack(ctx, wa.UserLogin.ID, wa.Client, url)
}
func (wa *WhatsAppClient) ListImagePacks(ctx context.Context) ([]*event.ImagePackMetadata, error) {
// TODO
return nil, nil
}

View file

@ -17,15 +17,12 @@
package connector package connector
import ( import (
"context"
"errors" "errors"
"fmt" "fmt"
"html" "html"
"slices"
"strings" "strings"
"github.com/rs/zerolog" "github.com/rs/zerolog"
"go.mau.fi/util/exslices"
"go.mau.fi/whatsmeow" "go.mau.fi/whatsmeow"
"go.mau.fi/whatsmeow/appstate" "go.mau.fi/whatsmeow/appstate"
"go.mau.fi/whatsmeow/types" "go.mau.fi/whatsmeow/types"
@ -68,7 +65,7 @@ func fnAccept(ce *commands.Event) {
ce.Reply("Login not found") ce.Reply("Login not found")
} else if !login.Client.IsLoggedIn() { } else if !login.Client.IsLoggedIn() {
ce.Reply("Not logged in") ce.Reply("Not logged in")
} else if err = login.Client.(*WhatsAppClient).Client.JoinGroupWithInvite(ce.Ctx, meta.JID, meta.Inviter, meta.Code, meta.Expiration); err != nil { } else if err = login.Client.(*WhatsAppClient).Client.JoinGroupWithInvite(meta.JID, meta.Inviter, meta.Code, meta.Expiration); err != nil {
ce.Log.Err(err).Msg("Failed to accept group invite") ce.Log.Err(err).Msg("Failed to accept group invite")
ce.Reply("Failed to accept group invite: %v", err) ce.Reply("Failed to accept group invite: %v", err)
} else { } else {
@ -117,12 +114,15 @@ func fnSync(ce *commands.Event) {
}) })
ce.React("✅") ce.React("✅")
case "groups": case "groups":
groups, err := wa.Client.GetJoinedGroups(ce.Ctx) groups, err := wa.Client.GetJoinedGroups()
if err != nil { if err != nil {
ce.Reply("Failed to get joined groups: %v", err) ce.Reply("Failed to get joined groups: %v", err)
return return
} }
for _, group := range groups { for _, group := range groups {
wrapped := wa.wrapGroupInfo(group)
wrapped.ExtraUpdates = bridgev2.MergeExtraUpdaters(wrapped.ExtraUpdates, updatePortalLastSyncAt)
wa.addExtrasToWrapped(ce.Ctx, group.JID, wrapped, nil)
login.QueueRemoteEvent(&simplevent.ChatResync{ login.QueueRemoteEvent(&simplevent.ChatResync{
EventMeta: simplevent.EventMeta{ EventMeta: simplevent.EventMeta{
Type: bridgev2.RemoteEventChatResync, Type: bridgev2.RemoteEventChatResync,
@ -130,34 +130,19 @@ func fnSync(ce *commands.Event) {
LogContext: logContext, LogContext: logContext,
CreatePortal: true, CreatePortal: true,
}, },
GetChatInfoFunc: func(ctx context.Context, portal *bridgev2.Portal) (*bridgev2.ChatInfo, error) { ChatInfo: wrapped,
wrapped := wa.wrapGroupInfo(ce.Ctx, group)
wrapped.ExtraUpdates = bridgev2.MergeExtraUpdaters(wrapped.ExtraUpdates, updatePortalLastSyncAt)
wa.addExtrasToWrapped(ce.Ctx, group.JID, wrapped, nil, portal.MXID == "")
return wrapped, nil
},
}) })
} }
ce.Reply("Queued syncs for %d groups", len(groups)) ce.Reply("Queued syncs for %d groups", len(groups))
case "contacts": case "contacts":
wa.resyncContacts(false, false) wa.resyncContacts(false)
ce.React("✅") ce.React("✅")
case "contacts-with-avatars": case "contacts-with-avatars":
wa.resyncContacts(true, false) wa.resyncContacts(true)
ce.React("✅") ce.React("✅")
case "appstate": case "appstate":
names := appstate.AllPatchNames[:] for _, name := range appstate.AllPatchNames {
if len(ce.Args) > 1 { err := wa.Client.FetchAppState(name, true, false)
names = exslices.CastFuncFilter(ce.Args[1:], func(name string) (appstate.WAPatchName, bool) {
if !slices.Contains(appstate.AllPatchNames[:], appstate.WAPatchName(name)) {
ce.Reply("Invalid app state name `%s`", name)
return "", false
}
return appstate.WAPatchName(name), true
})
}
for _, name := range names {
err := wa.Client.FetchAppState(ce.Ctx, name, true, false)
if errors.Is(err, appstate.ErrKeyNotFound) { if errors.Is(err, appstate.ErrKeyNotFound) {
ce.Reply("Key not found error syncing app state %s: %v\n\nKey requests are sent automatically, and the sync should happen in the background after your phone responds.", name, err) ce.Reply("Key not found error syncing app state %s: %v\n\nKey requests are sent automatically, and the sync should happen in the background after your phone responds.", name, err)
return return
@ -204,7 +189,7 @@ func fnInviteLink(ce *commands.Event) {
ce.Reply("Can't get invite link to private chat") ce.Reply("Can't get invite link to private chat")
} else if portalJID.IsBroadcastList() { } else if portalJID.IsBroadcastList() {
ce.Reply("Can't get invite link to broadcast list") ce.Reply("Can't get invite link to broadcast list")
} else if link, err := wa.Client.GetGroupInviteLink(ce.Ctx, portalJID, reset); err != nil { } else if link, err := wa.Client.GetGroupInviteLink(portalJID, reset); err != nil {
ce.Reply("Failed to get invite link: %v", err) ce.Reply("Failed to get invite link: %v", err)
} else { } else {
ce.Reply(link) ce.Reply(link)
@ -234,14 +219,14 @@ func fnResolveLink(ce *commands.Event) {
} }
wa := login.Client.(*WhatsAppClient) wa := login.Client.(*WhatsAppClient)
if strings.HasPrefix(ce.Args[0], whatsmeow.InviteLinkPrefix) { if strings.HasPrefix(ce.Args[0], whatsmeow.InviteLinkPrefix) {
group, err := wa.Client.GetGroupInfoFromLink(ce.Ctx, ce.Args[0]) group, err := wa.Client.GetGroupInfoFromLink(ce.Args[0])
if err != nil { if err != nil {
ce.Reply("Failed to get group info: %v", err) ce.Reply("Failed to get group info: %v", err)
return return
} }
ce.Reply("That invite link points at %s (`%s`)", group.Name, group.JID) ce.Reply("That invite link points at %s (`%s`)", group.Name, group.JID)
} else if strings.HasPrefix(ce.Args[0], whatsmeow.BusinessMessageLinkPrefix) || strings.HasPrefix(ce.Args[0], whatsmeow.BusinessMessageLinkDirectPrefix) { } else if strings.HasPrefix(ce.Args[0], whatsmeow.BusinessMessageLinkPrefix) || strings.HasPrefix(ce.Args[0], whatsmeow.BusinessMessageLinkDirectPrefix) {
target, err := wa.Client.ResolveBusinessMessageLink(ce.Ctx, ce.Args[0]) target, err := wa.Client.ResolveBusinessMessageLink(ce.Args[0])
if err != nil { if err != nil {
ce.Reply("Failed to get business info: %v", err) ce.Reply("Failed to get business info: %v", err)
return return
@ -256,7 +241,7 @@ func fnResolveLink(ce *commands.Event) {
} }
ce.Reply("That link points at %s (+%s).%s", target.PushName, target.JID.User, message) ce.Reply("That link points at %s (+%s).%s", target.PushName, target.JID.User, message)
} else if strings.HasPrefix(ce.Args[0], whatsmeow.ContactQRLinkPrefix) || strings.HasPrefix(ce.Args[0], whatsmeow.ContactQRLinkDirectPrefix) { } else if strings.HasPrefix(ce.Args[0], whatsmeow.ContactQRLinkPrefix) || strings.HasPrefix(ce.Args[0], whatsmeow.ContactQRLinkDirectPrefix) {
target, err := wa.Client.ResolveContactQRLink(ce.Ctx, ce.Args[0]) target, err := wa.Client.ResolveContactQRLink(ce.Args[0])
if err != nil { if err != nil {
ce.Reply("Failed to get contact info: %v", err) ce.Reply("Failed to get contact info: %v", err)
return return
@ -295,7 +280,7 @@ func fnJoin(ce *commands.Event) {
wa := login.Client.(*WhatsAppClient) wa := login.Client.(*WhatsAppClient)
if strings.HasPrefix(ce.Args[0], whatsmeow.InviteLinkPrefix) { if strings.HasPrefix(ce.Args[0], whatsmeow.InviteLinkPrefix) {
jid, err := wa.Client.JoinGroupWithLink(ce.Ctx, ce.Args[0]) jid, err := wa.Client.JoinGroupWithLink(ce.Args[0])
if err != nil { if err != nil {
ce.Reply("Failed to join group: %v", err) ce.Reply("Failed to join group: %v", err)
return return
@ -303,12 +288,12 @@ func fnJoin(ce *commands.Event) {
ce.Log.Debug().Stringer("group_jid", jid).Msg("User successfully joined WhatsApp group with link") ce.Log.Debug().Stringer("group_jid", jid).Msg("User successfully joined WhatsApp group with link")
ce.Reply("Successfully joined group `%s`, the portal should be created momentarily", jid) ce.Reply("Successfully joined group `%s`, the portal should be created momentarily", jid)
} else if strings.HasPrefix(ce.Args[0], whatsmeow.NewsletterLinkPrefix) { } else if strings.HasPrefix(ce.Args[0], whatsmeow.NewsletterLinkPrefix) {
info, err := wa.Client.GetNewsletterInfoWithInvite(ce.Ctx, ce.Args[0]) info, err := wa.Client.GetNewsletterInfoWithInvite(ce.Args[0])
if err != nil { if err != nil {
ce.Reply("Failed to get channel info: %v", err) ce.Reply("Failed to get channel info: %v", err)
return return
} }
err = wa.Client.FollowNewsletter(ce.Ctx, info.ID) err = wa.Client.FollowNewsletter(info.ID)
if err != nil { if err != nil {
ce.Reply("Failed to follow channel: %v", err) ce.Reply("Failed to follow channel: %v", err)
return return

View file

@ -4,7 +4,6 @@ import (
_ "embed" _ "embed"
"strings" "strings"
"text/template" "text/template"
"time"
up "go.mau.fi/util/configupgrade" up "go.mau.fi/util/configupgrade"
"go.mau.fi/whatsmeow/types" "go.mau.fi/whatsmeow/types"
@ -49,15 +48,12 @@ type Config struct {
DisableViewOnce bool `yaml:"disable_view_once"` DisableViewOnce bool `yaml:"disable_view_once"`
ForceActiveDeliveryReceipts bool `yaml:"force_active_delivery_receipts"` ForceActiveDeliveryReceipts bool `yaml:"force_active_delivery_receipts"`
DirectMediaAutoRequest bool `yaml:"direct_media_auto_request"` DirectMediaAutoRequest bool `yaml:"direct_media_auto_request"`
InitialAutoReconnect bool `yaml:"initial_auto_reconnect"`
UseWhatsAppRetryStore bool `yaml:"use_whatsapp_retry_store"`
AnimatedSticker msgconv.AnimatedStickerConfig `yaml:"animated_sticker"` AnimatedSticker msgconv.AnimatedStickerConfig `yaml:"animated_sticker"`
HistorySync struct { HistorySync struct {
MaxInitialConversations int `yaml:"max_initial_conversations"` MaxInitialConversations int `yaml:"max_initial_conversations"`
RequestFullSync bool `yaml:"request_full_sync"` RequestFullSync bool `yaml:"request_full_sync"`
DispatchWait time.Duration `yaml:"dispatch_wait"`
FullSyncConfig struct { FullSyncConfig struct {
DaysLimit uint32 `yaml:"days_limit"` DaysLimit uint32 `yaml:"days_limit"`
SizeLimit uint32 `yaml:"size_mb_limit"` SizeLimit uint32 `yaml:"size_mb_limit"`
@ -70,8 +66,6 @@ type Config struct {
RequestLocalTime int `yaml:"request_local_time"` RequestLocalTime int `yaml:"request_local_time"`
MaxAsyncHandle int64 `yaml:"max_async_handle"` MaxAsyncHandle int64 `yaml:"max_async_handle"`
} `yaml:"media_requests"` } `yaml:"media_requests"`
BackwardsOnDemand bool `yaml:"backwards_on_demand"`
} `yaml:"history_sync"` } `yaml:"history_sync"`
displaynameTemplate *template.Template `yaml:"-"` displaynameTemplate *template.Template `yaml:"-"`
@ -118,8 +112,6 @@ func upgradeConfig(helper up.Helper) {
helper.Copy(up.Bool, "disable_view_once") helper.Copy(up.Bool, "disable_view_once")
helper.Copy(up.Bool, "force_active_delivery_receipts") helper.Copy(up.Bool, "force_active_delivery_receipts")
helper.Copy(up.Bool, "direct_media_auto_request") helper.Copy(up.Bool, "direct_media_auto_request")
helper.Copy(up.Bool, "initial_auto_reconnect")
helper.Copy(up.Bool, "use_whatsapp_retry_store")
helper.Copy(up.Str, "animated_sticker", "target") helper.Copy(up.Str, "animated_sticker", "target")
helper.Copy(up.Int, "animated_sticker", "args", "width") helper.Copy(up.Int, "animated_sticker", "args", "width")
@ -128,7 +120,6 @@ func upgradeConfig(helper up.Helper) {
helper.Copy(up.Int, "history_sync", "max_initial_conversations") helper.Copy(up.Int, "history_sync", "max_initial_conversations")
helper.Copy(up.Bool, "history_sync", "request_full_sync") helper.Copy(up.Bool, "history_sync", "request_full_sync")
helper.Copy(up.Str|up.Int, "history_sync", "dispatch_wait")
helper.Copy(up.Int|up.Null, "history_sync", "full_sync_config", "days_limit") helper.Copy(up.Int|up.Null, "history_sync", "full_sync_config", "days_limit")
helper.Copy(up.Int|up.Null, "history_sync", "full_sync_config", "size_mb_limit") helper.Copy(up.Int|up.Null, "history_sync", "full_sync_config", "size_mb_limit")
helper.Copy(up.Int|up.Null, "history_sync", "full_sync_config", "storage_quota_mb") helper.Copy(up.Int|up.Null, "history_sync", "full_sync_config", "storage_quota_mb")
@ -136,7 +127,6 @@ func upgradeConfig(helper up.Helper) {
helper.Copy(up.Str, "history_sync", "media_requests", "request_method") helper.Copy(up.Str, "history_sync", "media_requests", "request_method")
helper.Copy(up.Int, "history_sync", "media_requests", "request_local_time") helper.Copy(up.Int, "history_sync", "media_requests", "request_local_time")
helper.Copy(up.Int, "history_sync", "media_requests", "max_async_handle") helper.Copy(up.Int, "history_sync", "media_requests", "max_async_handle")
helper.Copy(up.Bool, "history_sync", "backwards_on_demand")
} }
type DisplaynameParams struct { type DisplaynameParams struct {
@ -153,11 +143,11 @@ type DisplaynameParams struct {
func (c *Config) FormatDisplayname(jid types.JID, phone string, contact types.ContactInfo) string { func (c *Config) FormatDisplayname(jid types.JID, phone string, contact types.ContactInfo) string {
var nameBuf strings.Builder var nameBuf strings.Builder
if phone == "" && jid.Server == types.DefaultUserServer { if phone == "" {
phone = "+" + jid.User phone = "+" + jid.User
if jid.Server != types.DefaultUserServer {
phone = jid.User
} }
if contact.RedactedPhone == "" && phone != "" {
contact.RedactedPhone = redactPhone(phone)
} }
err := c.displaynameTemplate.Execute(&nameBuf, &DisplaynameParams{ err := c.displaynameTemplate.Execute(&nameBuf, &DisplaynameParams{
ContactInfo: contact, ContactInfo: contact,
@ -176,11 +166,6 @@ func (c *Config) FormatDisplayname(jid types.JID, phone string, contact types.Co
return nameBuf.String() return nameBuf.String()
} }
func redactPhone(phone string) string {
// This doesn't keep 2+ digit country codes properly, but whatever
return phone[:2] + strings.Repeat("∙", len(phone)-4) + phone[len(phone)-2:]
}
func (wa *WhatsAppConnector) GetConfig() (string, any, up.Upgrader) { func (wa *WhatsAppConnector) GetConfig() (string, any, up.Upgrader) {
return ExampleConfig, &wa.Config, &up.StructUpgrader{ return ExampleConfig, &wa.Config, &up.StructUpgrader{
SimpleUpgrader: up.SimpleUpgrader(upgradeConfig), SimpleUpgrader: up.SimpleUpgrader(upgradeConfig),

View file

@ -20,15 +20,10 @@ import (
"context" "context"
"encoding/hex" "encoding/hex"
"fmt" "fmt"
"net"
"net/http"
"strings" "strings"
"sync" "sync"
"sync/atomic" "sync/atomic"
"time"
"github.com/lib/pq"
"github.com/rs/zerolog"
"go.mau.fi/util/dbutil" "go.mau.fi/util/dbutil"
"go.mau.fi/util/random" "go.mau.fi/util/random"
"go.mau.fi/whatsmeow" "go.mau.fi/whatsmeow"
@ -36,19 +31,16 @@ import (
"go.mau.fi/whatsmeow/store" "go.mau.fi/whatsmeow/store"
"go.mau.fi/whatsmeow/store/sqlstore" "go.mau.fi/whatsmeow/store/sqlstore"
whatsmeowUpgrades "go.mau.fi/whatsmeow/store/sqlstore/upgrades" whatsmeowUpgrades "go.mau.fi/whatsmeow/store/sqlstore/upgrades"
"go.mau.fi/whatsmeow/types"
waLog "go.mau.fi/whatsmeow/util/log" waLog "go.mau.fi/whatsmeow/util/log"
"google.golang.org/protobuf/proto" "google.golang.org/protobuf/proto"
"maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/commands" "maunium.net/go/mautrix/bridgev2/commands"
"maunium.net/go/mautrix/bridgev2/database"
"maunium.net/go/mautrix/bridgev2/networkid" "maunium.net/go/mautrix/bridgev2/networkid"
"maunium.net/go/mautrix/event" "maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id" "maunium.net/go/mautrix/id"
"go.mau.fi/mautrix-whatsapp/pkg/connector/wadb" "go.mau.fi/mautrix-whatsapp/pkg/connector/wadb"
"go.mau.fi/mautrix-whatsapp/pkg/msgconv" "go.mau.fi/mautrix-whatsapp/pkg/msgconv"
"go.mau.fi/mautrix-whatsapp/pkg/waid"
) )
type WhatsAppConnector struct { type WhatsAppConnector struct {
@ -59,22 +51,18 @@ type WhatsAppConnector struct {
DB *wadb.Database DB *wadb.Database
firstClientConnectOnce sync.Once firstClientConnectOnce sync.Once
backgroundConnectOnce sync.Once
mediaEditCache MediaEditCache mediaEditCache MediaEditCache
mediaEditCacheLock sync.RWMutex mediaEditCacheLock sync.RWMutex
stopMediaEditCacheLoop atomic.Pointer[context.CancelFunc] stopMediaEditCacheLoop atomic.Pointer[context.CancelFunc]
} }
func init() {
sqlstore.PostgresArrayWrapper = pq.Array
}
var ( var (
_ bridgev2.NetworkConnector = (*WhatsAppConnector)(nil) _ bridgev2.NetworkConnector = (*WhatsAppConnector)(nil)
_ bridgev2.MaxFileSizeingNetwork = (*WhatsAppConnector)(nil) _ bridgev2.MaxFileSizeingNetwork = (*WhatsAppConnector)(nil)
_ bridgev2.StoppableNetwork = (*WhatsAppConnector)(nil) _ bridgev2.StoppableNetwork = (*WhatsAppConnector)(nil)
_ bridgev2.NetworkResettingNetwork = (*WhatsAppConnector)(nil)
_ bridgev2.TransactionIDGeneratingNetwork = (*WhatsAppConnector)(nil)
) )
func (wa *WhatsAppConnector) SetMaxFileSize(maxSize int64) { func (wa *WhatsAppConnector) SetMaxFileSize(maxSize int64) {
@ -128,12 +116,11 @@ func (wa *WhatsAppConnector) Init(bridge *bridgev2.Bridge) {
store.DeviceProps.Os = proto.String(wa.Config.OSName) store.DeviceProps.Os = proto.String(wa.Config.OSName)
store.DeviceProps.RequireFullSync = proto.Bool(wa.Config.HistorySync.RequestFullSync) store.DeviceProps.RequireFullSync = proto.Bool(wa.Config.HistorySync.RequestFullSync)
if fsc := wa.Config.HistorySync.FullSyncConfig; fsc.DaysLimit > 0 && fsc.SizeLimit > 0 && fsc.StorageQuota > 0 { if fsc := wa.Config.HistorySync.FullSyncConfig; fsc.DaysLimit > 0 && fsc.SizeLimit > 0 && fsc.StorageQuota > 0 {
if store.DeviceProps.HistorySyncConfig == nil { store.DeviceProps.HistorySyncConfig = &waCompanionReg.DeviceProps_HistorySyncConfig{
store.DeviceProps.HistorySyncConfig = &waCompanionReg.DeviceProps_HistorySyncConfig{} FullSyncDaysLimit: proto.Uint32(fsc.DaysLimit),
FullSyncSizeMbLimit: proto.Uint32(fsc.SizeLimit),
StorageQuotaMb: proto.Uint32(fsc.StorageQuota),
} }
store.DeviceProps.HistorySyncConfig.FullSyncDaysLimit = proto.Uint32(fsc.DaysLimit)
store.DeviceProps.HistorySyncConfig.FullSyncSizeMbLimit = proto.Uint32(fsc.SizeLimit)
store.DeviceProps.HistorySyncConfig.StorageQuotaMb = proto.Uint32(fsc.StorageQuota)
} }
platformID, ok := waCompanionReg.DeviceProps_PlatformType_value[strings.ToUpper(wa.Config.BrowserName)] platformID, ok := waCompanionReg.DeviceProps_PlatformType_value[strings.ToUpper(wa.Config.BrowserName)]
if ok { if ok {
@ -142,7 +129,7 @@ func (wa *WhatsAppConnector) Init(bridge *bridgev2.Bridge) {
} }
func (wa *WhatsAppConnector) Start(ctx context.Context) error { func (wa *WhatsAppConnector) Start(ctx context.Context) error {
err := wa.DeviceStore.Upgrade(ctx) err := wa.DeviceStore.Upgrade()
if err != nil { if err != nil {
return bridgev2.DBUpgradeError{Err: err, Section: "whatsmeow"} return bridgev2.DBUpgradeError{Err: err, Section: "whatsmeow"}
} }
@ -157,131 +144,27 @@ func (wa *WhatsAppConnector) Start(ctx context.Context) error {
return bridgev2.DBUpgradeError{Err: err, Section: "whatsapp"} return bridgev2.DBUpgradeError{Err: err, Section: "whatsapp"}
} }
if !wa.Bridge.Background && wa.Bridge.DB.KV.Get(ctx, "whatsapp_lid_dms_deleted") == "false" {
wa.deleteLIDDMsMigration(ctx)
}
return nil return nil
} }
func (wa *WhatsAppConnector) deleteLIDDMsMigration(ctx context.Context) {
log := zerolog.Ctx(ctx).With().Str("action", "delete lid dms").Logger()
portals, err := wa.Bridge.GetAllPortalsWithMXID(ctx)
if err != nil {
log.Err(err).Msg("Failed to get portals for LID DM deletion")
return
}
defer wa.Bridge.DB.KV.Set(ctx, "whatsapp_lid_dms_deleted", "true")
if len(portals) == 0 {
log.Debug().Msg("No portals found")
return
}
portalsByKey := make(map[networkid.PortalKey]*bridgev2.Portal, len(portals))
for _, p := range portals {
if p.Receiver == "" || p.RoomType != database.RoomTypeDM {
continue
}
portalsByKey[p.PortalKey] = p
}
_, err = wa.DB.Exec(ctx, "DELETE FROM whatsapp_history_sync_conversation WHERE chat_jid LIKE '%@lid'")
if err != nil {
log.Err(err).Msg("Failed to remove LID conversations from history sync")
}
for key, portal := range portalsByKey {
parsedID, err := waid.ParsePortalID(key.ID)
if err != nil {
log.Warn().Err(err).Str("portal_id", string(key.ID)).Msg("Failed to parse portal ID")
continue
} else if parsedID.Server != types.HiddenUserServer {
continue
}
var pnStr string
err = wa.DB.QueryRow(ctx, "SELECT pn FROM whatsmeow_lid_map WHERE lid=$1", parsedID.User).Scan(&pnStr)
if err != nil {
log.Warn().Err(err).Str("portal_id", string(key.ID)).Msg("Failed to get PN for LID portal")
continue
}
key.ID = waid.MakePortalID(types.JID{User: pnStr, Server: types.DefaultUserServer})
_, pnPortalExists := portalsByKey[key]
if !pnPortalExists {
log.Warn().Str("portal_id", string(key.ID)).Msg("PN portal does not exist, not deleting LID DM")
continue
}
err = portal.Delete(ctx)
if err != nil {
log.Err(err).
Object("portal_key", portal.PortalKey).
Stringer("portal_mxid", portal.MXID).
Msg("Failed to delete LID DM portal from database")
continue
}
err = wa.Bridge.Bot.DeleteRoom(ctx, portal.MXID, false)
if err != nil {
log.Err(err).
Object("portal_key", portal.PortalKey).
Stringer("portal_mxid", portal.MXID).
Msg("Failed to delete LID DM portal from Matrix")
continue
}
log.Debug().
Object("portal_key", portal.PortalKey).
Stringer("portal_mxid", portal.MXID).
Msg("Deleted LID DM portal")
}
log.Info().Msg("Finished deleting LID DM portals")
}
func (wa *WhatsAppConnector) Stop() { func (wa *WhatsAppConnector) Stop() {
if stop := wa.stopMediaEditCacheLoop.Swap(nil); stop != nil { if stop := wa.stopMediaEditCacheLoop.Load(); stop != nil {
(*stop)() (*stop)()
} }
} }
const kvWAVersion = "whatsapp_web_version"
var hardcodedWAVersion = store.GetWAVersion()
func (wa *WhatsAppConnector) onFirstBackgroundConnect() {
verStr := wa.Bridge.DB.KV.Get(wa.Bridge.BackgroundCtx, kvWAVersion)
if verStr == "" {
wa.Bridge.Log.Warn().Msg("No WhatsApp web version number cached in database")
return
}
ver, err := store.ParseVersion(verStr)
if err != nil {
wa.Bridge.Log.Err(err).Msg("Failed to parse WhatsApp web version number from database")
return
}
wa.Bridge.Log.Debug().
Stringer("hardcoded_version", hardcodedWAVersion).
Stringer("cached_version", ver).
Msg("Using cached WhatsApp web version number")
store.SetWAVersion(ver)
}
func (wa *WhatsAppConnector) onFirstClientConnect() { func (wa *WhatsAppConnector) onFirstClientConnect() {
wa.Bridge.Log.Debug().Msg("Fetching latest WhatsApp web version number") ver, err := whatsmeow.GetLatestVersion(nil)
ctx := wa.Bridge.BackgroundCtx
ver, err := whatsmeow.GetLatestVersion(ctx, &http.Client{
Transport: &http.Transport{
DialContext: (&net.Dialer{Timeout: 5 * time.Second}).DialContext,
TLSHandshakeTimeout: 5 * time.Second,
ResponseHeaderTimeout: 5 * time.Second,
ForceAttemptHTTP2: true,
},
Timeout: 10 * time.Second,
})
if err != nil { if err != nil {
wa.Bridge.Log.Err(err).Msg("Failed to get latest WhatsApp web version number") wa.Bridge.Log.Err(err).Msg("Failed to get latest WhatsApp web version number")
} else { } else {
wa.Bridge.Log.Debug(). wa.Bridge.Log.Debug().
Stringer("hardcoded_version", hardcodedWAVersion). Stringer("hardcoded_version", store.GetWAVersion()).
Stringer("latest_version", *ver). Stringer("latest_version", *ver).
Msg("Got latest WhatsApp web version number") Msg("Got latest WhatsApp web version number")
store.SetWAVersion(*ver) store.SetWAVersion(*ver)
wa.Bridge.DB.KV.Set(ctx, kvWAVersion, ver.String())
} }
meclCtx, cancel := context.WithCancel(ctx) meclCtx, cancel := context.WithCancel(context.Background())
wa.stopMediaEditCacheLoop.Store(&cancel) wa.stopMediaEditCacheLoop.Store(&cancel)
go wa.mediaEditCacheExpireLoop(meclCtx) go wa.mediaEditCacheExpireLoop(meclCtx)
} }
@ -291,13 +174,3 @@ func (wa *WhatsAppConnector) GenerateTransactionID(_ id.UserID, _ id.RoomID, _ e
// so nobody can tell the difference if we just generate random bytes. // so nobody can tell the difference if we just generate random bytes.
return networkid.RawTransactionID(whatsmeow.WebMessageIDPrefix + strings.ToUpper(hex.EncodeToString(random.Bytes(9)))) return networkid.RawTransactionID(whatsmeow.WebMessageIDPrefix + strings.ToUpper(hex.EncodeToString(random.Bytes(9))))
} }
func (wa *WhatsAppConnector) ResetHTTPTransport() {
// No-op for now, whatsmeow doesn't use the shared transport config yet
}
func (wa *WhatsAppConnector) ResetNetworkConnections() {
for _, login := range wa.Bridge.GetAllCachedUserLogins() {
login.Client.(*WhatsAppClient).Client.ResetConnection()
}
}

View file

@ -21,7 +21,6 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"io"
"net/http" "net/http"
"os" "os"
"sync" "sync"
@ -29,7 +28,6 @@ import (
"github.com/rs/zerolog" "github.com/rs/zerolog"
"go.mau.fi/util/exsync" "go.mau.fi/util/exsync"
"go.mau.fi/util/ptr"
"go.mau.fi/whatsmeow" "go.mau.fi/whatsmeow"
"go.mau.fi/whatsmeow/proto/waMmsRetry" "go.mau.fi/whatsmeow/proto/waMmsRetry"
"go.mau.fi/whatsmeow/types/events" "go.mau.fi/whatsmeow/types/events"
@ -39,7 +37,6 @@ import (
"maunium.net/go/mautrix/bridgev2/networkid" "maunium.net/go/mautrix/bridgev2/networkid"
"maunium.net/go/mautrix/mediaproxy" "maunium.net/go/mautrix/mediaproxy"
"go.mau.fi/mautrix-whatsapp/pkg/connector/wadb"
"go.mau.fi/mautrix-whatsapp/pkg/msgconv" "go.mau.fi/mautrix-whatsapp/pkg/msgconv"
"go.mau.fi/mautrix-whatsapp/pkg/waid" "go.mau.fi/mautrix-whatsapp/pkg/waid"
) )
@ -57,106 +54,13 @@ var ErrReloadNeeded = mautrix.RespError{
} }
func (wa *WhatsAppConnector) Download(ctx context.Context, mediaID networkid.MediaID, params map[string]string) (mediaproxy.GetMediaResponse, error) { func (wa *WhatsAppConnector) Download(ctx context.Context, mediaID networkid.MediaID, params map[string]string) (mediaproxy.GetMediaResponse, error) {
parsedID, err := waid.ParseMediaID(mediaID) parsedID, receiverID, err := waid.ParseMediaID(mediaID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
log := zerolog.Ctx(ctx).With().Any("parsed_media_id", parsedID).Logger() log := zerolog.Ctx(ctx).With().Any("message_id", parsedID).Logger()
ctx = log.WithContext(ctx) ctx = log.WithContext(ctx)
if parsedID.Message != nil { msg, err := wa.Bridge.DB.Message.GetFirstPartByID(ctx, receiverID, parsedID.String())
return wa.downloadMessageDirectMedia(ctx, parsedID, params)
} else if parsedID.Avatar != nil {
return wa.downloadAvatarDirectMedia(ctx, parsedID, params)
} else if parsedID.Sticker != nil {
return wa.downloadStickerDirectMedia(ctx, parsedID, params)
} else {
return nil, fmt.Errorf("unexpected media ID parsing result")
}
}
func (wa *WhatsAppConnector) downloadAvatarDirectMedia(ctx context.Context, parsedID *waid.ParsedMediaID, params map[string]string) (mediaproxy.GetMediaResponse, error) {
ul := wa.Bridge.GetCachedUserLoginByID(parsedID.UserLogin)
if ul == nil {
return nil, fmt.Errorf("%w: user login %s not found", bridgev2.ErrNotLoggedIn, parsedID.UserLogin)
}
waClient := ul.Client.(*WhatsAppClient)
if waClient.Client == nil {
return nil, fmt.Errorf("no WhatsApp client found on login %s", parsedID.UserLogin)
}
cachedInfo, err := wa.DB.AvatarCache.Get(ctx, parsedID.Avatar.TargetJID, parsedID.Avatar.AvatarID)
if err != nil {
return nil, fmt.Errorf("failed to get avatar cache entry: %w", err)
}
if cachedInfo != nil && cachedInfo.Gone {
return nil, mautrix.MNotFound.WithMessage("Avatar is no longer available (cached response)")
} else if cachedInfo == nil || cachedInfo.Expiry.Time.Before(time.Now().Add(5*time.Minute)) {
zerolog.Ctx(ctx).Debug().
Str("avatar_id", parsedID.Avatar.AvatarID).
Msg("Refreshing avatar URL from WhatsApp servers")
avatar, err := waClient.Client.GetProfilePictureInfo(ctx, parsedID.Avatar.TargetJID, &whatsmeow.GetProfilePictureParams{
IsCommunity: parsedID.Avatar.Community,
})
if errors.Is(err, whatsmeow.ErrProfilePictureNotSet) ||
errors.Is(err, whatsmeow.ErrProfilePictureUnauthorized) ||
(err == nil && (avatar == nil || avatar.ID != parsedID.Avatar.AvatarID)) {
zerolog.Ctx(ctx).Debug().
Err(err).
Stringer("target_jid", parsedID.Avatar.TargetJID).
Bool("is_community", parsedID.Avatar.Community).
Str("wanted_avatar_id", parsedID.Avatar.AvatarID).
Str("got_avatar_id", ptr.Val(avatar).ID).
Msg("Avatar is no longer available")
err = wa.DB.AvatarCache.Put(ctx, &wadb.AvatarCacheEntry{
EntityJID: parsedID.Avatar.TargetJID,
AvatarID: parsedID.Avatar.AvatarID,
Gone: true,
})
if err != nil {
zerolog.Ctx(ctx).Warn().Err(err).
Str("avatar_id", parsedID.Avatar.AvatarID).
Msg("Failed to mark avatar as gone in cache")
}
return nil, mautrix.MNotFound.WithMessage("Avatar is no longer available")
} else if err != nil {
return nil, mautrix.MUnknown.WithMessage("failed to refresh avatar url: %w", err).WithCanRetry(true)
}
cachedInfo = avatarInfoToCacheEntry(ctx, parsedID.Avatar.TargetJID, avatar)
err = wa.DB.AvatarCache.Put(ctx, cachedInfo)
if err != nil {
zerolog.Ctx(ctx).Warn().Err(err).
Str("avatar_id", avatar.ID).
Msg("Failed to update avatar cache entry")
}
}
return &mediaproxy.GetMediaResponseFile{
Callback: func(w *os.File) (*mediaproxy.FileMeta, error) {
return &mediaproxy.FileMeta{}, waClient.Client.DownloadMediaWithPathToFile(
ctx, cachedInfo.DirectPath, nil, nil, nil, 0, "", "", w,
)
},
}, nil
}
func (wa *WhatsAppConnector) downloadStickerDirectMedia(ctx context.Context, parsedID *waid.ParsedMediaID, params map[string]string) (mediaproxy.GetMediaResponse, error) {
ul := wa.Bridge.GetCachedUserLoginByID(parsedID.UserLogin)
if ul == nil {
return nil, fmt.Errorf("%w: user login %s not found", bridgev2.ErrNotLoggedIn, parsedID.UserLogin)
}
waClient := ul.Client.(*WhatsAppClient)
if waClient.Client == nil {
return nil, fmt.Errorf("no WhatsApp client found on login %s", parsedID.UserLogin)
}
sticker, err := wa.MsgConv.GetCachedSticker(ctx, waClient.Client, parsedID.Sticker.PackID, parsedID.Sticker.FileHash)
if err != nil {
return nil, err
} else if sticker == nil {
return nil, mautrix.MNotFound.WithMessage("Sticker not found in pack")
}
return wa.makeDirectMediaResponse(ctx, waClient, sticker, sticker.MimeType, "", nil, params)
}
func (wa *WhatsAppConnector) downloadMessageDirectMedia(ctx context.Context, parsedID *waid.ParsedMediaID, params map[string]string) (mediaproxy.GetMediaResponse, error) {
msg, err := wa.Bridge.DB.Message.GetFirstPartByID(ctx, parsedID.UserLogin, parsedID.Message.String())
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get message: %w", err) return nil, fmt.Errorf("failed to get message: %w", err)
} else if msg == nil { } else if msg == nil {
@ -172,8 +76,8 @@ func (wa *WhatsAppConnector) downloadMessageDirectMedia(ctx context.Context, par
return nil, fmt.Errorf("failed to unmarshal media keys: %w", err) return nil, fmt.Errorf("failed to unmarshal media keys: %w", err)
} }
var ul *bridgev2.UserLogin var ul *bridgev2.UserLogin
if parsedID.UserLogin != "" { if receiverID != "" {
ul = wa.Bridge.GetCachedUserLoginByID(parsedID.UserLogin) ul = wa.Bridge.GetCachedUserLoginByID(receiverID)
} else { } else {
logins, err := wa.Bridge.GetUserLoginsInPortal(ctx, msg.Room) logins, err := wa.Bridge.GetUserLoginsInPortal(ctx, msg.Room)
if err != nil { if err != nil {
@ -187,67 +91,38 @@ func (wa *WhatsAppConnector) downloadMessageDirectMedia(ctx context.Context, par
} }
} }
if ul == nil || !ul.Client.IsLoggedIn() { if ul == nil || !ul.Client.IsLoggedIn() {
return nil, bridgev2.ErrNotLoggedIn return nil, fmt.Errorf("no logged in user found")
} }
waClient := ul.Client.(*WhatsAppClient) waClient := ul.Client.(*WhatsAppClient)
if waClient.Client == nil { if waClient.Client == nil {
return nil, fmt.Errorf("no WhatsApp client found on login") return nil, fmt.Errorf("no WhatsApp client found on login")
} }
return wa.makeDirectMediaResponse(ctx, waClient, keys, keys.MimeType, msg.ID, keys, params)
}
func (wa *WhatsAppConnector) makeDirectMediaResponse(
ctx context.Context,
waClient *WhatsAppClient,
dm whatsmeow.DownloadableMessage,
mimeType string,
msgID networkid.MessageID,
keys *msgconv.FailedMediaKeys,
params map[string]string,
) (mediaproxy.GetMediaResponse, error) {
return &mediaproxy.GetMediaResponseFile{ return &mediaproxy.GetMediaResponseFile{
Callback: func(f *os.File) (*mediaproxy.FileMeta, error) { Callback: func(f *os.File) error {
log := zerolog.Ctx(ctx) err := waClient.Client.DownloadToFile(keys, f)
err := waClient.Client.DownloadToFile(ctx, dm, f) if errors.Is(err, whatsmeow.ErrMediaDownloadFailedWith403) || errors.Is(err, whatsmeow.ErrMediaDownloadFailedWith404) || errors.Is(err, whatsmeow.ErrMediaDownloadFailedWith410) {
if keys != nil && (errors.Is(err, whatsmeow.ErrMediaDownloadFailedWith403) || errors.Is(err, whatsmeow.ErrMediaDownloadFailedWith404) || errors.Is(err, whatsmeow.ErrMediaDownloadFailedWith410) || errors.Is(err, whatsmeow.ErrNoURLPresent)) {
val := params["fi.mau.whatsapp.reload_media"] val := params["fi.mau.whatsapp.reload_media"]
if val == "false" || (!wa.Config.DirectMediaAutoRequest && val != "true") { if val == "false" || (!wa.Config.DirectMediaAutoRequest && val != "true") {
return nil, ErrReloadNeeded return ErrReloadNeeded
} }
log.Trace().Msg("Media not found for direct download, requesting and waiting") log.Trace().Msg("Media not found for direct download, requesting and waiting")
err = waClient.requestAndWaitDirectMedia(ctx, msgID, keys) err = waClient.requestAndWaitDirectMedia(ctx, msg.ID, keys)
if err != nil { if err != nil {
log.Trace().Err(err).Msg("Failed to wait for media for direct download") log.Trace().Err(err).Msg("Failed to wait for media for direct download")
return nil, err return err
} }
log.Trace().Msg("Retrying download after successful retry") log.Trace().Msg("Retrying download after successful retry")
err = waClient.Client.DownloadToFile(ctx, keys, f) err = waClient.Client.DownloadToFile(keys, f)
} }
if errors.Is(err, whatsmeow.ErrFileLengthMismatch) || errors.Is(err, whatsmeow.ErrInvalidMediaSHA256) { if errors.Is(err, whatsmeow.ErrFileLengthMismatch) || errors.Is(err, whatsmeow.ErrInvalidMediaSHA256) {
zerolog.Ctx(ctx).Warn().Err(err).Msg("Mismatching media checksums in message. Ignoring because WhatsApp seems to ignore them too") zerolog.Ctx(ctx).Warn().Err(err).Msg("Mismatching media checksums in message. Ignoring because WhatsApp seems to ignore them too")
} else if err != nil { } else if err != nil {
return nil, err return err
} }
return nil
if mimeType == "application/was" {
if _, err := f.Seek(0, io.SeekStart); err != nil {
return nil, fmt.Errorf("failed to seek to start of sticker zip: %w", err)
} else if zipData, err := io.ReadAll(f); err != nil {
return nil, fmt.Errorf("failed to read sticker zip: %w", err)
} else if data, _, err := msgconv.ExtractAnimatedSticker(zipData); err != nil {
return nil, fmt.Errorf("failed to extract animated sticker: %w %x", err, zipData)
} else if _, err := f.WriteAt(data, 0); err != nil {
return nil, fmt.Errorf("failed to write animated sticker to file: %w", err)
} else if err := f.Truncate(int64(len(data))); err != nil {
return nil, fmt.Errorf("failed to truncate animated sticker file: %w", err)
}
mimeType = "video/lottie+json"
}
return &mediaproxy.FileMeta{
ContentType: mimeType,
}, nil
}, },
// TODO?
ContentType: "",
}, nil }, nil
} }
@ -285,16 +160,12 @@ func (wa *WhatsAppClient) requestAndWaitDirectMedia(ctx context.Context, rawMsgI
} }
switch state.resultType { switch state.resultType {
case waMmsRetry.MediaRetryNotification_NOT_FOUND: case waMmsRetry.MediaRetryNotification_NOT_FOUND:
return mautrix.MNotFound.WithMessage("This media was not found on your phone.") return mautrix.MNotFound.WithMessage("Media not found on phone")
case waMmsRetry.MediaRetryNotification_DECRYPTION_ERROR:
return mautrix.MNotFound.WithMessage("Unable to retrieve media: phone reported a decryption error. The original message may have been deleted.")
case waMmsRetry.MediaRetryNotification_GENERAL_ERROR:
return mautrix.MNotFound.WithMessage("Unable to retrieve media: phone returned an error. Please ensure your phone is connected to the internet and WhatsApp is running.").WithCanRetry(true)
default: default:
return mautrix.MNotFound.WithMessage(fmt.Sprintf("Unable to retrieve media: phone returned error code %d", state.resultType)).WithCanRetry(true) return mautrix.MNotFound.WithMessage("Phone returned error response")
} }
case <-time.After(30 * time.Second): case <-time.After(30 * time.Second):
return mautrix.MNotFound.WithMessage("Phone did not respond in time. Please ensure your phone is connected to the internet and WhatsApp is open.").WithStatus(http.StatusGatewayTimeout).WithCanRetry(true) return mautrix.MNotFound.WithMessage("Phone did not respond in time").WithStatus(http.StatusGatewayTimeout)
case <-ctx.Done(): case <-ctx.Done():
return ctx.Err() return ctx.Err()
} }
@ -306,7 +177,7 @@ func (wa *WhatsAppClient) requestDirectMedia(ctx context.Context, rawMsgID netwo
defer state.Unlock() defer state.Unlock()
if !state.requested { if !state.requested {
zerolog.Ctx(ctx).Debug().Msg("Sending request for missing media in direct download") zerolog.Ctx(ctx).Debug().Msg("Sending request for missing media in direct download")
err := wa.sendMediaRequestDirect(ctx, rawMsgID, key) err := wa.sendMediaRequestDirect(rawMsgID, key)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to send media retry request: %w", err) return nil, fmt.Errorf("failed to send media retry request: %w", err)
} }
@ -338,9 +209,7 @@ func (wa *WhatsAppClient) receiveDirectMediaRetry(ctx context.Context, msg *data
log.Warn().Err(err).Msg("Failed to decrypt media retry notification") log.Warn().Err(err).Msg("Failed to decrypt media retry notification")
return return
} }
if state != nil {
state.resultType = retryData.GetResult() state.resultType = retryData.GetResult()
}
if retryData.GetResult() != waMmsRetry.MediaRetryNotification_SUCCESS { if retryData.GetResult() != waMmsRetry.MediaRetryNotification_SUCCESS {
errorName := waMmsRetry.MediaRetryNotification_ResultType_name[int32(retryData.GetResult())] errorName := waMmsRetry.MediaRetryNotification_ResultType_name[int32(retryData.GetResult())]
if retryData.GetDirectPath() == "" { if retryData.GetDirectPath() == "" {

View file

@ -73,7 +73,7 @@ func (evt *MessageInfoWrapper) GetTimestamp() time.Time {
} }
func (evt *MessageInfoWrapper) GetSender() bridgev2.EventSender { func (evt *MessageInfoWrapper) GetSender() bridgev2.EventSender {
return evt.wa.makeEventSender(evt.wa.Main.Bridge.BackgroundCtx, evt.Info.Sender) return evt.wa.makeEventSender(evt.Info.Sender)
} }
func (evt *MessageInfoWrapper) GetID() networkid.MessageID { func (evt *MessageInfoWrapper) GetID() networkid.MessageID {
@ -124,50 +124,6 @@ func (evt *WAMessageEvent) AddLogContext(c zerolog.Context) zerolog.Context {
return evt.MessageInfoWrapper.AddLogContext(c).Str("parsed_message_type", evt.parsedMessageType) return evt.MessageInfoWrapper.AddLogContext(c).Str("parsed_message_type", evt.parsedMessageType)
} }
func (evt *WAMessageEvent) PreHandle(ctx context.Context, portal *bridgev2.Portal) {
if evt.Info.AddressingMode != types.AddressingModeLID || evt.Info.Chat.Server != types.GroupServer {
return
}
portalJID, err := waid.ParsePortalID(portal.ID)
if err != nil {
return
}
meta := portal.Metadata.(*waid.PortalMetadata)
if meta.AddressingMode == types.AddressingModeLID && evt.Info.Sender.Server == types.DefaultUserServer {
evt.Info.Sender, evt.Info.SenderAlt = evt.Info.SenderAlt, evt.Info.Sender
zerolog.Ctx(ctx).Debug().
Stringer("lid", evt.Info.Sender).
Stringer("pn", evt.Info.SenderAlt).
Str("message_id", evt.Info.ID).
Msg("Forced phone number sender to LID in group message")
}
if meta.AddressingMode == types.AddressingModeLID || meta.LIDMigrationAttempted {
return
}
log := zerolog.Ctx(ctx).With().Str("action", "group lid migration").Logger()
ctx = log.WithContext(ctx)
meta.LIDMigrationAttempted = true
info, err := evt.wa.Client.GetGroupInfo(ctx, portalJID)
if err != nil {
log.Err(err).Msg("Failed to get group info for lid migration")
return
}
if info.AddressingMode != types.AddressingModeLID {
log.Warn().Msg("Received LID message, but group addressing mode isn't set to LID? Not migrating")
return
}
log.Info().Msg("Resyncing group members as it appears to have switched to LID addressing mode")
portal.UpdateInfo(ctx, evt.wa.wrapGroupInfo(ctx, info), evt.wa.UserLogin, nil, time.Time{})
log.Debug().Msg("Finished resyncing after LID change")
if evt.Info.Sender.Server == types.DefaultUserServer && evt.Info.SenderAlt.Server == types.HiddenUserServer {
evt.Info.Sender, evt.Info.SenderAlt = evt.Info.SenderAlt, evt.Info.Sender
log.Debug().
Stringer("new_sender", evt.Info.Sender).
Stringer("new_sender_alt", evt.Info.SenderAlt).
Msg("Overriding sender to LID after resyncing group members")
}
}
func (evt *WAMessageEvent) PostHandle(ctx context.Context, portal *bridgev2.Portal) { func (evt *WAMessageEvent) PostHandle(ctx context.Context, portal *bridgev2.Portal) {
if ph := evt.postHandle; ph != nil { if ph := evt.postHandle; ph != nil {
evt.postHandle = nil evt.postHandle = nil
@ -194,10 +150,7 @@ func (evt *WAMessageEvent) ConvertEdit(ctx context.Context, portal *bridgev2.Por
meta.Edits = append(meta.Edits, evt.Info.ID) meta.Edits = append(meta.Edits, evt.Info.ID)
} }
ctx = context.WithValue(ctx, msgconv.ContextKeyEditTargetID, evt.Message.GetProtocolMessage().GetKey().GetID()) cm := evt.wa.Main.MsgConv.ToMatrix(ctx, portal, evt.wa.Client, intent, editedMsg, &evt.Info, evt.isViewOnce(), previouslyConvertedPart)
cm := evt.wa.Main.MsgConv.ToMatrix(
ctx, portal, evt.wa.Client, intent, editedMsg, evt.MsgEvent.RawMessage, &evt.Info, evt.isViewOnce(), false, previouslyConvertedPart,
)
if evt.isUndecryptableUpsertSubEvent && isFailedMedia(cm) { if evt.isUndecryptableUpsertSubEvent && isFailedMedia(cm) {
evt.postHandle = func() { evt.postHandle = func() {
evt.wa.processFailedMedia(ctx, portal.PortalKey, evt.GetID(), cm, false) evt.wa.processFailedMedia(ctx, portal.PortalKey, evt.GetID(), cm, false)
@ -217,15 +170,9 @@ func (evt *WAMessageEvent) ConvertEdit(ctx context.Context, portal *bridgev2.Por
func (evt *WAMessageEvent) GetTargetMessage() networkid.MessageID { func (evt *WAMessageEvent) GetTargetMessage() networkid.MessageID {
if reactionMsg := evt.Message.GetReactionMessage(); reactionMsg != nil { if reactionMsg := evt.Message.GetReactionMessage(); reactionMsg != nil {
ctx := evt.wa.UserLogin.Log. return msgconv.KeyToMessageID(evt.wa.Client, evt.Info.Chat, evt.Info.Sender, reactionMsg.GetKey())
With().Str("action", "get reaction target message").Str("message_id", evt.Info.ID).Logger().
WithContext(evt.wa.Main.Bridge.BackgroundCtx)
return msgconv.KeyToMessageID(ctx, evt.wa.Client, evt.Info.Chat, evt.Info.Sender, reactionMsg.GetKey())
} else if protocolMsg := evt.Message.GetProtocolMessage(); protocolMsg != nil { } else if protocolMsg := evt.Message.GetProtocolMessage(); protocolMsg != nil {
ctx := evt.wa.UserLogin.Log. return msgconv.KeyToMessageID(evt.wa.Client, evt.Info.Chat, evt.Info.Sender, protocolMsg.GetKey())
With().Str("action", "get edit target message").Str("message_id", evt.Info.ID).Logger().
WithContext(evt.wa.Main.Bridge.BackgroundCtx)
return msgconv.KeyToMessageID(ctx, evt.wa.Client, evt.Info.Chat, evt.Info.Sender, protocolMsg.GetKey())
} }
return "" return ""
} }
@ -277,10 +224,8 @@ func (evt *WAMessageEvent) HandleExisting(ctx context.Context, portal *bridgev2.
} }
func (evt *WAMessageEvent) ConvertMessage(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI) (*bridgev2.ConvertedMessage, error) { func (evt *WAMessageEvent) ConvertMessage(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI) (*bridgev2.ConvertedMessage, error) {
evt.wa.EnqueuePortalResync(portal, false) evt.wa.EnqueuePortalResync(portal)
converted := evt.wa.Main.MsgConv.ToMatrix( converted := evt.wa.Main.MsgConv.ToMatrix(ctx, portal, evt.wa.Client, intent, evt.Message, &evt.Info, evt.isViewOnce(), nil)
ctx, portal, evt.wa.Client, intent, evt.Message, evt.MsgEvent.RawMessage, &evt.Info, evt.isViewOnce(), false, nil,
)
if isFailedMedia(converted) { if isFailedMedia(converted) {
evt.postHandle = func() { evt.postHandle = func() {
evt.wa.processFailedMedia(ctx, portal.PortalKey, evt.GetID(), converted, false) evt.wa.processFailedMedia(ctx, portal.PortalKey, evt.GetID(), converted, false)
@ -354,13 +299,9 @@ func (evt *WAUndecryptableMessage) ConvertMessage(ctx context.Context, portal *b
} }
content := &undecryptableMessageContent content := &undecryptableMessageContent
if evt.Type == events.UnavailableTypeViewOnce { if evt.Type == events.UnavailableTypeViewOnce {
body := "You received a view once message. For added privacy, you can only open it on the WhatsApp app."
if evt.Info.IsFromMe {
body = "You sent a view once message from another device."
}
content = &event.MessageEventContent{ content = &event.MessageEventContent{
MsgType: event.MsgNotice, MsgType: event.MsgNotice,
Body: body, Body: "You received a view once message. For added privacy, you can only open it on the WhatsApp app.",
} }
} }
// TODO thread root for comments // TODO thread root for comments
@ -416,7 +357,7 @@ func (evt *WAMediaRetry) getRealSender() types.JID {
} }
func (evt *WAMediaRetry) GetSender() bridgev2.EventSender { func (evt *WAMediaRetry) GetSender() bridgev2.EventSender {
return evt.wa.makeEventSender(evt.wa.Main.Bridge.BackgroundCtx, evt.getRealSender()) return evt.wa.makeEventSender(evt.getRealSender())
} }
func (evt *WAMediaRetry) GetTargetMessage() networkid.MessageID { func (evt *WAMediaRetry) GetTargetMessage() networkid.MessageID {

View file

@ -16,9 +16,8 @@ proxy_only_login: false
# {{.PushName}} - nickname set by the WhatsApp user # {{.PushName}} - nickname set by the WhatsApp user
# {{.BusinessName}} - validated WhatsApp business name # {{.BusinessName}} - validated WhatsApp business name
# {{.Phone}} - phone number (international format) # {{.Phone}} - phone number (international format)
# {{.RedactedPhone}} - phone number with middle digits replaced by "∙"
# {{.FullName}} - Name you set in the contacts list # {{.FullName}} - Name you set in the contacts list
displayname_template: '{{or .BusinessName .PushName .Phone .RedactedPhone "Unknown user"}} (WA)' displayname_template: "{{or .BusinessName .PushName .Phone}} (WA)"
# Should incoming calls send a message to the Matrix room? # Should incoming calls send a message to the Matrix room?
call_start_notices: true call_start_notices: true
@ -62,13 +61,6 @@ force_active_delivery_receipts: false
# When direct media is enabled and a piece of media isn't available on the WhatsApp servers, # When direct media is enabled and a piece of media isn't available on the WhatsApp servers,
# should it be automatically requested from the phone? # should it be automatically requested from the phone?
direct_media_auto_request: true direct_media_auto_request: true
# Should the bridge automatically reconnect if it fails to connect on startup?
initial_auto_reconnect: true
# WhatsApp messages are sometimes undecryptable. Should the bridge store messages it sends in the
# bridge database in order to accept retry receipts from other WhatsApp users for messages sent via
# the bridge? By default, the bridge only stores messages in memory, and therefore can't accept
# retry receipts if the bridge is restarted after the message is sent.
use_whatsapp_retry_store: false
# Settings for converting animated stickers. # Settings for converting animated stickers.
animated_sticker: animated_sticker:
@ -94,10 +86,6 @@ history_sync:
# Should the bridge request a full sync from the phone when logging in? # Should the bridge request a full sync from the phone when logging in?
# This bumps the size of history syncs from 3 months to 1 year. # This bumps the size of history syncs from 3 months to 1 year.
request_full_sync: false request_full_sync: false
# Time to wait for history sync payloads before starting backfill. Each new payload resets the timer.
# If this is too low, the backfill may happen with incomplete history
# and backfill less messages than what is configured in the backfill section.
dispatch_wait: 1m
# Configuration parameters that are sent to the phone along with the request full sync flag. # Configuration parameters that are sent to the phone along with the request full sync flag.
# By default, (when the values are null or 0), the config isn't sent at all. # By default, (when the values are null or 0), the config isn't sent at all.
full_sync_config: full_sync_config:
@ -121,6 +109,3 @@ history_sync:
request_local_time: 120 request_local_time: 120
# Maximum number of media request responses to handle in parallel per user. # Maximum number of media request responses to handle in parallel per user.
max_async_handle: 2 max_async_handle: 2
# Use on-demand history sync requests for fetching older messages?
# This only applies when using the backfill queue, never for forward backfills.
backwards_on_demand: false

View file

@ -1,13 +1,9 @@
package connector package connector
import ( import (
"bytes"
"context" "context"
"crypto/sha256"
"errors" "errors"
"fmt" "fmt"
"image"
"image/jpeg"
"strings" "strings"
"time" "time"
@ -15,11 +11,8 @@ import (
"go.mau.fi/util/ptr" "go.mau.fi/util/ptr"
"go.mau.fi/util/variationselector" "go.mau.fi/util/variationselector"
"go.mau.fi/whatsmeow" "go.mau.fi/whatsmeow"
"go.mau.fi/whatsmeow/appstate"
"go.mau.fi/whatsmeow/proto/waCommon"
"go.mau.fi/whatsmeow/proto/waE2E" "go.mau.fi/whatsmeow/proto/waE2E"
"go.mau.fi/whatsmeow/types" "go.mau.fi/whatsmeow/types"
"golang.org/x/image/draw"
"google.golang.org/protobuf/proto" "google.golang.org/protobuf/proto"
"maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/database" "maunium.net/go/mautrix/bridgev2/database"
@ -37,15 +30,6 @@ var (
_ bridgev2.RedactionHandlingNetworkAPI = (*WhatsAppClient)(nil) _ bridgev2.RedactionHandlingNetworkAPI = (*WhatsAppClient)(nil)
_ bridgev2.ReadReceiptHandlingNetworkAPI = (*WhatsAppClient)(nil) _ bridgev2.ReadReceiptHandlingNetworkAPI = (*WhatsAppClient)(nil)
_ bridgev2.PollHandlingNetworkAPI = (*WhatsAppClient)(nil) _ bridgev2.PollHandlingNetworkAPI = (*WhatsAppClient)(nil)
_ bridgev2.DisappearTimerChangingNetworkAPI = (*WhatsAppClient)(nil)
_ bridgev2.MembershipHandlingNetworkAPI = (*WhatsAppClient)(nil)
_ bridgev2.RoomNameHandlingNetworkAPI = (*WhatsAppClient)(nil)
_ bridgev2.RoomTopicHandlingNetworkAPI = (*WhatsAppClient)(nil)
_ bridgev2.RoomAvatarHandlingNetworkAPI = (*WhatsAppClient)(nil)
_ bridgev2.MuteHandlingNetworkAPI = (*WhatsAppClient)(nil)
_ bridgev2.TagHandlingNetworkAPI = (*WhatsAppClient)(nil)
_ bridgev2.MarkedUnreadHandlingNetworkAPI = (*WhatsAppClient)(nil)
_ bridgev2.DeleteChatHandlingNetworkAPI = (*WhatsAppClient)(nil)
) )
func (wa *WhatsAppClient) HandleMatrixPollStart(ctx context.Context, msg *bridgev2.MatrixPollStart) (*bridgev2.MatrixMessageResponse, error) { func (wa *WhatsAppClient) HandleMatrixPollStart(ctx context.Context, msg *bridgev2.MatrixPollStart) (*bridgev2.MatrixMessageResponse, error) {
@ -107,13 +91,12 @@ func (wa *WhatsAppClient) handleConvertedMatrixMessage(ctx context.Context, msg
wrappedMsgID2 := waid.MakeMessageID(chatJID, wa.GetStore().GetLID(), req.ID) wrappedMsgID2 := waid.MakeMessageID(chatJID, wa.GetStore().GetLID(), req.ID)
msg.AddPendingToIgnore(networkid.TransactionID(wrappedMsgID)) msg.AddPendingToIgnore(networkid.TransactionID(wrappedMsgID))
msg.AddPendingToIgnore(networkid.TransactionID(wrappedMsgID2)) msg.AddPendingToIgnore(networkid.TransactionID(wrappedMsgID2))
zerolog.Ctx(ctx).Trace().Any("payload", waMsg).Msg("Outgoing message payload")
resp, err := wa.Client.SendMessage(ctx, chatJID, waMsg, *req) resp, err := wa.Client.SendMessage(ctx, chatJID, waMsg, *req)
if err != nil { if err != nil {
return nil, err return nil, err
} }
var pickedMessageID networkid.MessageID var pickedMessageID networkid.MessageID
if resp.Sender == wa.GetStore().GetLID() && chatJID.Server != types.DefaultUserServer { if resp.Sender == wa.GetStore().GetLID() {
pickedMessageID = wrappedMsgID2 pickedMessageID = wrappedMsgID2
msg.RemovePending(networkid.TransactionID(wrappedMsgID)) msg.RemovePending(networkid.TransactionID(wrappedMsgID))
} else { } else {
@ -173,7 +156,7 @@ func (wa *WhatsAppClient) HandleMatrixReaction(ctx context.Context, msg *bridgev
} }
var req whatsmeow.SendRequestExtra var req whatsmeow.SendRequestExtra
if msg.Portal.Metadata.(*waid.PortalMetadata).CommunityAnnouncementGroup { if msg.Portal.Metadata.(*waid.PortalMetadata).CommunityAnnouncementGroup {
reactionMsg.EncReactionMessage, err = wa.Client.EncryptReaction(ctx, msgconv.MessageIDToInfo(wa.Client, messageID), reactionMsg.ReactionMessage) reactionMsg.EncReactionMessage, err = wa.Client.EncryptReaction(msgconv.MessageIDToInfo(wa.Client, messageID), reactionMsg.ReactionMessage)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to encrypt reaction: %w", err) return nil, fmt.Errorf("failed to encrypt reaction: %w", err)
} }
@ -323,7 +306,7 @@ func (wa *WhatsAppClient) HandleMatrixReadReceipt(ctx context.Context, receipt *
messagesToRead[key] = append(messagesToRead[key], parsed.ID) messagesToRead[key] = append(messagesToRead[key], parsed.ID)
} }
for messageSender, ids := range messagesToRead { for messageSender, ids := range messagesToRead {
err = wa.Client.MarkRead(ctx, ids, receipt.Receipt.Timestamp, portalJID, messageSender) err = wa.Client.MarkRead(ids, receipt.Receipt.Timestamp, portalJID, messageSender)
if err != nil { if err != nil {
log.Err(err).Strs("ids", ids).Msg("Failed to mark messages as read") log.Err(err).Strs("ids", ids).Msg("Failed to mark messages as read")
} }
@ -353,60 +336,26 @@ func (wa *WhatsAppClient) HandleMatrixTyping(ctx context.Context, msg *bridgev2.
} }
if wa.Main.Config.SendPresenceOnTyping { if wa.Main.Config.SendPresenceOnTyping {
err = wa.updatePresence(ctx, types.PresenceAvailable) err = wa.Client.SendPresence(types.PresenceAvailable)
if err != nil { if err != nil {
zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to set presence on typing") zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to set presence on typing")
} }
} }
return wa.Client.SendChatPresence(ctx, portalJID, chatPresence, mediaPresence) return wa.Client.SendChatPresence(portalJID, chatPresence, mediaPresence)
} }
var errUnsupportedDisappearingTimer = bridgev2.WrapErrorInStatus(errors.New("invalid value for disappearing timer")).WithErrorAsMessage().WithIsCertain(true).WithSendNotice(true) func (wa *WhatsAppClient) HandleMatrixMembership(ctx context.Context, msg *bridgev2.MatrixMembershipChange) (bool, error) {
func (wa *WhatsAppClient) HandleMatrixDisappearingTimer(ctx context.Context, msg *bridgev2.MatrixDisappearingTimer) (bool, error) {
portalJID, err := waid.ParsePortalID(msg.Portal.ID) portalJID, err := waid.ParsePortalID(msg.Portal.ID)
if err != nil { if err != nil {
return false, err return false, err
} }
switch msg.Content.Timer.Duration {
case whatsmeow.DisappearingTimerOff, whatsmeow.DisappearingTimer24Hours, whatsmeow.DisappearingTimer7Days, whatsmeow.DisappearingTimer90Days:
default:
return false, fmt.Errorf("%w (%s)", errUnsupportedDisappearingTimer, msg.Content.Timer.Duration)
}
settingTS := time.UnixMilli(msg.Event.Timestamp)
err = wa.Client.SetDisappearingTimer(ctx, portalJID, msg.Content.Timer.Duration, settingTS)
if err != nil {
return false, err
}
msg.Portal.Metadata.(*waid.PortalMetadata).DisappearingTimerSetAt = settingTS.Unix()
msg.Portal.Disappear = database.DisappearingSetting{
Type: event.DisappearingTypeAfterSend,
Timer: msg.Content.Timer.Duration,
}
if msg.Portal.Disappear.Timer == 0 {
msg.Portal.Disappear.Type = event.DisappearingTypeNone
}
return true, nil
}
func (wa *WhatsAppClient) HandleMatrixMembership(ctx context.Context, msg *bridgev2.MatrixMembershipChange) (*bridgev2.MatrixMembershipResult, error) {
if msg.Type.IsSelf && msg.OrigSender != nil {
return nil, nil
}
portalJID, err := waid.ParsePortalID(msg.Portal.ID)
if err != nil {
return nil, err
}
if msg.Portal.RoomType == database.RoomTypeDM { if msg.Portal.RoomType == database.RoomTypeDM {
switch msg.Type { switch msg.Type {
case bridgev2.Invite: case bridgev2.Invite:
return nil, fmt.Errorf("cannot invite additional user to dm") return false, fmt.Errorf("cannot invite additional user to dm")
default: default:
return nil, nil return false, nil
} }
} }
@ -419,7 +368,7 @@ func (wa *WhatsAppClient) HandleMatrixMembership(ctx context.Context, msg *bridg
case bridgev2.Leave, bridgev2.Kick: case bridgev2.Leave, bridgev2.Kick:
action = whatsmeow.ParticipantChangeRemove action = whatsmeow.ParticipantChangeRemove
default: default:
return nil, nil return false, nil
} }
switch target := msg.Target.(type) { switch target := msg.Target.(type) {
@ -428,245 +377,17 @@ func (wa *WhatsAppClient) HandleMatrixMembership(ctx context.Context, msg *bridg
case *bridgev2.UserLogin: case *bridgev2.UserLogin:
ghost, err := target.Bridge.GetGhostByID(ctx, networkid.UserID(target.ID)) ghost, err := target.Bridge.GetGhostByID(ctx, networkid.UserID(target.ID))
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get ghost for user: %w", err) return false, fmt.Errorf("failed to get ghost for user: %w", err)
} }
changes[0] = waid.ParseUserID(ghost.ID) changes[0] = waid.ParseUserID(ghost.ID)
default: default:
return nil, fmt.Errorf("cannot get target intent: unknown type: %T", target) return false, fmt.Errorf("cannot get target intent: unknown type: %T", target)
} }
resp, err := wa.Client.UpdateGroupParticipants(ctx, portalJID, changes, action) _, err = wa.Client.UpdateGroupParticipants(portalJID, changes, action)
if err != nil {
return nil, err
} else if len(resp) == 0 {
return nil, fmt.Errorf("no response for participant change")
} else if resp[0].Error != 0 {
return nil, fmt.Errorf("failed to change participant: code %d", resp[0].Error)
}
zerolog.Ctx(ctx).Debug().
Any("change_response", resp).
Msg("Handled membership change")
return &bridgev2.MatrixMembershipResult{RedirectTo: waid.MakeUserID(resp[0].JID)}, nil
}
func (wa *WhatsAppClient) HandleMatrixRoomName(ctx context.Context, msg *bridgev2.MatrixRoomName) (bool, error) {
portalJID, err := waid.ParsePortalID(msg.Portal.ID)
if err != nil { if err != nil {
return false, err return false, err
} }
if msg.Portal.RoomType == database.RoomTypeDM {
return false, fmt.Errorf("cannot set room name for DM")
}
err = wa.Client.SetGroupName(ctx, portalJID, msg.Content.Name)
if err != nil {
return false, err
}
msg.Portal.Name = msg.Content.Name
msg.Portal.NameSet = true
return true, nil return true, nil
} }
func (wa *WhatsAppClient) HandleMatrixRoomTopic(ctx context.Context, msg *bridgev2.MatrixRoomTopic) (bool, error) {
portalJID, err := waid.ParsePortalID(msg.Portal.ID)
if err != nil {
return false, err
}
if msg.Portal.RoomType == database.RoomTypeDM {
return false, fmt.Errorf("cannot set room topic for DM")
}
newID := wa.Client.GenerateMessageID()
oldID := msg.Portal.Metadata.(*waid.PortalMetadata).TopicID
err = wa.Client.SetGroupTopic(ctx, portalJID, oldID, newID, msg.Content.Topic)
if err != nil {
return false, err
}
msg.Portal.Topic = msg.Content.Topic
msg.Portal.TopicSet = true
msg.Portal.Metadata.(*waid.PortalMetadata).TopicID = newID
return true, nil
}
func (wa *WhatsAppClient) HandleMatrixRoomAvatar(ctx context.Context, msg *bridgev2.MatrixRoomAvatar) (bool, error) {
portalJID, err := waid.ParsePortalID(msg.Portal.ID)
if err != nil {
return false, err
}
if msg.Portal.RoomType == database.RoomTypeDM {
return false, fmt.Errorf("cannot set room avatar for DM")
}
var data []byte
if msg.Content.URL != "" {
data, err = msg.Portal.Bridge.Bot.DownloadMedia(ctx, msg.Content.URL, nil)
if err != nil {
return false, fmt.Errorf("failed to download avatar: %w", err)
}
data, err = convertRoomAvatar(data)
if err != nil {
return false, err
}
}
avatarID, err := wa.Client.SetGroupPhoto(ctx, portalJID, data)
if err != nil {
return false, err
}
msg.Portal.AvatarMXC = msg.Content.URL
if data == nil {
msg.Portal.AvatarHash = [32]byte{}
msg.Portal.AvatarID = "remove"
} else {
msg.Portal.AvatarHash = sha256.Sum256(data)
msg.Portal.AvatarID = networkid.AvatarID(avatarID)
}
msg.Portal.AvatarSet = true
return true, nil
}
const avatarMaxSize = 720
const avatarMinSize = 190
func convertRoomAvatar(data []byte) ([]byte, error) {
cfg, imageType, err := image.DecodeConfig(bytes.NewReader(data))
if err != nil {
return nil, fmt.Errorf("failed to decode avatar: %w", err)
}
width, height := cfg.Width, cfg.Height
isCorrectSize := width == height && avatarMinSize < width && width < avatarMaxSize
if isCorrectSize && imageType == "jpeg" {
return data, nil
} else if len(data) > 10*1024*1024 || width > 12000 || height > 12000 {
return nil, fmt.Errorf("avatar is too large for re-encoding")
}
img, _, err := image.Decode(bytes.NewReader(data))
if err != nil {
return nil, fmt.Errorf("failed to decode avatar: %w", err)
}
if !isCorrectSize {
var squareCrop image.Rectangle
var dstSize int
if width > height {
dstSize = max(avatarMinSize, min(height, avatarMaxSize))
offset := (width - height) / 2
squareCrop = image.Rect(offset, 0, width-offset, height)
} else {
dstSize = max(avatarMinSize, min(width, avatarMaxSize))
offset := (height - width) / 2
squareCrop = image.Rect(0, offset, width, height-offset)
}
cropped := image.NewRGBA(image.Rect(0, 0, dstSize, dstSize))
draw.BiLinear.Scale(cropped, cropped.Rect, img, squareCrop, draw.Src, nil)
img = cropped
}
var buf bytes.Buffer
err = jpeg.Encode(&buf, img, &jpeg.Options{Quality: jpeg.DefaultQuality})
if err != nil {
return nil, fmt.Errorf("failed to re-encode avatar: %w", err)
}
return buf.Bytes(), nil
}
func (wa *WhatsAppClient) HandleMute(ctx context.Context, msg *bridgev2.MatrixMute) error {
chatJID, err := waid.ParsePortalID(msg.Portal.ID)
if err != nil {
return err
}
mutedUntil := msg.Content.GetMutedUntilTime()
muted := mutedUntil.After(time.Now())
muteTS := ptr.Ptr(mutedUntil.UnixMilli())
if !muted || mutedUntil == event.MutedForever {
muteTS = nil
}
return wa.Client.SendAppState(ctx, appstate.BuildMuteAbs(chatJID, muted, muteTS))
}
func (wa *WhatsAppClient) HandleRoomTag(ctx context.Context, msg *bridgev2.MatrixRoomTag) error {
chatJID, err := waid.ParsePortalID(msg.Portal.ID)
if err != nil {
return err
}
_, isFavorite := msg.Content.Tags[event.RoomTagFavourite]
return wa.Client.SendAppState(ctx, appstate.BuildPin(chatJID, isFavorite))
}
func (wa *WhatsAppClient) getLastMessageInfo(ctx context.Context, chatJID types.JID, portalKey networkid.PortalKey) (time.Time, *waCommon.MessageKey, error) {
msgs, err := wa.Main.Bridge.DB.Message.GetLastNInPortal(ctx, portalKey, 1)
if err != nil {
return time.Time{}, nil, fmt.Errorf("failed to get last message in portal: %w", err)
}
var lastTS time.Time
var lastKey *waCommon.MessageKey
if len(msgs) == 1 {
lastTS = msgs[0].Timestamp
parsed, _ := waid.ParseMessageID(msgs[0].ID)
if parsed != nil {
fromMe := parsed.Sender.ToNonAD() == wa.JID.ToNonAD() || parsed.Sender.ToNonAD() == wa.GetStore().GetLID().ToNonAD()
var participant *string
if chatJID.Server == types.GroupServer {
participant = ptr.Ptr(parsed.Sender.String())
}
lastKey = &waCommon.MessageKey{
RemoteJID: ptr.Ptr(chatJID.String()),
FromMe: &fromMe,
ID: &parsed.ID,
Participant: participant,
}
}
}
return lastTS, lastKey, nil
}
func (wa *WhatsAppClient) HandleMarkedUnread(ctx context.Context, msg *bridgev2.MatrixMarkedUnread) error {
chatJID, err := waid.ParsePortalID(msg.Portal.ID)
if err != nil {
return err
}
lastTS, lastKey, err := wa.getLastMessageInfo(ctx, chatJID, msg.Portal.PortalKey)
if err != nil {
return err
}
return wa.Client.SendAppState(ctx, appstate.BuildMarkChatAsRead(chatJID, msg.Content.Unread, lastTS, lastKey))
}
func (wa *WhatsAppClient) HandleMatrixDeleteChat(ctx context.Context, msg *bridgev2.MatrixDeleteChat) error {
chatJID, err := waid.ParsePortalID(msg.Portal.ID)
if err != nil {
return err
}
if chatJID.Server == types.GroupServer {
memberInfo, err := wa.Main.Bridge.Matrix.GetMemberInfo(ctx, msg.Portal.MXID, wa.UserLogin.UserMXID)
if err != nil {
return fmt.Errorf("failed to get own member info: %w", err)
} else if memberInfo.Membership == event.MembershipJoin {
err = wa.Client.LeaveGroup(ctx, chatJID)
if err != nil {
// TODO ignore errors saying you already left the group?
return fmt.Errorf("failed to leave group before deleting chat: %w", err)
}
}
}
lastTS, lastKey, err := wa.getLastMessageInfo(ctx, chatJID, msg.Portal.PortalKey)
if err != nil {
return err
}
return wa.Client.SendAppState(ctx, appstate.BuildDeleteChat(chatJID, lastTS, lastKey, true))
}

View file

@ -25,10 +25,7 @@ import (
"github.com/rs/zerolog" "github.com/rs/zerolog"
"go.mau.fi/util/ptr" "go.mau.fi/util/ptr"
"go.mau.fi/whatsmeow"
"go.mau.fi/whatsmeow/appstate" "go.mau.fi/whatsmeow/appstate"
"go.mau.fi/whatsmeow/proto/waE2E"
"go.mau.fi/whatsmeow/store"
"go.mau.fi/whatsmeow/types" "go.mau.fi/whatsmeow/types"
"go.mau.fi/whatsmeow/types/events" "go.mau.fi/whatsmeow/types/events"
"maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2"
@ -74,77 +71,79 @@ func init() {
}) })
} }
func (wa *WhatsAppClient) handleWAEvent(rawEvt any) (success bool) { func (wa *WhatsAppClient) handleWAEvent(rawEvt any) {
log := wa.UserLogin.Log log := wa.UserLogin.Log
ctx := log.WithContext(wa.Main.Bridge.BackgroundCtx)
success = true
switch evt := rawEvt.(type) { switch evt := rawEvt.(type) {
case *events.Message: case *events.Message:
success = wa.handleWAMessage(ctx, evt) wa.handleWAMessage(evt)
case *events.Receipt: case *events.Receipt:
success = wa.handleWAReceipt(ctx, evt) wa.handleWAReceipt(evt)
case *events.ChatPresence: case *events.ChatPresence:
wa.handleWAChatPresence(ctx, evt) wa.handleWAChatPresence(evt)
case *events.UndecryptableMessage: case *events.UndecryptableMessage:
success = wa.handleWAUndecryptableMessage(ctx, evt) wa.handleWAUndecryptableMessage(evt)
case *events.CallOffer: case *events.CallOffer:
success = wa.handleWACallStart(ctx, evt.GroupJID, evt.CallCreator, evt.CallCreatorAlt, evt.CallID, "", evt.Timestamp) wa.handleWACallStart(evt.CallCreator, evt.CallID, "", evt.Timestamp)
case *events.CallOfferNotice: case *events.CallOfferNotice:
success = wa.handleWACallStart(ctx, evt.GroupJID, evt.CallCreator, evt.CallCreatorAlt, evt.CallID, evt.Type, evt.Timestamp) wa.handleWACallStart(evt.CallCreator, evt.CallID, evt.Type, evt.Timestamp)
case *events.CallTerminate, *events.CallRelayLatency, *events.CallAccept, *events.UnknownCallEvent: case *events.CallTerminate, *events.CallRelayLatency, *events.CallAccept, *events.UnknownCallEvent:
// ignore // ignore
case *events.IdentityChange: case *events.IdentityChange:
wa.handleWAIdentityChange(ctx, evt) wa.handleWAIdentityChange(evt)
case *events.MarkChatAsRead: case *events.MarkChatAsRead:
success = wa.handleWAMarkChatAsRead(ctx, evt) wa.handleWAMarkChatAsRead(evt)
case *events.DeleteForMe: case *events.DeleteForMe:
success = wa.handleWADeleteForMe(ctx, evt) wa.handleWADeleteForMe(evt)
case *events.DeleteChat: case *events.DeleteChat:
success = wa.handleWADeleteChat(ctx, evt) wa.handleWADeleteChat(evt)
case *events.Mute: case *events.Mute:
success = wa.handleWAMute(evt) wa.handleWAMute(evt)
case *events.Archive: case *events.Archive:
success = wa.handleWAArchive(evt) wa.handleWAArchive(evt)
case *events.Pin: case *events.Pin:
success = wa.handleWAPin(evt) wa.handleWAPin(evt)
case *events.HistorySync: case *events.HistorySync:
wa.UserLogin.Log.Warn().Msg("Unexpected history sync event received") if wa.Main.Bridge.Config.Backfill.Enabled {
wa.historySyncs <- evt.Data
}
case *events.MediaRetry: case *events.MediaRetry:
wa.phoneSeen(evt.Timestamp) wa.phoneSeen(evt.Timestamp)
success = wa.UserLogin.QueueRemoteEvent(&WAMediaRetry{MediaRetry: evt, wa: wa}).Success wa.UserLogin.QueueRemoteEvent(&WAMediaRetry{MediaRetry: evt, wa: wa})
case *events.GroupInfo: case *events.GroupInfo:
success = wa.handleWAGroupInfoChange(ctx, evt) wa.handleWAGroupInfoChange(evt)
case *events.JoinedGroup: case *events.JoinedGroup:
success = wa.handleWAJoinedGroup(ctx, evt) wa.handleWAJoinedGroup(evt)
case *events.NewsletterJoin: case *events.NewsletterJoin:
success = wa.handleWANewsletterJoin(ctx, evt) wa.handleWANewsletterJoin(evt)
case *events.NewsletterLeave: case *events.NewsletterLeave:
success = wa.handleWANewsletterLeave(evt) wa.handleWANewsletterLeave(evt)
case *events.Picture: case *events.Picture:
success = wa.handleWAPictureUpdate(ctx, evt) go wa.handleWAPictureUpdate(evt)
case *events.AppStateSyncComplete: case *events.AppStateSyncComplete:
wa.handleWAAppStateSyncComplete(ctx, evt) if len(wa.GetStore().PushName) > 0 && evt.Name == appstate.WAPatchCriticalBlock {
case *events.AppStateSyncError: err := wa.Client.SendPresence(types.PresenceUnavailable)
wa.handleWAAppStateSyncError(ctx, evt) if err != nil {
log.Warn().Err(err).Msg("Failed to send presence after app state sync")
}
go wa.syncRemoteProfile(log.WithContext(context.Background()), nil)
} else if evt.Name == appstate.WAPatchCriticalUnblockLow {
go wa.resyncContacts(false)
}
case *events.AppState: case *events.AppState:
// Intentionally ignored // Intentionally ignored
case *events.PushNameSetting: case *events.PushNameSetting:
// Send presence available when connecting and when the pushname is changed. // Send presence available when connecting and when the pushname is changed.
// This makes sure that outgoing messages always have the right pushname. // This makes sure that outgoing messages always have the right pushname.
err := wa.updatePresence(ctx, types.PresenceUnavailable) err := wa.Client.SendPresence(types.PresenceUnavailable)
if err != nil { if err != nil {
log.Warn().Err(err).Msg("Failed to send presence after push name update") log.Warn().Err(err).Msg("Failed to send presence after push name update")
} }
_, _, err = wa.GetStore().Contacts.PutPushName(ctx, wa.JID.ToNonAD(), evt.Action.GetName()) _, _, err = wa.GetStore().Contacts.PutPushName(wa.JID.ToNonAD(), evt.Action.GetName())
if err != nil {
log.Err(err).Msg("Failed to update push name in store")
}
_, _, err = wa.GetStore().Contacts.PutPushName(ctx, wa.GetStore().GetLID().ToNonAD(), evt.Action.GetName())
if err != nil { if err != nil {
log.Err(err).Msg("Failed to update push name in store") log.Err(err).Msg("Failed to update push name in store")
} }
@ -161,14 +160,27 @@ func (wa *WhatsAppClient) handleWAEvent(rawEvt any) (success bool) {
wa.UserLogin.BridgeState.Send(status.BridgeState{StateEvent: status.StateConnected}) wa.UserLogin.BridgeState.Send(status.BridgeState{StateEvent: status.StateConnected})
if len(wa.GetStore().PushName) > 0 { if len(wa.GetStore().PushName) > 0 {
go func() { go func() {
err := wa.updatePresence(ctx, types.PresenceUnavailable) err := wa.Client.SendPresence(types.PresenceUnavailable)
if err != nil { if err != nil {
log.Warn().Err(err).Msg("Failed to send initial presence after connecting") log.Warn().Err(err).Msg("Failed to send initial presence after connecting")
} }
}() }()
go wa.syncRemoteProfile(ctx, nil) go wa.syncRemoteProfile(log.WithContext(context.Background()), nil)
}
meta := wa.UserLogin.Metadata.(*waid.UserLoginMetadata)
if meta.WALID == "" {
meta.WALID = wa.GetStore().GetLID().User
if meta.WALID != "" {
go func() {
err := wa.UserLogin.Save(log.WithContext(context.Background()))
if err != nil {
log.Err(err).Msg("Failed to save user login metadata after updating LID")
} else {
log.Info().Msg("Updated LID in user login metadata")
}
}()
}
} }
wa.MC.OnConnect(store.GetWAVersion()[2], wa.Device.Platform)
case *events.OfflineSyncPreview: case *events.OfflineSyncPreview:
log.Info(). log.Info().
Int("message_count", evt.Messages). Int("message_count", evt.Messages).
@ -179,13 +191,10 @@ func (wa *WhatsAppClient) handleWAEvent(rawEvt any) (success bool) {
case *events.OfflineSyncCompleted: case *events.OfflineSyncCompleted:
if !wa.PhoneRecentlySeen(true) { if !wa.PhoneRecentlySeen(true) {
log.Info(). log.Info().
Int("evt_count", evt.Count).
Time("phone_last_seen", wa.UserLogin.Metadata.(*waid.UserLoginMetadata).PhoneLastSeen.Time). Time("phone_last_seen", wa.UserLogin.Metadata.(*waid.UserLoginMetadata).PhoneLastSeen.Time).
Msg("Offline sync completed, but phone last seen date is still old") Msg("Offline sync completed, but phone last seen date is still old")
} else { } else {
log.Info(). log.Info().Msg("Offline sync completed")
Int("evt_count", evt.Count).
Msg("Offline sync completed")
} }
wa.UserLogin.BridgeState.Send(status.BridgeState{StateEvent: status.StateConnected}) wa.UserLogin.BridgeState.Send(status.BridgeState{StateEvent: status.StateConnected})
wa.notifyOfflineSyncWaiter(nil) wa.notifyOfflineSyncWaiter(nil)
@ -243,78 +252,22 @@ func (wa *WhatsAppClient) handleWAEvent(rawEvt any) (success bool) {
default: default:
log.Debug().Type("event_type", rawEvt).Msg("Unhandled WhatsApp event") log.Debug().Type("event_type", rawEvt).Msg("Unhandled WhatsApp event")
} }
return
} }
func (wa *WhatsAppClient) rerouteWAMessage(ctx context.Context, evtType string, info *types.MessageSource, msgID any) { func (wa *WhatsAppClient) handleWAMessage(evt *events.Message) {
if (info.Chat.Server == types.HiddenUserServer || info.Chat.Server == types.BroadcastServer) && wa.UserLogin.Log.Trace().
info.Sender.Server == types.HiddenUserServer && info.SenderAlt.IsEmpty() { Any("info", evt.Info).
info.SenderAlt, _ = wa.GetStore().LIDs.GetPNForLID(ctx, info.Sender) Any("payload", evt.Message).
} Msg("Received WhatsApp message")
if info.Chat.Server == types.HiddenUserServer && info.IsFromMe && info.RecipientAlt.IsEmpty() {
info.RecipientAlt, _ = wa.GetStore().LIDs.GetPNForLID(ctx, info.Chat)
}
if info.Chat.Server == types.HiddenUserServer && info.Sender.ToNonAD() == info.Chat && info.SenderAlt.Server == types.DefaultUserServer {
wa.UserLogin.Log.Debug().
Stringer("lid", info.Sender).
Stringer("pn", info.SenderAlt).
Any("message_id", msgID).
Str("evt_type", evtType).
Msg("Forced LID DM sender to phone number in incoming message")
info.Sender, info.SenderAlt = info.SenderAlt, info.Sender
info.Chat = info.Sender.ToNonAD()
} else if info.Chat.Server == types.HiddenUserServer && info.IsFromMe && info.RecipientAlt.Server == types.DefaultUserServer {
wa.UserLogin.Log.Debug().
Stringer("lid", info.Chat).
Stringer("pn", info.RecipientAlt).
Any("message_id", msgID).
Str("evt_type", evtType).
Msg("Forced LID DM sender to phone number in own message sent from another device")
info.Chat = info.RecipientAlt.ToNonAD()
if info.Sender.Server == types.HiddenUserServer {
info.Sender, info.SenderAlt = info.SenderAlt, info.Sender
if info.Sender.IsEmpty() {
info.Sender = wa.GetStore().GetJID()
info.Sender.Device = info.SenderAlt.Device
}
}
} else if info.Chat.Server == types.BroadcastServer && info.Sender.Server == types.HiddenUserServer && info.SenderAlt.Server == types.DefaultUserServer {
wa.UserLogin.Log.Debug().
Stringer("lid", info.Sender).
Stringer("pn", info.SenderAlt).
Stringer("chat", info.Chat).
Any("message_id", msgID).
Str("evt_type", evtType).
Msg("Forced LID broadcast list sender to phone number in incoming message")
info.Sender, info.SenderAlt = info.SenderAlt, info.Sender
} else if info.Sender.Server == types.BotServer && info.Chat.Server == types.HiddenUserServer {
chatPN, err := wa.GetStore().LIDs.GetPNForLID(ctx, info.Chat)
if err != nil {
wa.UserLogin.Log.Err(err).
Any("message_id", msgID).
Stringer("lid", info.Chat).
Str("evt_type", evtType).
Msg("Failed to get phone number of DM for incoming bot message")
} else if !chatPN.IsEmpty() {
wa.UserLogin.Log.Debug().
Stringer("lid", info.Chat).
Stringer("pn", chatPN).
Any("message_id", msgID).
Str("evt_type", evtType).
Msg("Forced LID chat to phone number in bot message")
info.Chat = chatPN
}
}
}
func (wa *WhatsAppClient) handleWAMessage(ctx context.Context, evt *events.Message) (success bool) {
success = true
if evt.Info.Chat == types.StatusBroadcastJID && !wa.Main.Config.EnableStatusBroadcast { if evt.Info.Chat == types.StatusBroadcastJID && !wa.Main.Config.EnableStatusBroadcast {
return return
} }
parsedMessageType := getMessageType(evt.Message) parsedMessageType := getMessageType(evt.Message)
if parsedMessageType == "ignore" || strings.HasPrefix(parsedMessageType, "unknown_protocol_") {
return
}
if encReact := evt.Message.GetEncReactionMessage(); encReact != nil { if encReact := evt.Message.GetEncReactionMessage(); encReact != nil {
decrypted, err := wa.Client.DecryptReaction(ctx, evt) decrypted, err := wa.Client.DecryptReaction(evt)
if err != nil { if err != nil {
wa.UserLogin.Log.Err(err).Str("message_id", evt.Info.ID).Msg("Failed to decrypt reaction") wa.UserLogin.Log.Err(err).Str("message_id", evt.Info.ID).Msg("Failed to decrypt reaction")
return return
@ -323,7 +276,7 @@ func (wa *WhatsAppClient) handleWAMessage(ctx context.Context, evt *events.Messa
evt.Message.ReactionMessage = decrypted evt.Message.ReactionMessage = decrypted
} }
if encComment := evt.Message.GetEncCommentMessage(); encComment != nil { if encComment := evt.Message.GetEncCommentMessage(); encComment != nil {
decrypted, err := wa.Client.DecryptComment(ctx, evt) decrypted, err := wa.Client.DecryptComment(evt)
if err != nil { if err != nil {
wa.UserLogin.Log.Err(err).Str("message_id", evt.Info.ID).Msg("Failed to decrypt comment") wa.UserLogin.Log.Err(err).Str("message_id", evt.Info.ID).Msg("Failed to decrypt comment")
} else { } else {
@ -331,62 +284,7 @@ func (wa *WhatsAppClient) handleWAMessage(ctx context.Context, evt *events.Messa
evt.Message = decrypted evt.Message = decrypted
} }
} }
if encMessage := evt.Message.GetSecretEncryptedMessage(); encMessage != nil { wa.Main.Bridge.QueueRemoteEvent(wa.UserLogin, &WAMessageEvent{
decrypted, err := wa.Client.DecryptSecretEncryptedMessage(ctx, evt)
if err != nil {
wa.UserLogin.Log.Err(err).
Str("message_id", evt.Info.ID).
Stringer("evt_sender", evt.Info.Sender).
Any("target_message_key", encMessage.TargetMessageKey).
Msg("Failed to decrypt secret-encrypted message")
return
}
evt.RawMessage = decrypted
evt.UnwrapRaw()
parsedMessageType = getMessageType(evt.Message)
}
wa.rerouteWAMessage(ctx, "message", &evt.Info.MessageSource, evt.Info.ID)
wa.UserLogin.Log.Trace().
Any("info", evt.Info).
Any("payload", evt.Message).
Msg("Received WhatsApp message")
if evt.Info.IsFromMe &&
evt.Message.GetProtocolMessage().GetHistorySyncNotification() != nil &&
wa.Main.Bridge.Config.Backfill.Enabled {
wa.saveWAHistorySyncNotification(ctx, evt.Message.ProtocolMessage.HistorySyncNotification)
}
if parsedMessageType == "ignore" || strings.HasPrefix(parsedMessageType, "unknown_protocol_") {
return
}
messageAssoc := evt.Message.GetMessageContextInfo().GetMessageAssociation()
if assocType := messageAssoc.GetAssociationType(); assocType == waE2E.MessageAssociation_HD_IMAGE_DUAL_UPLOAD || assocType == waE2E.MessageAssociation_HD_VIDEO_DUAL_UPLOAD {
parentKey := messageAssoc.GetParentMessageKey()
associatedMessage := evt.Message.GetAssociatedChildMessage().GetMessage()
wa.UserLogin.Log.Debug().
Str("message_id", evt.Info.ID).
Str("parent_id", parentKey.GetID()).
Stringer("assoc_type", assocType).
Msg("Received HD replacement message, converting to edit")
protocolMsg := &waE2E.ProtocolMessage{
Type: waE2E.ProtocolMessage_MESSAGE_EDIT.Enum(),
Key: parentKey,
EditedMessage: associatedMessage,
}
evt.Message = &waE2E.Message{
ProtocolMessage: protocolMsg,
}
} else if assocType == waE2E.MessageAssociation_MOTION_PHOTO {
//evt.Message = evt.Message.GetAssociatedChildMessage().GetMessage()
wa.UserLogin.Log.Debug().
Str("message_id", evt.Info.ID).
Str("parent_id", messageAssoc.GetParentMessageKey().GetID()).
Msg("Ignoring motion photo update")
return
}
res := wa.UserLogin.QueueRemoteEvent(&WAMessageEvent{
MessageInfoWrapper: &MessageInfoWrapper{ MessageInfoWrapper: &MessageInfoWrapper{
Info: evt.Info, Info: evt.Info,
wa: wa, wa: wa,
@ -396,11 +294,9 @@ func (wa *WhatsAppClient) handleWAMessage(ctx context.Context, evt *events.Messa
parsedMessageType: parsedMessageType, parsedMessageType: parsedMessageType,
}) })
return res.Success
} }
func (wa *WhatsAppClient) handleWAUndecryptableMessage(ctx context.Context, evt *events.UndecryptableMessage) bool { func (wa *WhatsAppClient) handleWAUndecryptableMessage(evt *events.UndecryptableMessage) {
wa.rerouteWAMessage(ctx, "undecryptable message", &evt.Info.MessageSource, evt.Info.ID)
wa.UserLogin.Log.Debug(). wa.UserLogin.Log.Debug().
Any("info", evt.Info). Any("info", evt.Info).
Bool("unavailable", evt.IsUnavailable). Bool("unavailable", evt.IsUnavailable).
@ -408,24 +304,21 @@ func (wa *WhatsAppClient) handleWAUndecryptableMessage(ctx context.Context, evt
Msg("Received undecryptable WhatsApp message") Msg("Received undecryptable WhatsApp message")
wa.trackUndecryptable(evt) wa.trackUndecryptable(evt)
if evt.DecryptFailMode == events.DecryptFailHide { if evt.DecryptFailMode == events.DecryptFailHide {
return true return
} }
if evt.Info.Chat == types.StatusBroadcastJID && !wa.Main.Config.EnableStatusBroadcast { if evt.Info.Chat == types.StatusBroadcastJID && !wa.Main.Config.EnableStatusBroadcast {
return true return
} }
res := wa.UserLogin.QueueRemoteEvent(&WAUndecryptableMessage{ wa.Main.Bridge.QueueRemoteEvent(wa.UserLogin, &WAUndecryptableMessage{
MessageInfoWrapper: &MessageInfoWrapper{ MessageInfoWrapper: &MessageInfoWrapper{
Info: evt.Info, Info: evt.Info,
wa: wa, wa: wa,
}, },
Type: evt.UnavailableType, Type: evt.UnavailableType,
}) })
return res.Success
} }
func (wa *WhatsAppClient) handleWAReceipt(ctx context.Context, evt *events.Receipt) (success bool) { func (wa *WhatsAppClient) handleWAReceipt(evt *events.Receipt) {
origChat := evt.Chat
wa.rerouteWAMessage(ctx, "receipt", &evt.MessageSource, evt.MessageIDs)
if evt.IsFromMe && evt.Sender.Device == 0 { if evt.IsFromMe && evt.Sender.Device == 0 {
wa.phoneSeen(evt.Timestamp) wa.phoneSeen(evt.Timestamp)
} }
@ -438,47 +331,28 @@ func (wa *WhatsAppClient) handleWAReceipt(ctx context.Context, evt *events.Recei
case types.ReceiptTypeSender: case types.ReceiptTypeSender:
fallthrough fallthrough
default: default:
return true return
} }
targets := make([]networkid.MessageID, len(evt.MessageIDs)) targets := make([]networkid.MessageID, len(evt.MessageIDs))
messageSender := wa.JID messageSender := wa.JID
if !evt.MessageSender.IsEmpty() { if !evt.MessageSender.IsEmpty() {
messageSender = evt.MessageSender messageSender = evt.MessageSender
// Second part of rerouting receipts in LID chats
if messageSender == origChat && evt.Chat != origChat {
messageSender = evt.Chat
}
} else if evt.Chat.Server == types.GroupServer && evt.Sender.Server == types.HiddenUserServer {
lid := wa.GetStore().GetLID()
if !lid.IsEmpty() {
messageSender = lid
}
} }
for i, id := range evt.MessageIDs { for i, id := range evt.MessageIDs {
targets[i] = waid.MakeMessageID(evt.Chat, messageSender, id) targets[i] = waid.MakeMessageID(evt.Chat, messageSender, id)
} }
res := wa.UserLogin.QueueRemoteEvent(&simplevent.Receipt{ wa.Main.Bridge.QueueRemoteEvent(wa.UserLogin, &simplevent.Receipt{
EventMeta: simplevent.EventMeta{ EventMeta: simplevent.EventMeta{
Type: evtType, Type: evtType,
PortalKey: wa.makeWAPortalKey(evt.Chat), PortalKey: wa.makeWAPortalKey(evt.Chat),
Sender: wa.makeEventSender(ctx, evt.Sender), Sender: wa.makeEventSender(evt.Sender),
Timestamp: evt.Timestamp, Timestamp: evt.Timestamp,
}, },
Targets: targets, Targets: targets,
}) })
return res.Success
} }
func (wa *WhatsAppClient) handleWAChatPresence(ctx context.Context, evt *events.ChatPresence) { func (wa *WhatsAppClient) handleWAChatPresence(evt *events.ChatPresence) {
if evt.Chat.Server == types.HiddenUserServer && evt.Sender.ToNonAD() == evt.Chat {
if evt.SenderAlt.IsEmpty() {
evt.SenderAlt, _ = wa.GetStore().LIDs.GetPNForLID(ctx, evt.Sender)
}
if evt.SenderAlt.Server == types.DefaultUserServer {
evt.Sender, evt.SenderAlt = evt.SenderAlt, evt.Sender
evt.Chat = evt.Sender.ToNonAD()
}
}
typingType := bridgev2.TypingTypeText typingType := bridgev2.TypingTypeText
timeout := 15 * time.Second timeout := 15 * time.Second
if evt.Media == types.ChatPresenceMediaAudio { if evt.Media == types.ChatPresenceMediaAudio {
@ -488,12 +362,12 @@ func (wa *WhatsAppClient) handleWAChatPresence(ctx context.Context, evt *events.
timeout = 0 timeout = 0
} }
wa.UserLogin.QueueRemoteEvent(&simplevent.Typing{ wa.Main.Bridge.QueueRemoteEvent(wa.UserLogin, &simplevent.Typing{
EventMeta: simplevent.EventMeta{ EventMeta: simplevent.EventMeta{
Type: bridgev2.RemoteEventTyping, Type: bridgev2.RemoteEventTyping,
LogContext: nil, LogContext: nil,
PortalKey: wa.makeWAPortalKey(evt.Chat), PortalKey: wa.makeWAPortalKey(evt.Chat),
Sender: wa.makeEventSender(ctx, evt.Sender), Sender: wa.makeEventSender(evt.Sender),
Timestamp: time.Now(), Timestamp: time.Now(),
}, },
Timeout: timeout, Timeout: timeout,
@ -508,7 +382,7 @@ func (wa *WhatsAppClient) handleWALogout(reason events.ConnectFailureReason, onC
} else if reason == events.ConnectFailureMainDeviceGone { } else if reason == events.ConnectFailureMainDeviceGone {
errorCode = WAMainDeviceGone errorCode = WAMainDeviceGone
} }
wa.Disconnect() wa.Client.Disconnect()
wa.Client = nil wa.Client = nil
wa.JID = types.EmptyJID wa.JID = types.EmptyJID
wa.UserLogin.Metadata.(*waid.UserLoginMetadata).WADeviceID = 0 wa.UserLogin.Metadata.(*waid.UserLoginMetadata).WADeviceID = 0
@ -520,36 +394,23 @@ func (wa *WhatsAppClient) handleWALogout(reason events.ConnectFailureReason, onC
const callEventMaxAge = 15 * time.Minute const callEventMaxAge = 15 * time.Minute
func (wa *WhatsAppClient) handleWACallStart(ctx context.Context, group, sender, senderAlt types.JID, id, callType string, ts time.Time) bool { func (wa *WhatsAppClient) handleWACallStart(sender types.JID, id, callType string, ts time.Time) {
if !wa.Main.Config.CallStartNotices || time.Since(ts) > callEventMaxAge { if !wa.Main.Config.CallStartNotices || time.Since(ts) > callEventMaxAge {
return true return
} }
if sender.Server == types.HiddenUserServer && senderAlt.Server == types.DefaultUserServer { wa.UserLogin.QueueRemoteEvent(&simplevent.Message[string]{
wa.UserLogin.Log.Debug().
Stringer("lid", sender).
Stringer("pn", senderAlt).
Str("call_id", id).
Msg("Forced LID caller to phone number in incoming call")
sender, senderAlt = senderAlt, sender
}
chat := group
if chat.IsEmpty() {
chat = sender
}
return wa.UserLogin.QueueRemoteEvent(&simplevent.Message[string]{
EventMeta: simplevent.EventMeta{ EventMeta: simplevent.EventMeta{
Type: bridgev2.RemoteEventMessage, Type: bridgev2.RemoteEventMessage,
LogContext: nil, LogContext: nil,
PortalKey: wa.makeWAPortalKey(chat), PortalKey: wa.makeWAPortalKey(sender),
Sender: wa.makeEventSender(ctx, sender), Sender: wa.makeEventSender(sender),
CreatePortal: true, CreatePortal: true,
Timestamp: ts, Timestamp: ts,
StreamOrder: ts.Unix(),
}, },
Data: callType, Data: callType,
ID: waid.MakeFakeMessageID(chat, sender, "call-"+id), ID: waid.MakeFakeMessageID(sender, sender, "call-"+id),
ConvertMessageFunc: convertCallStart, ConvertMessageFunc: convertCallStart,
}).Success })
} }
func convertCallStart(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI, callType string) (*bridgev2.ConvertedMessage, error) { func convertCallStart(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI, callType string) (*bridgev2.ConvertedMessage, error) {
@ -563,16 +424,12 @@ func convertCallStart(ctx context.Context, portal *bridgev2.Portal, intent bridg
Content: &event.MessageEventContent{ Content: &event.MessageEventContent{
MsgType: event.MsgText, MsgType: event.MsgText,
Body: text, Body: text,
BeeperActionMessage: &event.BeeperActionMessage{
Type: event.BeeperActionMessageCall,
CallType: event.BeeperActionMessageCallType(callType),
},
}, },
}}, }},
}, nil }, nil
} }
func (wa *WhatsAppClient) handleWAIdentityChange(ctx context.Context, evt *events.IdentityChange) { func (wa *WhatsAppClient) handleWAIdentityChange(evt *events.IdentityChange) {
if !wa.Main.Config.IdentityChangeNotices { if !wa.Main.Config.IdentityChangeNotices {
return return
} }
@ -581,7 +438,7 @@ func (wa *WhatsAppClient) handleWAIdentityChange(ctx context.Context, evt *event
Type: bridgev2.RemoteEventMessage, Type: bridgev2.RemoteEventMessage,
LogContext: nil, LogContext: nil,
PortalKey: wa.makeWAPortalKey(evt.JID), PortalKey: wa.makeWAPortalKey(evt.JID),
Sender: wa.makeEventSender(ctx, evt.JID), Sender: wa.makeEventSender(evt.JID),
CreatePortal: false, CreatePortal: false,
Timestamp: evt.Timestamp, Timestamp: evt.Timestamp,
}, },
@ -611,43 +468,39 @@ func convertIdentityChange(ctx context.Context, portal *bridgev2.Portal, intent
}, nil }, nil
} }
func (wa *WhatsAppClient) handleWADeleteChat(ctx context.Context, evt *events.DeleteChat) bool { func (wa *WhatsAppClient) handleWADeleteChat(evt *events.DeleteChat) {
chatJID := wa.maybeConvertJIDToLID(ctx, evt.JID) wa.UserLogin.QueueRemoteEvent(&simplevent.ChatDelete{
return wa.UserLogin.QueueRemoteEvent(&simplevent.ChatDelete{
EventMeta: simplevent.EventMeta{ EventMeta: simplevent.EventMeta{
Type: bridgev2.RemoteEventChatDelete, Type: bridgev2.RemoteEventChatDelete,
PortalKey: wa.makeWAPortalKey(chatJID), PortalKey: wa.makeWAPortalKey(evt.JID),
Timestamp: evt.Timestamp, Timestamp: evt.Timestamp,
}, },
OnlyForMe: true, OnlyForMe: true,
Children: true, })
}).Success
} }
func (wa *WhatsAppClient) handleWADeleteForMe(ctx context.Context, evt *events.DeleteForMe) bool { func (wa *WhatsAppClient) handleWADeleteForMe(evt *events.DeleteForMe) {
chatJID := wa.maybeConvertJIDToLID(ctx, evt.ChatJID) wa.UserLogin.QueueRemoteEvent(&simplevent.MessageRemove{
return wa.UserLogin.QueueRemoteEvent(&simplevent.MessageRemove{
EventMeta: simplevent.EventMeta{ EventMeta: simplevent.EventMeta{
Type: bridgev2.RemoteEventMessageRemove, Type: bridgev2.RemoteEventMessageRemove,
PortalKey: wa.makeWAPortalKey(chatJID), PortalKey: wa.makeWAPortalKey(evt.ChatJID),
Timestamp: evt.Timestamp, Timestamp: evt.Timestamp,
}, },
TargetMessage: waid.MakeMessageID(chatJID, evt.SenderJID, evt.MessageID), TargetMessage: waid.MakeMessageID(evt.ChatJID, evt.SenderJID, evt.MessageID),
OnlyForMe: true, OnlyForMe: true,
}).Success })
} }
func (wa *WhatsAppClient) handleWAMarkChatAsRead(ctx context.Context, evt *events.MarkChatAsRead) bool { func (wa *WhatsAppClient) handleWAMarkChatAsRead(evt *events.MarkChatAsRead) {
chatJID := wa.maybeConvertJIDToLID(ctx, evt.JID) wa.UserLogin.QueueRemoteEvent(&simplevent.Receipt{
return wa.UserLogin.QueueRemoteEvent(&simplevent.Receipt{
EventMeta: simplevent.EventMeta{ EventMeta: simplevent.EventMeta{
Type: bridgev2.RemoteEventReadReceipt, Type: bridgev2.RemoteEventReadReceipt,
PortalKey: wa.makeWAPortalKey(chatJID), PortalKey: wa.makeWAPortalKey(evt.JID),
Sender: wa.makeEventSender(ctx, wa.JID), Sender: wa.makeEventSender(wa.JID),
Timestamp: evt.Timestamp, Timestamp: evt.Timestamp,
}, },
ReadUpTo: evt.Timestamp, ReadUpTo: evt.Timestamp,
}).Success })
} }
func (wa *WhatsAppClient) syncGhost(jid types.JID, reason string, pictureID *string) { func (wa *WhatsAppClient) syncGhost(jid types.JID, reason string, pictureID *string) {
@ -657,7 +510,7 @@ func (wa *WhatsAppClient) syncGhost(jid types.JID, reason string, pictureID *str
Str("picture_id", ptr.Val(pictureID)). Str("picture_id", ptr.Val(pictureID)).
Stringer("jid", jid). Stringer("jid", jid).
Logger() Logger()
ctx := log.WithContext(wa.Main.Bridge.BackgroundCtx) ctx := log.WithContext(context.Background())
ghost, err := wa.Main.Bridge.GetGhostByID(ctx, waid.MakeUserID(jid)) ghost, err := wa.Main.Bridge.GetGhostByID(ctx, waid.MakeUserID(jid))
if err != nil { if err != nil {
log.Err(err).Msg("Failed to get ghost") log.Err(err).Msg("Failed to get ghost")
@ -672,15 +525,13 @@ func (wa *WhatsAppClient) syncGhost(jid types.JID, reason string, pictureID *str
} else { } else {
ghost.UpdateInfo(ctx, userInfo) ghost.UpdateInfo(ctx, userInfo)
log.Debug().Msg("Synced ghost info") log.Debug().Msg("Synced ghost info")
wa.syncAltGhostWithInfo(ctx, jid, userInfo)
} }
go wa.syncRemoteProfile(ctx, ghost) go wa.syncRemoteProfile(ctx, ghost)
} }
func (wa *WhatsAppClient) handleWAPictureUpdate(ctx context.Context, evt *events.Picture) bool { func (wa *WhatsAppClient) handleWAPictureUpdate(evt *events.Picture) {
if evt.JID.Server == types.DefaultUserServer || evt.JID.Server == types.HiddenUserServer || evt.JID.Server == types.BotServer { if evt.JID.Server == types.DefaultUserServer || evt.JID.Server == types.BotServer {
go wa.syncGhost(evt.JID, "picture event", &evt.PictureID) wa.syncGhost(evt.JID, "picture event", &evt.PictureID)
return true
} else { } else {
var changes bridgev2.ChatInfo var changes bridgev2.ChatInfo
if evt.Remove { if evt.Remove {
@ -688,7 +539,7 @@ func (wa *WhatsAppClient) handleWAPictureUpdate(ctx context.Context, evt *events
} else { } else {
changes.ExtraUpdates = wa.makePortalAvatarFetcher(evt.PictureID, evt.Author, evt.Timestamp) changes.ExtraUpdates = wa.makePortalAvatarFetcher(evt.PictureID, evt.Author, evt.Timestamp)
} }
return wa.UserLogin.QueueRemoteEvent(&simplevent.ChatInfoChange{ wa.UserLogin.QueueRemoteEvent(&simplevent.ChatInfoChange{
EventMeta: simplevent.EventMeta{ EventMeta: simplevent.EventMeta{
Type: bridgev2.RemoteEventChatInfoChange, Type: bridgev2.RemoteEventChatInfoChange,
LogContext: func(c zerolog.Context) zerolog.Context { LogContext: func(c zerolog.Context) zerolog.Context {
@ -699,17 +550,17 @@ func (wa *WhatsAppClient) handleWAPictureUpdate(ctx context.Context, evt *events
Bool("remove_picture", evt.Remove) Bool("remove_picture", evt.Remove)
}, },
PortalKey: wa.makeWAPortalKey(evt.JID), PortalKey: wa.makeWAPortalKey(evt.JID),
Sender: wa.makeEventSender(ctx, evt.Author), Sender: wa.makeEventSender(evt.Author),
Timestamp: evt.Timestamp, Timestamp: evt.Timestamp,
}, },
ChatInfoChange: &bridgev2.ChatInfoChange{ ChatInfoChange: &bridgev2.ChatInfoChange{
ChatInfo: &changes, ChatInfo: &changes,
}, },
}).Success })
} }
} }
func (wa *WhatsAppClient) handleWAGroupInfoChange(ctx context.Context, evt *events.GroupInfo) bool { func (wa *WhatsAppClient) handleWAGroupInfoChange(evt *events.GroupInfo) {
eventMeta := simplevent.EventMeta{ eventMeta := simplevent.EventMeta{
Type: bridgev2.RemoteEventChatInfoChange, Type: bridgev2.RemoteEventChatInfoChange,
LogContext: nil, LogContext: nil,
@ -718,59 +569,56 @@ func (wa *WhatsAppClient) handleWAGroupInfoChange(ctx context.Context, evt *even
Timestamp: evt.Timestamp, Timestamp: evt.Timestamp,
} }
if evt.Sender != nil { if evt.Sender != nil {
eventMeta.Sender = wa.makeEventSender(ctx, *evt.Sender) eventMeta.Sender = wa.makeEventSender(*evt.Sender)
} }
if evt.Delete != nil { if evt.Delete != nil {
eventMeta.Type = bridgev2.RemoteEventChatDelete eventMeta.Type = bridgev2.RemoteEventChatDelete
return wa.UserLogin.QueueRemoteEvent(&simplevent.ChatDelete{EventMeta: eventMeta}).Success wa.UserLogin.QueueRemoteEvent(&simplevent.ChatDelete{EventMeta: eventMeta})
} else { } else {
return wa.UserLogin.QueueRemoteEvent(&simplevent.ChatInfoChange{ wa.UserLogin.QueueRemoteEvent(&simplevent.ChatInfoChange{
EventMeta: eventMeta, EventMeta: eventMeta,
ChatInfoChange: wa.wrapGroupInfoChange(ctx, evt), ChatInfoChange: wa.wrapGroupInfoChange(evt),
}).Success })
} }
} }
func (wa *WhatsAppClient) handleWAJoinedGroup(ctx context.Context, evt *events.JoinedGroup) bool { func (wa *WhatsAppClient) handleWAJoinedGroup(evt *events.JoinedGroup) {
if wa.createDedup.Pop(evt.CreateKey) { wa.Main.Bridge.QueueRemoteEvent(wa.UserLogin, &simplevent.ChatResync{
return true
}
return wa.UserLogin.QueueRemoteEvent(&simplevent.ChatResync{
EventMeta: simplevent.EventMeta{ EventMeta: simplevent.EventMeta{
Type: bridgev2.RemoteEventChatResync, Type: bridgev2.RemoteEventChatResync,
LogContext: nil, LogContext: nil,
PortalKey: wa.makeWAPortalKey(evt.JID), PortalKey: wa.makeWAPortalKey(evt.JID),
CreatePortal: true, CreatePortal: true,
}, },
ChatInfo: wa.wrapGroupInfo(ctx, &evt.GroupInfo), ChatInfo: wa.wrapGroupInfo(&evt.GroupInfo),
}).Success })
} }
func (wa *WhatsAppClient) handleWANewsletterJoin(ctx context.Context, evt *events.NewsletterJoin) bool { func (wa *WhatsAppClient) handleWANewsletterJoin(evt *events.NewsletterJoin) {
return 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: nil, LogContext: nil,
PortalKey: wa.makeWAPortalKey(evt.ID), PortalKey: wa.makeWAPortalKey(evt.ID),
CreatePortal: true, CreatePortal: true,
}, },
ChatInfo: wa.wrapNewsletterInfo(ctx, &evt.NewsletterMetadata), ChatInfo: wa.wrapNewsletterInfo(&evt.NewsletterMetadata),
}).Success })
} }
func (wa *WhatsAppClient) handleWANewsletterLeave(evt *events.NewsletterLeave) bool { func (wa *WhatsAppClient) handleWANewsletterLeave(evt *events.NewsletterLeave) {
return wa.UserLogin.QueueRemoteEvent(&simplevent.ChatDelete{ wa.Main.Bridge.QueueRemoteEvent(wa.UserLogin, &simplevent.ChatDelete{
EventMeta: simplevent.EventMeta{ EventMeta: simplevent.EventMeta{
Type: bridgev2.RemoteEventChatDelete, Type: bridgev2.RemoteEventChatDelete,
LogContext: nil, LogContext: nil,
PortalKey: wa.makeWAPortalKey(evt.ID), PortalKey: wa.makeWAPortalKey(evt.ID),
}, },
OnlyForMe: true, OnlyForMe: true,
}).Success })
} }
func (wa *WhatsAppClient) handleWAUserLocalPortalInfo(chatJID types.JID, ts time.Time, info *bridgev2.UserLocalPortalInfo) bool { func (wa *WhatsAppClient) handleWAUserLocalPortalInfo(chatJID types.JID, ts time.Time, info *bridgev2.UserLocalPortalInfo) {
return wa.UserLogin.QueueRemoteEvent(&simplevent.ChatInfoChange{ wa.UserLogin.QueueRemoteEvent(&simplevent.ChatInfoChange{
EventMeta: simplevent.EventMeta{ EventMeta: simplevent.EventMeta{
Type: bridgev2.RemoteEventChatInfoChange, Type: bridgev2.RemoteEventChatInfoChange,
PortalKey: wa.makeWAPortalKey(chatJID), PortalKey: wa.makeWAPortalKey(chatJID),
@ -781,136 +629,40 @@ func (wa *WhatsAppClient) handleWAUserLocalPortalInfo(chatJID types.JID, ts time
UserLocal: info, UserLocal: info,
}, },
}, },
}).Success })
} }
func (wa *WhatsAppClient) handleWAMute(evt *events.Mute) bool { func (wa *WhatsAppClient) handleWAMute(evt *events.Mute) {
var mutedUntil time.Time var mutedUntil time.Time
if evt.Action.GetMuted() { if evt.Action.GetMuted() {
mutedUntil = event.MutedForever mutedUntil = event.MutedForever
if evt.Action.GetMuteEndTimestamp() > 0 { if evt.Action.GetMuteEndTimestamp() != 0 {
mutedUntil = time.Unix(evt.Action.GetMuteEndTimestamp(), 0) mutedUntil = time.Unix(evt.Action.GetMuteEndTimestamp(), 0)
} }
} else { } else {
mutedUntil = bridgev2.Unmuted mutedUntil = bridgev2.Unmuted
} }
return wa.handleWAUserLocalPortalInfo(evt.JID, evt.Timestamp, &bridgev2.UserLocalPortalInfo{ wa.handleWAUserLocalPortalInfo(evt.JID, evt.Timestamp, &bridgev2.UserLocalPortalInfo{
MutedUntil: &mutedUntil, MutedUntil: &mutedUntil,
}) })
} }
func (wa *WhatsAppClient) handleWAArchive(evt *events.Archive) bool { func (wa *WhatsAppClient) handleWAArchive(evt *events.Archive) {
var tag event.RoomTag var tag event.RoomTag
if evt.Action.GetArchived() { if evt.Action.GetArchived() {
tag = wa.Main.Config.ArchiveTag tag = wa.Main.Config.ArchiveTag
} }
return wa.handleWAUserLocalPortalInfo(evt.JID, evt.Timestamp, &bridgev2.UserLocalPortalInfo{ wa.handleWAUserLocalPortalInfo(evt.JID, evt.Timestamp, &bridgev2.UserLocalPortalInfo{
Tag: &tag, Tag: &tag,
}) })
} }
func (wa *WhatsAppClient) handleWAPin(evt *events.Pin) bool { func (wa *WhatsAppClient) handleWAPin(evt *events.Pin) {
var tag event.RoomTag var tag event.RoomTag
if evt.Action.GetPinned() { if evt.Action.GetPinned() {
tag = wa.Main.Config.PinnedTag tag = wa.Main.Config.PinnedTag
} }
return wa.handleWAUserLocalPortalInfo(evt.JID, evt.Timestamp, &bridgev2.UserLocalPortalInfo{ wa.handleWAUserLocalPortalInfo(evt.JID, evt.Timestamp, &bridgev2.UserLocalPortalInfo{
Tag: &tag, Tag: &tag,
}) })
} }
func (wa *WhatsAppClient) handleWAAppStateSyncComplete(ctx context.Context, evt *events.AppStateSyncComplete) {
log := zerolog.Ctx(ctx).With().
Str("patch_name", string(evt.Name)).
Uint64("patch_version", evt.Version).
Logger()
if len(wa.GetStore().PushName) > 0 && evt.Name == appstate.WAPatchCriticalBlock {
err := wa.updatePresence(ctx, types.PresenceUnavailable)
if err != nil {
log.Warn().Err(err).Msg("Failed to send presence after app state sync")
}
go wa.syncRemoteProfile(log.WithContext(context.Background()), nil)
} else if evt.Name == appstate.WAPatchCriticalUnblockLow {
go wa.resyncContacts(false, true)
}
wa.appStateRecoveryLock.Lock()
defer wa.appStateRecoveryLock.Unlock()
meta := wa.UserLogin.Metadata.(*waid.UserLoginMetadata)
if ts, exists := meta.AppStateRecoveryAttempted[evt.Name]; exists {
delete(wa.appStateFullSyncAttempted, evt.Name)
delete(meta.AppStateRecoveryAttempted, evt.Name)
err := wa.UserLogin.Save(ctx)
if err != nil {
log.Err(err).Msg("Failed to save login metadata after unmarking app state recovery as attempted")
} else {
log.Info().
Time("recovery_ts", ts).
Msg("Unmarked app state recovery as attempted after successful full sync")
}
} else if ts, exists = wa.appStateFullSyncAttempted[evt.Name]; exists {
delete(wa.appStateFullSyncAttempted, evt.Name)
log.Debug().Time("full_sync_ts", ts).Msg("Unmarked app state full sync attempted after successful sync")
}
}
func (wa *WhatsAppClient) handleWAAppStateSyncError(ctx context.Context, evt *events.AppStateSyncError) {
log := zerolog.Ctx(ctx).With().
Str("patch_name", string(evt.Name)).
Logger()
wa.appStateRecoveryLock.Lock()
defer wa.appStateRecoveryLock.Unlock()
meta := wa.UserLogin.Metadata.(*waid.UserLoginMetadata)
lastRecovery := meta.AppStateRecoveryAttempted[evt.Name]
lastFullSync := wa.appStateFullSyncAttempted[evt.Name]
if !lastRecovery.IsZero() && time.Since(lastRecovery) < 48*time.Hour {
log.Debug().Err(evt.Error).
Time("last_recovery_attempt", lastRecovery).
Time("last_full_sync_attempt", lastFullSync).
Msg("App state sync failed, but recovery already attempted")
return
}
if !evt.FullSync {
if !lastFullSync.IsZero() {
log.Debug().
Err(evt.Error).
Time("last_full_sync_attempt", lastFullSync).
Msg("App state sync failed, but full sync already attempted")
return
}
wa.appStateFullSyncAttempted[evt.Name] = time.Now()
log.Info().
Err(evt.Error).
Msg("Trying full sync for app state after partial sync error")
go func() {
err := wa.Client.FetchAppState(ctx, evt.Name, true, false)
if err != nil {
log.Err(err).Msg("Full app state sync failed")
} else {
log.Debug().Msg("Full app state sync succeeded")
}
}()
return
}
log.Info().
Err(evt.Error).
Msg("Trying recovery for app state after full sync error")
if meta.AppStateRecoveryAttempted == nil {
meta.AppStateRecoveryAttempted = make(map[appstate.WAPatchName]time.Time)
}
meta.AppStateRecoveryAttempted[evt.Name] = time.Now()
err := wa.UserLogin.Save(ctx)
if err != nil {
log.Err(err).Msg("Failed to save login metadata after marking app state recovery as attempted")
}
go func() {
resp, err := wa.Client.SendPeerMessage(ctx, whatsmeow.BuildAppStateRecoveryRequest(evt.Name))
if err != nil {
log.Err(err).Msg("Failed to send app state recovery request")
} else {
log.Debug().
Str("message_id", resp.ID).
Time("message_ts", resp.Timestamp).
Msg("Sent app state recovery request")
}
}()
}

View file

@ -1,9 +1,6 @@
package connector package connector
import ( import (
"context"
"github.com/rs/zerolog"
"go.mau.fi/util/ptr" "go.mau.fi/util/ptr"
"go.mau.fi/whatsmeow/proto/waCommon" "go.mau.fi/whatsmeow/proto/waCommon"
"go.mau.fi/whatsmeow/types" "go.mau.fi/whatsmeow/types"
@ -28,30 +25,15 @@ func (wa *WhatsAppClient) makeWAPortalKey(chatJID types.JID) networkid.PortalKey
return key return key
} }
func (wa *WhatsAppClient) makeEventSender(ctx context.Context, id types.JID) bridgev2.EventSender { func (wa *WhatsAppClient) makeEventSender(id types.JID) bridgev2.EventSender {
if id.Server == types.NewsletterServer { if id.Server == types.NewsletterServer {
// Send as bot // Send as bot
return bridgev2.EventSender{} return bridgev2.EventSender{}
} }
var senderLoginJID types.JID
if wa.Main.Bridge.Config.SplitPortals {
// no need for sender login ID
} else if id.Server == types.DefaultUserServer {
senderLoginJID = id
} else if id.Server == types.HiddenUserServer {
pn, err := wa.GetStore().LIDs.GetPNForLID(ctx, id)
if err != nil {
zerolog.Ctx(ctx).Err(err).
Stringer("lid", id).
Msg("Failed to get phone number for LID to make event sender")
} else if !pn.IsEmpty() {
senderLoginJID = pn
}
}
return bridgev2.EventSender{ return bridgev2.EventSender{
IsFromMe: id.User == wa.GetStore().GetJID().User || id.User == wa.GetStore().GetLID().User, IsFromMe: id.User == wa.GetStore().GetJID().User || id.User == wa.GetStore().GetLID().User,
Sender: waid.MakeUserID(id), Sender: waid.MakeUserID(id),
SenderLogin: waid.MakeUserLoginID(senderLoginJID), SenderLogin: waid.MakeUserLoginID(id),
} }
} }
@ -68,16 +50,3 @@ func (wa *WhatsAppClient) messageIDToKey(id *waid.ParsedMessageID) *waCommon.Mes
} }
return key return key
} }
func (wa *WhatsAppClient) maybeConvertJIDToLID(ctx context.Context, chatJID types.JID) types.JID {
if chatJID.Server == types.HiddenUserServer {
if pn, err := wa.GetStore().LIDs.GetPNForLID(ctx, chatJID); err != nil {
wa.UserLogin.Log.Err(err).
Stringer("lid", chatJID).
Msg("Failed to get phone number for LID chat")
} else if !pn.IsEmpty() {
return pn.ToNonAD()
}
}
return chatJID
}

View file

@ -2,7 +2,6 @@ package connector
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"net/http" "net/http"
"sync/atomic" "sync/atomic"
@ -10,7 +9,6 @@ import (
"github.com/rs/zerolog" "github.com/rs/zerolog"
"go.mau.fi/util/exsync" "go.mau.fi/util/exsync"
"go.mau.fi/util/jsontime"
"go.mau.fi/whatsmeow" "go.mau.fi/whatsmeow"
"go.mau.fi/whatsmeow/types/events" "go.mau.fi/whatsmeow/types/events"
waLog "go.mau.fi/whatsmeow/util/log" waLog "go.mau.fi/whatsmeow/util/log"
@ -64,21 +62,6 @@ var (
Err: "Unexpected event while waiting for login", Err: "Unexpected event while waiting for login",
StatusCode: http.StatusInternalServerError, StatusCode: http.StatusInternalServerError,
} }
ErrPhoneNumberTooShort = bridgev2.RespError{
ErrCode: "FI.MAU.WHATSAPP.PHONE_NUMBER_TOO_SHORT",
Err: "Phone number too short",
StatusCode: http.StatusBadRequest,
}
ErrPhoneNumberIsNotInternational = bridgev2.RespError{
ErrCode: "FI.MAU.WHATSAPP.PHONE_NUMBER_NOT_INTERNATIONAL",
Err: "Phone number must be in international format",
StatusCode: http.StatusBadRequest,
}
ErrRateLimitedByWhatsApp = bridgev2.RespError{
ErrCode: "FI.MAU.WHATSAPP.RATE_LIMITED",
Err: "Rate limited by WhatsApp",
StatusCode: http.StatusTooManyRequests,
}
) )
func (wa *WhatsAppConnector) CreateLogin(_ context.Context, user *bridgev2.User, flowID string) (bridgev2.LoginProcess, error) { func (wa *WhatsAppConnector) CreateLogin(_ context.Context, user *bridgev2.User, flowID string) (bridgev2.LoginProcess, error) {
@ -121,7 +104,6 @@ type WALogin struct {
var ( var (
_ bridgev2.LoginProcessDisplayAndWait = (*WALogin)(nil) _ bridgev2.LoginProcessDisplayAndWait = (*WALogin)(nil)
_ bridgev2.LoginProcessUserInput = (*WALogin)(nil) _ bridgev2.LoginProcessUserInput = (*WALogin)(nil)
_ bridgev2.LoginProcessWithOverride = (*WALogin)(nil)
) )
const LoginConnectWait = 15 * time.Second const LoginConnectWait = 15 * time.Second
@ -167,20 +149,6 @@ func (wl *WALogin) Start(ctx context.Context) (*bridgev2.LoginStep, error) {
return makeQRStep(wl.QRs[0]), nil return makeQRStep(wl.QRs[0]), nil
} }
func (wl *WALogin) StartWithOverride(ctx context.Context, old *bridgev2.UserLogin) (*bridgev2.LoginStep, error) {
step, err := wl.Start(ctx)
if err == nil && step != nil && old != nil && step.StepID == LoginStepIDPhoneNumber {
phoneNumber := fmt.Sprintf("+%s", old.ID)
wl.Log.Debug().
Str("phone_number", phoneNumber).
Msg("Auto-submitting phone number for relogin")
return wl.SubmitUserInput(ctx, map[string]string{
"phone_number": phoneNumber,
})
}
return step, err
}
func (wl *WALogin) SubmitUserInput(ctx context.Context, input map[string]string) (*bridgev2.LoginStep, error) { func (wl *WALogin) SubmitUserInput(ctx context.Context, input map[string]string) (*bridgev2.LoginStep, error) {
ctx, cancel := context.WithTimeout(ctx, LoginConnectWait) ctx, cancel := context.WithTimeout(ctx, LoginConnectWait)
defer cancel() defer cancel()
@ -194,16 +162,9 @@ func (wl *WALogin) SubmitUserInput(ctx context.Context, input map[string]string)
wl.Log.Warn().Err(err).Msg("Timed out waiting for connection") wl.Log.Warn().Err(err).Msg("Timed out waiting for connection")
return nil, fmt.Errorf("failed to wait for connection: %w", err) return nil, fmt.Errorf("failed to wait for connection: %w", err)
} }
pairingCode, err := wl.Client.PairPhone(ctx, input["phone_number"], true, whatsmeow.PairClientChrome, "Chrome (Linux)") pairingCode, err := wl.Client.PairPhone(input["phone_number"], true, whatsmeow.PairClientChrome, "Chrome (Linux)")
if err != nil { if err != nil {
wl.Log.Err(err).Msg("Failed to request phone code login") wl.Log.Err(err).Msg("Failed to request phone code login")
if errors.Is(err, whatsmeow.ErrPhoneNumberTooShort) {
return nil, ErrPhoneNumberTooShort
} else if errors.Is(err, whatsmeow.ErrPhoneNumberIsNotInternational) {
return nil, ErrPhoneNumberIsNotInternational
} else if errors.Is(err, whatsmeow.ErrIQRateOverLimit) {
return nil, ErrRateLimitedByWhatsApp
}
return nil, err return nil, err
} }
wl.Log.Debug().Msg("Phone code login started") wl.Log.Debug().Msg("Phone code login started")
@ -345,8 +306,8 @@ func (wl *WALogin) Wait(ctx context.Context) (*bridgev2.LoginStep, error) {
Name: wl.LoginSuccess.BusinessName, Name: wl.LoginSuccess.BusinessName,
}, },
Metadata: &waid.UserLoginMetadata{ Metadata: &waid.UserLoginMetadata{
WALID: wl.LoginSuccess.LID.User,
WADeviceID: wl.LoginSuccess.ID.Device, WADeviceID: wl.LoginSuccess.ID.Device,
LoggedInAt: jsontime.UnixNow(),
Timezone: wl.Timezone, Timezone: wl.Timezone,
HistorySyncPortalsNeedCreating: true, HistorySyncPortalsNeedCreating: true,
@ -359,7 +320,7 @@ func (wl *WALogin) Wait(ctx context.Context) (*bridgev2.LoginStep, error) {
} }
ul.Client.(*WhatsAppClient).isNewLogin = true ul.Client.(*WhatsAppClient).isNewLogin = true
ul.Client.Connect(ul.Log.WithContext(wl.Main.Bridge.BackgroundCtx)) ul.Client.Connect(ul.Log.WithContext(context.Background()))
return &bridgev2.LoginStep{ return &bridgev2.LoginStep{
Type: bridgev2.LoginStepTypeComplete, Type: bridgev2.LoginStepTypeComplete,

View file

@ -1,81 +0,0 @@
// mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge.
// Copyright (C) 2026 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package connector
import (
"context"
"encoding/json"
"time"
"go.mau.fi/whatsmeow"
waBinary "go.mau.fi/whatsmeow/binary"
"go.mau.fi/whatsmeow/types"
"go.mau.fi/mautrix-whatsapp/pkg/waid"
)
type NewMCFunc = func(json.RawMessage, mWAClient) mClient
var NewMC NewMCFunc
func (wa *WhatsAppClient) initMC() {
if NewMC != nil {
wa.MC = NewMC(wa.UserLogin.Metadata.(*waid.UserLoginMetadata).MData, wa)
}
}
type mClient = interface {
OnConnect(version uint32, platform string)
}
type noopMC struct{}
var noopMCInstance mClient = &noopMC{}
func (n *noopMC) OnConnect(version uint32, platform string) {}
type mWAClient = interface {
MSend(data []byte)
MSave(data json.RawMessage)
}
var _ mWAClient = (*WhatsAppClient)(nil)
// Deprecated: ignore DangerousInternal error
func (wa *WhatsAppClient) MSend(bytes []byte) {
_, err := wa.Client.DangerousInternals().SendIQAsync(wa.Main.Bridge.BackgroundCtx, whatsmeow.DangerousInfoQuery{
Namespace: "w:stats",
Type: "set",
To: types.ServerJID,
Content: []waBinary.Node{{
Tag: "add",
Attrs: waBinary.Attrs{"t": time.Now().Unix()},
Content: bytes,
}},
})
if err != nil {
wa.UserLogin.Log.Err(err).Msg("Failed to send stats")
}
}
func (wa *WhatsAppClient) MSave(s json.RawMessage) {
wa.UserLogin.Metadata.(*waid.UserLoginMetadata).MData = s
err := wa.UserLogin.Save(context.Background())
if err != nil {
wa.UserLogin.Log.Err(err).Msg("Failed to save MC data")
}
}

View file

@ -137,7 +137,7 @@ func (wa *WhatsAppClient) sendMediaRequest(ctx context.Context, req *wadb.MediaR
req.Status = wadb.MediaBackfillRequestStatusRequestSkipped req.Status = wadb.MediaBackfillRequestStatusRequestSkipped
return return
} }
err = wa.sendMediaRequestDirect(ctx, req.MessageID, req.MediaKey) err = wa.sendMediaRequestDirect(req.MessageID, req.MediaKey)
if err != nil { if err != nil {
log.Err(err).Msg("Failed to send media retry request") log.Err(err).Msg("Failed to send media retry request")
req.Status = wadb.MediaBackfillRequestStatusRequestFailed req.Status = wadb.MediaBackfillRequestStatusRequestFailed
@ -148,12 +148,12 @@ func (wa *WhatsAppClient) sendMediaRequest(ctx context.Context, req *wadb.MediaR
} }
} }
func (wa *WhatsAppClient) sendMediaRequestDirect(ctx context.Context, rawMsgID networkid.MessageID, key []byte) error { func (wa *WhatsAppClient) sendMediaRequestDirect(rawMsgID networkid.MessageID, key []byte) error {
msgID, err := waid.ParseMessageID(rawMsgID) msgID, err := waid.ParseMessageID(rawMsgID)
if err != nil { if err != nil {
return fmt.Errorf("failed to parse message ID: %w", err) return fmt.Errorf("failed to parse message ID: %w", err)
} }
return wa.Client.SendMediaRetryReceipt(ctx, &types.MessageInfo{ return wa.Client.SendMediaRetryReceipt(&types.MessageInfo{
ID: msgID.ID, ID: msgID.ID,
MessageSource: types.MessageSource{ MessageSource: types.MessageSource{
IsFromMe: msgID.Sender.User == wa.JID.User, IsFromMe: msgID.Sender.User == wa.JID.User,

View file

@ -58,12 +58,7 @@ func (wa *WhatsAppConnector) updateProxy(ctx context.Context, client *whatsmeow.
} }
if proxy, err := wa.getProxy(reason); err != nil { if proxy, err := wa.getProxy(reason); err != nil {
return fmt.Errorf("failed to get proxy address: %w", err) return fmt.Errorf("failed to get proxy address: %w", err)
} else if proxy == "" { } else if err = client.SetProxyAddress(proxy); err != nil {
return nil
} else if err = client.SetProxyAddress(proxy, whatsmeow.SetProxyOptions{
OnlyLogin: wa.Config.ProxyOnlyLogin,
NoMedia: wa.Config.ProxyOnlyLogin,
}); err != nil {
return fmt.Errorf("failed to set proxy address: %w", err) return fmt.Errorf("failed to set proxy address: %w", err)
} }
zerolog.Ctx(ctx).Debug().Msg("Enabled proxy") zerolog.Ctx(ctx).Debug().Msg("Enabled proxy")

View file

@ -18,24 +18,15 @@ package connector
import ( import (
"context" "context"
"crypto/sha256"
"errors" "errors"
"fmt" "fmt"
"strings" "strings"
"time"
"github.com/rs/zerolog"
"go.mau.fi/util/ptr"
"go.mau.fi/whatsmeow"
"go.mau.fi/whatsmeow/types" "go.mau.fi/whatsmeow/types"
"maunium.net/go/mautrix" "maunium.net/go/mautrix"
"maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/database"
"maunium.net/go/mautrix/bridgev2/networkid" "maunium.net/go/mautrix/bridgev2/networkid"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
"go.mau.fi/mautrix-whatsapp/pkg/msgconv"
"go.mau.fi/mautrix-whatsapp/pkg/waid" "go.mau.fi/mautrix-whatsapp/pkg/waid"
) )
@ -44,7 +35,6 @@ var (
_ bridgev2.ContactListingNetworkAPI = (*WhatsAppClient)(nil) _ bridgev2.ContactListingNetworkAPI = (*WhatsAppClient)(nil)
_ bridgev2.UserSearchingNetworkAPI = (*WhatsAppClient)(nil) _ bridgev2.UserSearchingNetworkAPI = (*WhatsAppClient)(nil)
_ bridgev2.GhostDMCreatingNetworkAPI = (*WhatsAppClient)(nil) _ bridgev2.GhostDMCreatingNetworkAPI = (*WhatsAppClient)(nil)
_ bridgev2.GroupCreatingNetworkAPI = (*WhatsAppClient)(nil)
_ bridgev2.IdentifierValidatingNetwork = (*WhatsAppConnector)(nil) _ bridgev2.IdentifierValidatingNetwork = (*WhatsAppConnector)(nil)
) )
@ -62,11 +52,9 @@ func looksEmaily(str string) bool {
return false return false
} }
func (wa *WhatsAppClient) validateIdentifer(ctx context.Context, number string) (types.JID, error) { func (wa *WhatsAppClient) validateIdentifer(number string) (types.JID, error) {
if strings.HasSuffix(number, "@"+types.BotServer) || strings.HasSuffix(number, "@"+types.HiddenUserServer) { if strings.HasSuffix(number, "@"+types.BotServer) {
return types.ParseJID(number) return types.ParseJID(number)
} else if strings.HasPrefix(number, waid.BotPrefix) || strings.HasPrefix(number, waid.LIDPrefix) {
return waid.ParseUserID(networkid.UserID(number)), nil
} }
if strings.HasSuffix(number, "@"+types.DefaultUserServer) { if strings.HasSuffix(number, "@"+types.DefaultUserServer) {
jid, _ := types.ParseJID(number) jid, _ := types.ParseJID(number)
@ -76,7 +64,7 @@ func (wa *WhatsAppClient) validateIdentifer(ctx context.Context, number string)
return types.EmptyJID, ErrInputLooksLikeEmail return types.EmptyJID, ErrInputLooksLikeEmail
} else if wa.Client == nil || !wa.Client.IsLoggedIn() { } else if wa.Client == nil || !wa.Client.IsLoggedIn() {
return types.EmptyJID, bridgev2.ErrNotLoggedIn return types.EmptyJID, bridgev2.ErrNotLoggedIn
} else if resp, err := wa.Client.IsOnWhatsApp(ctx, []string{number}); err != nil { } else if resp, err := wa.Client.IsOnWhatsApp([]string{number}); err != nil {
return types.EmptyJID, fmt.Errorf("failed to check if number is on WhatsApp: %w", err) return types.EmptyJID, fmt.Errorf("failed to check if number is on WhatsApp: %w", err)
} else if len(resp) == 0 { } else if len(resp) == 0 {
return types.EmptyJID, fmt.Errorf("the server did not respond to the query") return types.EmptyJID, fmt.Errorf("the server did not respond to the query")
@ -108,47 +96,12 @@ func (wa *WhatsAppConnector) ValidateUserID(id networkid.UserID) bool {
} }
} }
func (wa *WhatsAppClient) startChatLIDToPN(ctx context.Context, jid types.JID) (types.JID, error) {
if jid.Server == types.HiddenUserServer {
pn, err := wa.GetStore().LIDs.GetPNForLID(ctx, jid)
if err != nil {
return jid, fmt.Errorf("failed to get phone number for lid: %w", err)
} else if pn.IsEmpty() {
// Don't allow starting chats with LIDs for now
return jid, fmt.Errorf("phone number not found")
}
return pn, nil
}
return jid, nil
}
func (wa *WhatsAppClient) makeCreateChatResponse(ctx context.Context, jid, origJID types.JID) *bridgev2.CreateChatResponse {
var redirID networkid.UserID
if origJID != jid {
redirID = waid.MakeUserID(jid)
}
return &bridgev2.CreateChatResponse{
PortalKey: wa.makeWAPortalKey(jid),
PortalInfo: wa.wrapDMInfo(ctx, jid),
DMRedirectedTo: redirID,
}
}
func (wa *WhatsAppClient) CreateChatWithGhost(ctx context.Context, ghost *bridgev2.Ghost) (*bridgev2.CreateChatResponse, error) { func (wa *WhatsAppClient) CreateChatWithGhost(ctx context.Context, ghost *bridgev2.Ghost) (*bridgev2.CreateChatResponse, error) {
origJID := waid.ParseUserID(ghost.ID) return &bridgev2.CreateChatResponse{PortalKey: wa.makeWAPortalKey(waid.ParseUserID(ghost.ID))}, nil
jid, err := wa.startChatLIDToPN(ctx, origJID)
if err != nil {
return nil, err
}
return wa.makeCreateChatResponse(ctx, jid, origJID), nil
} }
func (wa *WhatsAppClient) ResolveIdentifier(ctx context.Context, identifier string, startChat bool) (*bridgev2.ResolveIdentifierResponse, error) { func (wa *WhatsAppClient) ResolveIdentifier(ctx context.Context, identifier string, startChat bool) (*bridgev2.ResolveIdentifierResponse, error) {
origJID, err := wa.validateIdentifer(ctx, identifier) jid, err := wa.validateIdentifer(identifier)
if err != nil {
return nil, err
}
jid, err := wa.startChatLIDToPN(ctx, origJID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -160,16 +113,16 @@ func (wa *WhatsAppClient) ResolveIdentifier(ctx context.Context, identifier stri
return &bridgev2.ResolveIdentifierResponse{ return &bridgev2.ResolveIdentifierResponse{
Ghost: ghost, Ghost: ghost,
UserID: waid.MakeUserID(jid), UserID: waid.MakeUserID(jid),
Chat: wa.makeCreateChatResponse(ctx, jid, origJID), Chat: &bridgev2.CreateChatResponse{PortalKey: wa.makeWAPortalKey(jid)},
}, nil }, nil
} }
func (wa *WhatsAppClient) GetContactList(ctx context.Context) ([]*bridgev2.ResolveIdentifierResponse, error) { func (wa *WhatsAppClient) GetContactList(ctx context.Context) ([]*bridgev2.ResolveIdentifierResponse, error) {
return wa.getContactList(ctx, "", true) return wa.getContactList(ctx, "")
} }
func (wa *WhatsAppClient) SearchUsers(ctx context.Context, query string) ([]*bridgev2.ResolveIdentifierResponse, error) { func (wa *WhatsAppClient) SearchUsers(ctx context.Context, query string) ([]*bridgev2.ResolveIdentifierResponse, error) {
return wa.getContactList(ctx, strings.ToLower(query), false) return wa.getContactList(ctx, strings.ToLower(query))
} }
func matchesQuery(str string, query string) bool { func matchesQuery(str string, query string) bool {
@ -179,19 +132,16 @@ func matchesQuery(str string, query string) bool {
return strings.Contains(strings.ToLower(str), query) return strings.Contains(strings.ToLower(str), query)
} }
func (wa *WhatsAppClient) getContactList(ctx context.Context, filter string, onlyContacts bool) ([]*bridgev2.ResolveIdentifierResponse, error) { func (wa *WhatsAppClient) getContactList(ctx context.Context, filter string) ([]*bridgev2.ResolveIdentifierResponse, error) {
if !wa.IsLoggedIn() { if !wa.IsLoggedIn() {
return nil, mautrix.MForbidden.WithMessage("You must be logged in to list contacts") return nil, mautrix.MForbidden.WithMessage("You must be logged in to list contacts")
} }
contacts, err := wa.GetStore().Contacts.GetAllContacts(ctx) contacts, err := wa.GetStore().Contacts.GetAllContacts()
if err != nil { if err != nil {
return nil, err return nil, err
} }
resp := make([]*bridgev2.ResolveIdentifierResponse, 0, len(contacts)) resp := make([]*bridgev2.ResolveIdentifierResponse, 0, len(contacts))
for jid, contactInfo := range contacts { for jid, contactInfo := range contacts {
if onlyContacts && (contactInfo.FirstName == "" && contactInfo.FullName == "") {
continue
}
if !matchesQuery(contactInfo.PushName, filter) && !matchesQuery(contactInfo.FullName, filter) && !matchesQuery(jid.User, filter) { if !matchesQuery(contactInfo.PushName, filter) && !matchesQuery(contactInfo.FullName, filter) && !matchesQuery(jid.User, filter) {
continue continue
} }
@ -205,165 +155,3 @@ func (wa *WhatsAppClient) getContactList(ctx context.Context, filter string, onl
} }
return resp, nil return resp, nil
} }
func (wa *WhatsAppClient) CreateGroup(ctx context.Context, params *bridgev2.GroupCreateParams) (*bridgev2.CreateChatResponse, error) {
createKey := wa.Client.GenerateMessageID()
if params.RoomID != "" {
wa.createDedup.Add(createKey)
}
req := whatsmeow.ReqCreateGroup{
Name: ptr.Val(params.Name).Name,
Participants: make([]types.JID, len(params.Participants)),
CreateKey: createKey,
}
for i, participant := range params.Participants {
jid := waid.ParseUserID(participant)
// Normalize to PN if it's a LID
jid, err := wa.startChatLIDToPN(ctx, jid)
if err != nil {
return nil, fmt.Errorf("failed to normalize participant %s: %w", participant, err)
}
req.Participants[i] = jid
}
if params.Parent != nil {
var err error
req.GroupLinkedParent.LinkedParentJID, err = waid.ParsePortalID(params.Parent.ID)
if err != nil {
return nil, fmt.Errorf("failed to parse parent ID: %w", err)
}
}
if params.Disappear != nil {
req.GroupEphemeral = types.GroupEphemeral{
IsEphemeral: true,
DisappearingTimer: uint32(params.Disappear.Timer.Seconds()),
}
}
var avatarBytes []byte
var avatarMXC id.ContentURIString
if params.Avatar != nil && params.Avatar.URL != "" {
avatarMXC = params.Avatar.URL
var err error
avatarBytes, err = wa.Main.Bridge.Bot.DownloadMedia(ctx, params.Avatar.URL, nil)
if err != nil {
return nil, fmt.Errorf("failed to download avatar: %w", err)
}
avatarBytes, err = convertRoomAvatar(avatarBytes)
if err != nil {
return nil, err
}
}
resp, err := wa.Client.CreateGroup(ctx, req)
if err != nil {
return nil, fmt.Errorf("failed to create group: %w", err)
}
failedParticipants := make(map[networkid.UserID]*bridgev2.CreateChatFailedParticipant)
filteredParticipants := resp.Participants[:0]
for _, pcp := range resp.Participants {
if pcp.Error != 0 {
var inviteContent *event.Content
if pcp.AddRequest != nil {
inviteContent = &event.Content{
Raw: map[string]any{
msgconv.GroupInviteMetaField: &waid.GroupInviteMeta{
JID: resp.JID,
Code: pcp.AddRequest.Code,
Expiration: pcp.AddRequest.Expiration.Unix(),
Inviter: wa.JID.ToNonAD(),
GroupName: resp.Name,
IsParentGroup: resp.IsParent,
},
},
Parsed: &event.MessageEventContent{
Body: "Invitation to join my WhatsApp group",
MsgType: event.MsgText,
},
}
}
failedParticipants[waid.MakeUserID(pcp.JID)] = &bridgev2.CreateChatFailedParticipant{
Reason: fmt.Sprintf("error %d", pcp.Error),
InviteEventType: event.EventMessage.Type,
InviteContent: inviteContent,
}
} else {
filteredParticipants = append(filteredParticipants, pcp)
}
}
resp.Participants = filteredParticipants
portal, err := wa.Main.Bridge.GetPortalByKey(ctx, wa.makeWAPortalKey(resp.JID))
if err != nil {
return nil, fmt.Errorf("failed to get portal: %w", err)
}
groupInfo := wa.wrapGroupInfo(ctx, resp)
if params.RoomID != "" {
err = portal.UpdateMatrixRoomID(ctx, params.RoomID, bridgev2.UpdateMatrixRoomIDParams{
SyncDBMetadata: func() {
portal.Name = req.Name
portal.NameSet = true
portal.ParentKey = ptr.Val(params.Parent)
if avatarBytes != nil {
portal.AvatarSet = true
portal.AvatarHash = sha256.Sum256(avatarBytes)
portal.AvatarMXC = avatarMXC
}
if req.DisappearingTimer > 0 {
portal.Disappear = database.DisappearingSetting{
Type: event.DisappearingTypeAfterSend,
Timer: time.Duration(req.DisappearingTimer) * time.Second,
}
}
},
OverwriteOldPortal: true,
TombstoneOldRoom: true,
DeleteOldRoom: true,
ChatInfo: groupInfo,
ChatInfoSource: wa.UserLogin,
})
if err != nil {
return nil, fmt.Errorf("failed to update room ID after creating group: %w", err)
}
}
changed := false
if avatarBytes != nil {
avatarID, err := wa.Client.SetGroupPhoto(ctx, resp.JID, avatarBytes)
if err != nil {
zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to set group avatar after creating group")
} else {
portal.AvatarID = networkid.AvatarID(avatarID)
portal.AvatarHash = sha256.Sum256(avatarBytes)
portal.AvatarMXC = avatarMXC
portal.AvatarSet = true
groupInfo.Avatar = &bridgev2.Avatar{
ID: portal.AvatarID,
MXC: portal.AvatarMXC,
Hash: portal.AvatarHash,
}
changed = true
}
}
if params.Topic != nil {
newTopicID := wa.Client.GenerateMessageID()
err = wa.Client.SetGroupTopic(ctx, resp.JID, "", newTopicID, params.Topic.Topic)
if err != nil {
zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to set group topic after creating group")
} else {
portal.Topic = params.Topic.Topic
portal.TopicSet = params.RoomID != ""
portal.Metadata.(*waid.PortalMetadata).TopicID = newTopicID
changed = true
groupInfo.Topic = &params.Topic.Topic
}
}
if changed {
err = portal.Save(ctx)
if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to save portal after post-creation updates")
}
}
return &bridgev2.CreateChatResponse{
PortalKey: wa.makeWAPortalKey(resp.JID),
Portal: portal,
PortalInfo: groupInfo,
FailedParticipants: failedParticipants,
}, nil
}

View file

@ -2,12 +2,9 @@ package connector
import ( import (
"context" "context"
"crypto/sha256"
"errors" "errors"
"fmt" "fmt"
"math/rand/v2" "math/rand/v2"
"regexp"
"strconv"
"time" "time"
"github.com/rs/zerolog" "github.com/rs/zerolog"
@ -20,39 +17,31 @@ import (
"maunium.net/go/mautrix/bridgev2/networkid" "maunium.net/go/mautrix/bridgev2/networkid"
"maunium.net/go/mautrix/bridgev2/simplevent" "maunium.net/go/mautrix/bridgev2/simplevent"
"go.mau.fi/mautrix-whatsapp/pkg/connector/wadb"
"go.mau.fi/mautrix-whatsapp/pkg/waid" "go.mau.fi/mautrix-whatsapp/pkg/waid"
) )
var ResyncMinInterval = 7 * 24 * time.Hour const resyncMinInterval = 7 * 24 * time.Hour
var ResyncLoopInterval = 4 * time.Hour const resyncLoopInterval = 4 * time.Hour
var ResyncJitterSeconds = 3600
func (wa *WhatsAppClient) EnqueueGhostResync(ghost *bridgev2.Ghost) { func (wa *WhatsAppClient) EnqueueGhostResync(ghost *bridgev2.Ghost) {
if ghost.Metadata.(*waid.GhostMetadata).LastSync.Add(ResyncMinInterval).After(time.Now()) { if ghost.Metadata.(*waid.GhostMetadata).LastSync.Add(resyncMinInterval).After(time.Now()) {
return return
} }
wa.resyncQueueLock.Lock() wa.resyncQueueLock.Lock()
jid := waid.ParseUserID(ghost.ID) jid := waid.ParseUserID(ghost.ID)
if _, exists := wa.resyncQueue[jid]; !exists { if _, exists := wa.resyncQueue[jid]; !exists {
wa.resyncQueue[jid] = resyncQueueItem{ghost: ghost} wa.resyncQueue[jid] = resyncQueueItem{ghost: ghost}
nextResyncIn := time.Until(wa.nextResync).String()
if wa.nextResync.IsZero() {
nextResyncIn = "never"
}
wa.UserLogin.Log.Debug(). wa.UserLogin.Log.Debug().
Stringer("jid", jid). Stringer("jid", jid).
Str("next_resync_in", nextResyncIn). Stringer("next_resync_in", time.Until(wa.nextResync)).
Msg("Enqueued resync for ghost") Msg("Enqueued resync for ghost")
} }
wa.resyncQueueLock.Unlock() wa.resyncQueueLock.Unlock()
} }
func (wa *WhatsAppClient) EnqueuePortalResync(portal *bridgev2.Portal, allowDM bool) { func (wa *WhatsAppClient) EnqueuePortalResync(portal *bridgev2.Portal) {
jid, _ := waid.ParsePortalID(portal.ID) jid, _ := waid.ParsePortalID(portal.ID)
if portal.Metadata.(*waid.PortalMetadata).LastSync.Add(ResyncMinInterval).After(time.Now()) { if jid.Server != types.GroupServer || portal.Metadata.(*waid.PortalMetadata).LastSync.Add(resyncMinInterval).After(time.Now()) {
return
} else if !allowDM && jid.Server != types.GroupServer {
return return
} }
wa.resyncQueueLock.Lock() wa.resyncQueueLock.Lock()
@ -69,7 +58,7 @@ func (wa *WhatsAppClient) EnqueuePortalResync(portal *bridgev2.Portal, allowDM b
func (wa *WhatsAppClient) ghostResyncLoop(ctx context.Context) { func (wa *WhatsAppClient) ghostResyncLoop(ctx context.Context) {
log := wa.UserLogin.Log.With().Str("action", "ghost resync loop").Logger() log := wa.UserLogin.Log.With().Str("action", "ghost resync loop").Logger()
ctx = log.WithContext(ctx) ctx = log.WithContext(ctx)
wa.nextResync = time.Now().Add(ResyncLoopInterval).Add(-time.Duration(rand.IntN(ResyncJitterSeconds)) * time.Second) wa.nextResync = time.Now().Add(resyncLoopInterval).Add(-time.Duration(rand.IntN(3600)) * time.Second)
timer := time.NewTimer(time.Until(wa.nextResync)) timer := time.NewTimer(time.Until(wa.nextResync))
log.Info().Time("first_resync", wa.nextResync).Msg("Ghost resync queue starting") log.Info().Time("first_resync", wa.nextResync).Msg("Ghost resync queue starting")
for { for {
@ -92,7 +81,7 @@ func (wa *WhatsAppClient) ghostResyncLoop(ctx context.Context) {
func (wa *WhatsAppClient) rotateResyncQueue() map[types.JID]resyncQueueItem { func (wa *WhatsAppClient) rotateResyncQueue() map[types.JID]resyncQueueItem {
wa.resyncQueueLock.Lock() wa.resyncQueueLock.Lock()
defer wa.resyncQueueLock.Unlock() defer wa.resyncQueueLock.Unlock()
wa.nextResync = time.Now().Add(ResyncLoopInterval) wa.nextResync = time.Now().Add(resyncLoopInterval)
if len(wa.resyncQueue) == 0 { if len(wa.resyncQueue) == 0 {
return nil return nil
} }
@ -119,7 +108,7 @@ func (wa *WhatsAppClient) doGhostResync(ctx context.Context, queue map[types.JID
} else if item.portal != nil { } else if item.portal != nil {
lastSync = item.portal.Metadata.(*waid.PortalMetadata).LastSync.Time lastSync = item.portal.Metadata.(*waid.PortalMetadata).LastSync.Time
} }
if lastSync.Add(ResyncMinInterval).After(time.Now()) { if lastSync.Add(resyncMinInterval).After(time.Now()) {
log.Debug(). log.Debug().
Stringer("jid", jid). Stringer("jid", jid).
Time("last_sync", lastSync). Time("last_sync", lastSync).
@ -134,7 +123,7 @@ func (wa *WhatsAppClient) doGhostResync(ctx context.Context, queue map[types.JID
} }
} }
for _, portal := range portals { for _, portal := range portals {
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: func(c zerolog.Context) zerolog.Context {
@ -149,7 +138,7 @@ func (wa *WhatsAppClient) doGhostResync(ctx context.Context, queue map[types.JID
return return
} }
log.Debug().Array("jids", exzerolog.ArrayOfStringers(ghostJIDs)).Msg("Doing background sync for users") log.Debug().Array("jids", exzerolog.ArrayOfStringers(ghostJIDs)).Msg("Doing background sync for users")
infos, err := wa.Client.GetUserInfo(ctx, ghostJIDs) infos, err := wa.Client.GetUserInfo(ghostJIDs)
if err != nil { if err != nil {
log.Err(err).Msg("Failed to get user info for background sync") log.Err(err).Msg("Failed to get user info for background sync")
return return
@ -167,12 +156,11 @@ func (wa *WhatsAppClient) doGhostResync(ctx context.Context, queue map[types.JID
continue continue
} }
ghost.UpdateInfo(ctx, userInfo) ghost.UpdateInfo(ctx, userInfo)
wa.syncAltGhostWithInfo(ctx, jid, userInfo)
} }
} }
func (wa *WhatsAppClient) GetUserInfo(ctx context.Context, ghost *bridgev2.Ghost) (*bridgev2.UserInfo, error) { func (wa *WhatsAppClient) GetUserInfo(ctx context.Context, ghost *bridgev2.Ghost) (*bridgev2.UserInfo, error) {
if ghost.Name != "" && ghost.NameSet { if ghost.Name != "" {
wa.EnqueueGhostResync(ghost) wa.EnqueueGhostResync(ghost)
return nil, nil return nil, nil
} }
@ -181,7 +169,7 @@ func (wa *WhatsAppClient) GetUserInfo(ctx context.Context, ghost *bridgev2.Ghost
} }
func (wa *WhatsAppClient) getUserInfo(ctx context.Context, jid types.JID, fetchAvatar bool) (*bridgev2.UserInfo, error) { func (wa *WhatsAppClient) getUserInfo(ctx context.Context, jid types.JID, fetchAvatar bool) (*bridgev2.UserInfo, error) {
contact, err := wa.GetStore().Contacts.GetContact(ctx, jid) contact, err := wa.GetStore().Contacts.GetContact(jid)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -191,80 +179,48 @@ func (wa *WhatsAppClient) getUserInfo(ctx context.Context, jid types.JID, fetchA
func (wa *WhatsAppClient) contactToUserInfo(ctx context.Context, jid types.JID, contact types.ContactInfo, getAvatar bool) *bridgev2.UserInfo { func (wa *WhatsAppClient) contactToUserInfo(ctx context.Context, jid types.JID, contact types.ContactInfo, getAvatar bool) *bridgev2.UserInfo {
if jid == types.MetaAIJID && contact.PushName == jid.User { if jid == types.MetaAIJID && contact.PushName == jid.User {
contact.PushName = "Meta AI" contact.PushName = "Meta AI"
} else if jid == types.LegacyPSAJID || jid == types.PSAJID { } else if jid == types.PSAJID {
contact.PushName = "WhatsApp" contact.PushName = "WhatsApp"
} }
var altJID types.JID var phone string
if jid.Server == types.DefaultUserServer || jid.Server == types.HiddenUserServer { if jid.Server == types.DefaultUserServer {
var err error phone = "+" + jid.User
altJID, err = wa.GetStore().GetAltJID(ctx, jid) } else if jid.Server == types.HiddenUserServer {
pnJID, err := wa.GetStore().LIDs.GetPNForLID(ctx, jid)
if err != nil { if err != nil {
zerolog.Ctx(ctx).Err(err).Stringer("source_jid", jid).Msg("Failed to get alt JID") zerolog.Ctx(ctx).Err(err).Stringer("lid", jid).Msg("Failed to get PN for LID")
} else if altJID.IsEmpty() {
zerolog.Ctx(ctx).Debug().Stringer("source_jid", jid).Msg("Alternate JID not found in contactToUserInfo")
} else { } else {
extraContact, err := wa.GetStore().Contacts.GetContact(ctx, altJID) phone = "+" + pnJID.User
extraContact, err := wa.GetStore().Contacts.GetContact(pnJID)
if err != nil { if err != nil {
zerolog.Ctx(ctx).Err(err). zerolog.Ctx(ctx).Err(err).
Stringer("source_jid", jid). Stringer("lid", jid).
Stringer("alt_jid", altJID). Stringer("pn_jid", pnJID).
Msg("Failed to get contact info from alternate JID") Msg("Failed to get contact info from PN")
} else { } else {
// Phone contact info should only be stored for phone number JIDs
if altJID.Server == types.DefaultUserServer {
if contact.FirstName == "" { if contact.FirstName == "" {
contact.FirstName = extraContact.FirstName contact.FirstName = extraContact.FirstName
} }
if contact.FullName == "" { if contact.FullName == "" {
contact.FullName = extraContact.FullName contact.FullName = extraContact.FullName
} }
}
if contact.PushName == "" { if contact.PushName == "" {
contact.PushName = extraContact.PushName contact.PushName = extraContact.PushName
} }
if contact.BusinessName == "" { if contact.BusinessName == "" {
contact.BusinessName = extraContact.BusinessName contact.BusinessName = extraContact.BusinessName
} }
if contact.PushName != "" && extraContact.PushName != "" && contact.PushName != extraContact.PushName {
zerolog.Ctx(ctx).Debug().
Stringer("source_jid", jid).
Stringer("alt_jid", altJID).
Str("source_push_name", contact.PushName).
Str("alt_push_name", extraContact.PushName).
Msg("Conflicting push names between JIDs")
if altJID.Server == types.DefaultUserServer {
contact.PushName = extraContact.PushName
} }
} }
if contact.BusinessName != "" && extraContact.BusinessName != "" && contact.BusinessName != extraContact.BusinessName {
zerolog.Ctx(ctx).Debug().
Stringer("source_jid", jid).
Stringer("alt_jid", altJID).
Str("source_push_name", contact.BusinessName).
Str("alt_push_name", extraContact.BusinessName).
Msg("Conflicting business names between JIDs")
if altJID.Server == types.DefaultUserServer {
contact.BusinessName = extraContact.BusinessName
}
}
}
}
}
var phone string
if jid.Server == types.DefaultUserServer {
phone = "+" + jid.User
} else if altJID.Server == types.DefaultUserServer {
phone = "+" + altJID.User
} }
ui := &bridgev2.UserInfo{ ui := &bridgev2.UserInfo{
Name: ptr.Ptr(wa.Main.Config.FormatDisplayname(jid, phone, contact)), Name: ptr.Ptr(wa.Main.Config.FormatDisplayname(jid, phone, contact)),
IsBot: ptr.Ptr(jid.IsBot()), IsBot: ptr.Ptr(jid.IsBot()),
Identifiers: []string{fmt.Sprintf("tel:+%s", jid.User)},
ExtraUpdates: updateGhostLastSyncAt, ExtraUpdates: updateGhostLastSyncAt,
} }
if jid.Server == types.BotServer { if jid.Server == types.BotServer {
ui.Identifiers = []string{} ui.Identifiers = []string{}
} else if phone != "" {
ui.Identifiers = []string{fmt.Sprintf("tel:%s", phone)}
} }
if getAvatar { if getAvatar {
ui.ExtraUpdates = bridgev2.MergeExtraUpdaters(ui.ExtraUpdates, wa.fetchGhostAvatar) ui.ExtraUpdates = bridgev2.MergeExtraUpdaters(ui.ExtraUpdates, wa.fetchGhostAvatar)
@ -274,56 +230,11 @@ func (wa *WhatsAppClient) contactToUserInfo(ctx context.Context, jid types.JID,
func updateGhostLastSyncAt(_ context.Context, ghost *bridgev2.Ghost) bool { func updateGhostLastSyncAt(_ context.Context, ghost *bridgev2.Ghost) bool {
meta := ghost.Metadata.(*waid.GhostMetadata) meta := ghost.Metadata.(*waid.GhostMetadata)
forceSave := ResyncMinInterval < 24*time.Hour || time.Since(meta.LastSync.Time) > 24*time.Hour forceSave := time.Since(meta.LastSync.Time) > 24*time.Hour
meta.LastSync = jsontime.UnixNow() meta.LastSync = jsontime.UnixNow()
return forceSave return forceSave
} }
var expiryRegex = regexp.MustCompile("oe=([0-9A-Fa-f]+)")
func avatarInfoToCacheEntry(ctx context.Context, jid types.JID, avatar *types.ProfilePictureInfo) *wadb.AvatarCacheEntry {
expiry := time.Now().Add(24 * time.Hour)
match := expiryRegex.FindStringSubmatch(avatar.DirectPath)
if len(match) == 2 {
expiryUnix, err := strconv.ParseInt(match[1], 16, 64)
if err == nil {
expiry = time.Unix(expiryUnix, 0)
} else {
zerolog.Ctx(ctx).Warn().Err(err).
Strs("match", match).
Msg("Failed to parse expiry from avatar direct path")
}
}
return &wadb.AvatarCacheEntry{
EntityJID: jid,
AvatarID: avatar.ID,
DirectPath: avatar.DirectPath,
Expiry: jsontime.U(expiry),
Gone: false,
}
}
func (wa *WhatsAppClient) makeDirectMediaAvatar(ctx context.Context, jid types.JID, avatar *types.ProfilePictureInfo, community bool) (*bridgev2.Avatar, error) {
mxc, err := wa.Main.Bridge.Matrix.GenerateContentURI(ctx, waid.MakeAvatarMediaID(jid, avatar.ID, wa.UserLogin.ID, community))
if err != nil {
return nil, fmt.Errorf("failed to generate MXC URI: %w", err)
}
cacheEntry := avatarInfoToCacheEntry(ctx, jid, avatar)
err = wa.Main.DB.AvatarCache.Put(ctx, cacheEntry)
if err != nil {
return nil, fmt.Errorf("failed to cache avatar info: %w", err)
}
hash := sha256.Sum256([]byte(avatar.ID))
if len(avatar.Hash) == 32 {
hash = [32]byte(avatar.Hash)
}
return &bridgev2.Avatar{
ID: networkid.AvatarID(avatar.ID),
MXC: mxc,
Hash: hash,
}, nil
}
func (wa *WhatsAppClient) fetchGhostAvatar(ctx context.Context, ghost *bridgev2.Ghost) bool { func (wa *WhatsAppClient) fetchGhostAvatar(ctx context.Context, ghost *bridgev2.Ghost) bool {
jid := waid.ParseUserID(ghost.ID) jid := waid.ParseUserID(ghost.ID)
existingID := string(ghost.AvatarID) existingID := string(ghost.AvatarID)
@ -331,7 +242,7 @@ func (wa *WhatsAppClient) fetchGhostAvatar(ctx context.Context, ghost *bridgev2.
existingID = "" existingID = ""
} }
var wrappedAvatar *bridgev2.Avatar var wrappedAvatar *bridgev2.Avatar
avatar, err := wa.Client.GetProfilePictureInfo(ctx, jid, &whatsmeow.GetProfilePictureParams{ExistingID: existingID}) avatar, err := wa.Client.GetProfilePictureInfo(jid, &whatsmeow.GetProfilePictureParams{ExistingID: existingID})
if errors.Is(err, whatsmeow.ErrProfilePictureNotSet) { if errors.Is(err, whatsmeow.ErrProfilePictureNotSet) {
wrappedAvatar = &bridgev2.Avatar{ wrappedAvatar = &bridgev2.Avatar{
ID: "remove", ID: "remove",
@ -347,90 +258,32 @@ func (wa *WhatsAppClient) fetchGhostAvatar(ctx context.Context, ghost *bridgev2.
return false return false
} else if avatar == nil { } else if avatar == nil {
return false return false
} else if wa.Main.MsgConv.DirectMedia {
wrappedAvatar, err = wa.makeDirectMediaAvatar(ctx, jid, avatar, false)
if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to prepare direct media avatar")
return false
}
} else { } else {
wrappedAvatar = &bridgev2.Avatar{ wrappedAvatar = &bridgev2.Avatar{
ID: networkid.AvatarID(avatar.ID), ID: networkid.AvatarID(avatar.ID),
Get: func(ctx context.Context) ([]byte, error) { Get: func(ctx context.Context) ([]byte, error) {
return wa.Client.DownloadMediaWithPath(ctx, avatar.DirectPath, nil, nil, nil, 0, "", "") return wa.Client.DownloadMediaWithPath(avatar.DirectPath, nil, nil, nil, 0, "", "")
}, },
} }
} }
return ghost.UpdateAvatar(ctx, wrappedAvatar) return ghost.UpdateAvatar(ctx, wrappedAvatar)
} }
func (wa *WhatsAppClient) resyncContacts(forceAvatarSync, automatic bool) { func (wa *WhatsAppClient) resyncContacts(forceAvatarSync bool) {
log := wa.UserLogin.Log.With().Str("action", "resync contacts").Logger() log := wa.UserLogin.Log.With().Str("action", "resync contacts").Logger()
ctx := log.WithContext(wa.Main.Bridge.BackgroundCtx) ctx := log.WithContext(context.Background())
if automatic && wa.isNewLogin { contacts, err := wa.GetStore().Contacts.GetAllContacts()
log.Debug().Msg("Waiting for push name history sync before resyncing contacts")
timeoutCtx, cancel := context.WithTimeout(ctx, 5*time.Minute)
_ = wa.pushNamesSynced.Wait(timeoutCtx)
cancel()
if ctx.Err() != nil {
return
}
}
contactStore := wa.GetStore().Contacts
contacts, err := contactStore.GetAllContacts(ctx)
if err != nil { if err != nil {
log.Err(err).Msg("Failed to get cached contacts") log.Err(err).Msg("Failed to get cached contacts")
return return
} }
log.Info().Int("contact_count", len(contacts)).Msg("Resyncing displaynames with contact info") log.Info().Int("contact_count", len(contacts)).Msg("Resyncing displaynames with contact info")
for jid := range contacts { for jid, contact := range contacts {
if ctx.Err() != nil {
return
}
ghost, err := wa.Main.Bridge.GetGhostByID(ctx, waid.MakeUserID(jid)) ghost, err := wa.Main.Bridge.GetGhostByID(ctx, waid.MakeUserID(jid))
if err != nil { if err != nil {
log.Err(err).Stringer("jid", jid).Msg("Failed to get ghost") log.Err(err).Msg("Failed to get ghost")
// Refetch contact info from the store to reduce the risk of races. } else if ghost != nil {
// This should always hit the cache. ghost.UpdateInfo(ctx, wa.contactToUserInfo(ctx, jid, contact, forceAvatarSync || ghost.AvatarID == ""))
} else if contact, err := contactStore.GetContact(ctx, jid); err != nil {
log.Err(err).Stringer("jid", jid).Msg("Failed to get contact info")
} else {
userInfo := wa.contactToUserInfo(ctx, jid, contact, forceAvatarSync || ghost.AvatarID == "")
ghost.UpdateInfo(ctx, userInfo)
wa.syncAltGhostWithInfo(ctx, jid, userInfo)
} }
} }
} }
func (wa *WhatsAppClient) syncAltGhostWithInfo(ctx context.Context, jid types.JID, info *bridgev2.UserInfo) {
log := zerolog.Ctx(ctx)
var altJID types.JID
var err error
if jid.Server == types.HiddenUserServer {
altJID, err = wa.GetStore().LIDs.GetPNForLID(ctx, jid)
} else if jid.Server == types.DefaultUserServer {
altJID, err = wa.GetStore().LIDs.GetLIDForPN(ctx, jid)
}
if err != nil {
log.Warn().Err(err).
Stringer("jid", jid).
Msg("Failed to get alternate JID for syncing user info")
return
} else if altJID.IsEmpty() {
return
}
ghost, err := wa.Main.Bridge.GetGhostByID(ctx, waid.MakeUserID(altJID))
if err != nil {
log.Err(err).
Stringer("alternate_jid", altJID).
Stringer("jid", jid).
Msg("Failed to get ghost for alternate JID")
return
}
ghost.UpdateInfo(ctx, info)
log.Debug().
Stringer("jid", jid).
Stringer("alternate_jid", altJID).
Msg("Synced alternate ghost with info")
go wa.syncRemoteProfile(ctx, ghost)
}

View file

@ -1,51 +0,0 @@
package wadb
import (
"context"
"go.mau.fi/util/dbutil"
"go.mau.fi/util/jsontime"
"go.mau.fi/whatsmeow/types"
)
type AvatarCacheQuery struct {
*dbutil.QueryHelper[*AvatarCacheEntry]
}
const (
getAvatarCacheEntry = `
SELECT entity_jid, avatar_id, direct_path, expiry, gone
FROM whatsapp_avatar_cache
WHERE entity_jid = $1 AND avatar_id = $2
`
putAvatarCacheEntry = `
INSERT INTO whatsapp_avatar_cache (entity_jid, avatar_id, direct_path, expiry, gone)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (entity_jid, avatar_id) DO UPDATE
SET direct_path = EXCLUDED.direct_path, expiry = EXCLUDED.expiry, gone = EXCLUDED.gone
`
)
func (acq *AvatarCacheQuery) Get(ctx context.Context, entityJID types.JID, avatarID string) (*AvatarCacheEntry, error) {
return acq.QueryOne(ctx, getAvatarCacheEntry, entityJID, avatarID)
}
func (acq *AvatarCacheQuery) Put(ctx context.Context, entry *AvatarCacheEntry) error {
return acq.Exec(ctx, putAvatarCacheEntry, entry.sqlVariables()...)
}
type AvatarCacheEntry struct {
EntityJID types.JID
AvatarID string
DirectPath string
Expiry jsontime.Unix
Gone bool
}
func (ace *AvatarCacheEntry) Scan(row dbutil.Scannable) (*AvatarCacheEntry, error) {
return dbutil.ValueOrErr(ace, row.Scan(&ace.EntityJID, &ace.AvatarID, &ace.DirectPath, &ace.Expiry, &ace.Gone))
}
func (ace *AvatarCacheEntry) sqlVariables() []any {
return []any{ace.EntityJID, ace.AvatarID, ace.DirectPath, ace.Expiry, ace.Gone}
}

View file

@ -6,7 +6,6 @@ import (
"time" "time"
"go.mau.fi/util/dbutil" "go.mau.fi/util/dbutil"
"go.mau.fi/util/jsontime"
"go.mau.fi/util/ptr" "go.mau.fi/util/ptr"
"go.mau.fi/whatsmeow/proto/waHistorySync" "go.mau.fi/whatsmeow/proto/waHistorySync"
"go.mau.fi/whatsmeow/types" "go.mau.fi/whatsmeow/types"
@ -31,6 +30,7 @@ type Conversation struct {
EphemeralSettingTimestamp *int64 EphemeralSettingTimestamp *int64
MarkedAsUnread *bool MarkedAsUnread *bool
UnreadCount *uint32 UnreadCount *uint32
Bridged bool
} }
func parseHistoryTime(ts *uint64) time.Time { func parseHistoryTime(ts *uint64) time.Time {
@ -69,9 +69,9 @@ const (
INSERT INTO whatsapp_history_sync_conversation ( INSERT INTO whatsapp_history_sync_conversation (
bridge_id, user_login_id, chat_jid, last_message_timestamp, archived, pinned, mute_end_time, bridge_id, user_login_id, chat_jid, last_message_timestamp, archived, pinned, mute_end_time,
end_of_history_transfer_type, ephemeral_expiration, ephemeral_setting_timestamp, marked_as_unread, end_of_history_transfer_type, ephemeral_expiration, ephemeral_setting_timestamp, marked_as_unread,
unread_count unread_count, bridged
) )
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
ON CONFLICT (bridge_id, user_login_id, chat_jid) ON CONFLICT (bridge_id, user_login_id, chat_jid)
DO UPDATE SET DO UPDATE SET
last_message_timestamp=CASE last_message_timestamp=CASE
@ -87,15 +87,16 @@ const (
ephemeral_expiration=COALESCE(excluded.ephemeral_expiration, whatsapp_history_sync_conversation.ephemeral_expiration), ephemeral_expiration=COALESCE(excluded.ephemeral_expiration, whatsapp_history_sync_conversation.ephemeral_expiration),
ephemeral_setting_timestamp=COALESCE(excluded.ephemeral_setting_timestamp, whatsapp_history_sync_conversation.ephemeral_setting_timestamp), ephemeral_setting_timestamp=COALESCE(excluded.ephemeral_setting_timestamp, whatsapp_history_sync_conversation.ephemeral_setting_timestamp),
marked_as_unread=COALESCE(excluded.marked_as_unread, whatsapp_history_sync_conversation.marked_as_unread), marked_as_unread=COALESCE(excluded.marked_as_unread, whatsapp_history_sync_conversation.marked_as_unread),
unread_count=COALESCE(excluded.unread_count, whatsapp_history_sync_conversation.unread_count) unread_count=COALESCE(excluded.unread_count, whatsapp_history_sync_conversation.unread_count),
bridged=false
` `
getRecentConversations = ` getRecentConversations = `
SELECT SELECT
bridge_id, user_login_id, chat_jid, last_message_timestamp, archived, pinned, mute_end_time, bridge_id, user_login_id, chat_jid, last_message_timestamp, archived, pinned, mute_end_time,
end_of_history_transfer_type, ephemeral_expiration, ephemeral_setting_timestamp, marked_as_unread, end_of_history_transfer_type, ephemeral_expiration, ephemeral_setting_timestamp, marked_as_unread,
unread_count unread_count, bridged
FROM whatsapp_history_sync_conversation FROM whatsapp_history_sync_conversation
WHERE bridge_id=$1 AND user_login_id=$2 AND (synced_login_ts IS NULL OR synced_login_ts < $4) WHERE bridge_id=$1 AND user_login_id=$2 AND bridged=false
ORDER BY last_message_timestamp DESC ORDER BY last_message_timestamp DESC
LIMIT $3 LIMIT $3
` `
@ -103,7 +104,7 @@ const (
SELECT SELECT
bridge_id, user_login_id, chat_jid, last_message_timestamp, archived, pinned, mute_end_time, bridge_id, user_login_id, chat_jid, last_message_timestamp, archived, pinned, mute_end_time,
end_of_history_transfer_type, ephemeral_expiration, ephemeral_setting_timestamp, marked_as_unread, end_of_history_transfer_type, ephemeral_expiration, ephemeral_setting_timestamp, marked_as_unread,
unread_count unread_count, bridged
FROM whatsapp_history_sync_conversation FROM whatsapp_history_sync_conversation
WHERE bridge_id=$1 AND user_login_id=$2 AND chat_jid=$3 WHERE bridge_id=$1 AND user_login_id=$2 AND chat_jid=$3
` `
@ -112,9 +113,9 @@ const (
DELETE FROM whatsapp_history_sync_conversation DELETE FROM whatsapp_history_sync_conversation
WHERE bridge_id=$1 AND user_login_id=$2 AND chat_jid=$3 WHERE bridge_id=$1 AND user_login_id=$2 AND chat_jid=$3
` `
markConversationSynced = ` markConversationBridged = `
UPDATE whatsapp_history_sync_conversation UPDATE whatsapp_history_sync_conversation
SET synced_login_ts=$4 SET bridged=true
WHERE bridge_id=$1 AND user_login_id=$2 AND chat_jid=$3 WHERE bridge_id=$1 AND user_login_id=$2 AND chat_jid=$3
` `
) )
@ -124,19 +125,17 @@ func (cq *ConversationQuery) Put(ctx context.Context, conv *Conversation) error
return cq.Exec(ctx, upsertHistorySyncConversationQuery, conv.sqlVariables()...) return cq.Exec(ctx, upsertHistorySyncConversationQuery, conv.sqlVariables()...)
} }
func (cq *ConversationQuery) GetRecent( func (cq *ConversationQuery) GetRecent(ctx context.Context, loginID networkid.UserLoginID, limit int) ([]*Conversation, error) {
ctx context.Context, loginID networkid.UserLoginID, limit int, notSyncedAfter jsontime.Unix,
) ([]*Conversation, error) {
limitPtr := &limit limitPtr := &limit
// Negative limit on SQLite means unlimited, but Postgres prefers a NULL limit. // Negative limit on SQLite means unlimited, but Postgres prefers a NULL limit.
if limit < 0 && cq.GetDB().Dialect == dbutil.Postgres { if limit < 0 && cq.GetDB().Dialect == dbutil.Postgres {
limitPtr = nil limitPtr = nil
} }
return cq.QueryMany(ctx, getRecentConversations, cq.BridgeID, loginID, limitPtr, notSyncedAfter) return cq.QueryMany(ctx, getRecentConversations, cq.BridgeID, loginID, limitPtr)
} }
func (cq *ConversationQuery) MarkSynced(ctx context.Context, loginID networkid.UserLoginID, chatJID types.JID, loginTS jsontime.Unix) error { func (cq *ConversationQuery) MarkBridged(ctx context.Context, loginID networkid.UserLoginID, chatJID types.JID) error {
return cq.Exec(ctx, markConversationSynced, cq.BridgeID, loginID, chatJID, loginTS) return cq.Exec(ctx, markConversationBridged, cq.BridgeID, loginID, chatJID)
} }
func (cq *ConversationQuery) Get(ctx context.Context, loginID networkid.UserLoginID, chatJID types.JID) (*Conversation, error) { func (cq *ConversationQuery) Get(ctx context.Context, loginID networkid.UserLoginID, chatJID types.JID) (*Conversation, error) {
@ -172,6 +171,7 @@ func (c *Conversation) sqlVariables() []any {
c.EphemeralSettingTimestamp, c.EphemeralSettingTimestamp,
c.MarkedAsUnread, c.MarkedAsUnread,
c.UnreadCount, c.UnreadCount,
c.Bridged,
} }
} }
@ -190,6 +190,7 @@ func (c *Conversation) Scan(row dbutil.Scannable) (*Conversation, error) {
&c.EphemeralSettingTimestamp, &c.EphemeralSettingTimestamp,
&c.MarkedAsUnread, &c.MarkedAsUnread,
&c.UnreadCount, &c.UnreadCount,
&c.Bridged,
) )
if err != nil { if err != nil {
return nil, err return nil, err

View file

@ -14,8 +14,6 @@ type Database struct {
Message *MessageQuery Message *MessageQuery
PollOption *PollOptionQuery PollOption *PollOptionQuery
MediaRequest *MediaRequestQuery MediaRequest *MediaRequestQuery
HSNotif *HistorySyncNotificationQuery
AvatarCache *AvatarCacheQuery
} }
func New(bridgeID networkid.BridgeID, db *dbutil.Database, log zerolog.Logger) *Database { func New(bridgeID networkid.BridgeID, db *dbutil.Database, log zerolog.Logger) *Database {
@ -42,14 +40,5 @@ func New(bridgeID networkid.BridgeID, db *dbutil.Database, log zerolog.Logger) *
return &MediaRequest{} return &MediaRequest{}
}), }),
}, },
HSNotif: &HistorySyncNotificationQuery{
BridgeID: bridgeID,
Database: db,
},
AvatarCache: &AvatarCacheQuery{
QueryHelper: dbutil.MakeQueryHelper(db, func(_ *dbutil.QueryHelper[*AvatarCacheEntry]) *AvatarCacheEntry {
return &AvatarCacheEntry{}
}),
},
} }
} }

View file

@ -1,78 +0,0 @@
// mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge.
// Copyright (C) 2025 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package wadb
import (
"context"
"database/sql"
"errors"
"go.mau.fi/util/dbutil"
"google.golang.org/protobuf/proto"
"maunium.net/go/mautrix/bridgev2/networkid"
"go.mau.fi/whatsmeow/proto/waE2E"
)
type HistorySyncNotificationQuery struct {
BridgeID networkid.BridgeID
*dbutil.Database
}
const (
putHSNotificationQuery = `
INSERT INTO whatsapp_history_sync_notification (bridge_id, user_login_id, data)
VALUES ($1, $2, $3)
`
getNextHSNotificationQuery = `
SELECT rowid, data FROM whatsapp_history_sync_notification
WHERE bridge_id=$1 AND user_login_id=$2
`
deleteHSNotificationQuery = `
DELETE FROM whatsapp_history_sync_notification WHERE rowid=$1
`
)
func (hsnq *HistorySyncNotificationQuery) Put(ctx context.Context, loginID networkid.UserLoginID, notif *waE2E.HistorySyncNotification) error {
notifBytes, err := proto.Marshal(notif)
if err != nil {
return err
}
_, err = hsnq.Exec(ctx, putHSNotificationQuery, hsnq.BridgeID, loginID, notifBytes)
return err
}
func (hsnq *HistorySyncNotificationQuery) GetNext(ctx context.Context, loginID networkid.UserLoginID) (*waE2E.HistorySyncNotification, int, error) {
var notifBytes []byte
var rowid int
err := hsnq.QueryRow(ctx, getNextHSNotificationQuery, hsnq.BridgeID, loginID).Scan(&rowid, &notifBytes)
if errors.Is(err, sql.ErrNoRows) {
return nil, 0, nil
} else if err != nil {
return nil, 0, err
}
var notif waE2E.HistorySyncNotification
if err = proto.Unmarshal(notifBytes, &notif); err != nil {
return nil, 0, err
}
return &notif, rowid, nil
}
func (hsnq *HistorySyncNotificationQuery) Delete(ctx context.Context, rowid int) error {
_, err := hsnq.Exec(ctx, deleteHSNotificationQuery, rowid)
return err
}

View file

@ -116,12 +116,9 @@ func (mq *MessageQuery) GetBetween(ctx context.Context, loginID networkid.UserLo
AsList() AsList()
} }
func (mq *MessageQuery) DeleteBetween(ctx context.Context, loginID networkid.UserLoginID, chatJID types.JID, before, after uint64) (int64, error) { func (mq *MessageQuery) DeleteBetween(ctx context.Context, loginID networkid.UserLoginID, chatJID types.JID, before, after uint64) error {
res, err := mq.Exec(ctx, deleteHistorySyncMessagesBetweenQuery, mq.BridgeID, loginID, chatJID, before, after) _, err := mq.Exec(ctx, deleteHistorySyncMessagesBetweenQuery, mq.BridgeID, loginID, chatJID, before, after)
if err != nil { return err
return 0, err
}
return res.RowsAffected()
} }
func (mq *MessageQuery) DeleteAll(ctx context.Context, loginID networkid.UserLoginID) error { func (mq *MessageQuery) DeleteAll(ctx context.Context, loginID networkid.UserLoginID) error {
@ -129,12 +126,9 @@ func (mq *MessageQuery) DeleteAll(ctx context.Context, loginID networkid.UserLog
return err return err
} }
func (mq *MessageQuery) DeleteAllInChat(ctx context.Context, loginID networkid.UserLoginID, chatJID types.JID) (int64, error) { func (mq *MessageQuery) DeleteAllInChat(ctx context.Context, loginID networkid.UserLoginID, chatJID types.JID) error {
res, err := mq.Exec(ctx, deleteHistorySyncMessagesForPortalQuery, mq.BridgeID, loginID, chatJID) _, err := mq.Exec(ctx, deleteHistorySyncMessagesForPortalQuery, mq.BridgeID, loginID, chatJID)
if err != nil { return err
return 0, err
}
return res.RowsAffected()
} }
func (mq *MessageQuery) ConversationHasMessages(ctx context.Context, loginID networkid.UserLoginID, chatJID types.JID) (exists bool, err error) { func (mq *MessageQuery) ConversationHasMessages(ctx context.Context, loginID networkid.UserLoginID, chatJID types.JID) (exists bool, err error) {

View file

@ -1,4 +1,4 @@
-- v0 -> v9 (compatible with v3+): Latest revision -- v0 -> v4 (compatible with v3+): Latest revision
CREATE TABLE whatsapp_poll_option_id ( CREATE TABLE whatsapp_poll_option_id (
bridge_id TEXT NOT NULL, bridge_id TEXT NOT NULL,
@ -26,7 +26,8 @@ CREATE TABLE whatsapp_history_sync_conversation (
ephemeral_setting_timestamp BIGINT, ephemeral_setting_timestamp BIGINT,
marked_as_unread BOOLEAN, marked_as_unread BOOLEAN,
unread_count INTEGER, unread_count INTEGER,
synced_login_ts BIGINT,
bridged BOOLEAN NOT NULL DEFAULT false,
PRIMARY KEY (bridge_id, user_login_id, chat_jid), PRIMARY KEY (bridge_id, user_login_id, chat_jid),
CONSTRAINT whatsapp_history_sync_conversation_user_login_fkey FOREIGN KEY (bridge_id, user_login_id) CONSTRAINT whatsapp_history_sync_conversation_user_login_fkey FOREIGN KEY (bridge_id, user_login_id)
@ -73,28 +74,3 @@ CREATE TABLE whatsapp_media_backfill_request (
); );
CREATE INDEX whatsapp_media_backfill_request_portal_idx ON whatsapp_media_backfill_request (bridge_id, portal_id, portal_receiver); CREATE INDEX whatsapp_media_backfill_request_portal_idx ON whatsapp_media_backfill_request (bridge_id, portal_id, portal_receiver);
CREATE INDEX whatsapp_media_backfill_request_message_idx ON whatsapp_media_backfill_request (bridge_id, portal_receiver, message_id, _part_id); CREATE INDEX whatsapp_media_backfill_request_message_idx ON whatsapp_media_backfill_request (bridge_id, portal_receiver, message_id, _part_id);
CREATE TABLE whatsapp_history_sync_notification (
-- only: sqlite (line commented)
-- rowid INTEGER PRIMARY KEY,
-- only: postgres
rowid BIGINT PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY,
bridge_id TEXT NOT NULL,
user_login_id TEXT NOT NULL,
data bytea NOT NULL,
CONSTRAINT whatsapp_history_sync_notification_user_login_fkey FOREIGN KEY (bridge_id, user_login_id)
REFERENCES user_login (bridge_id, id) ON UPDATE CASCADE ON DELETE CASCADE
);
CREATE INDEX whatsapp_history_sync_notification_login_idx ON whatsapp_history_sync_notification (bridge_id, user_login_id);
CREATE TABLE whatsapp_avatar_cache (
entity_jid TEXT NOT NULL,
avatar_id TEXT NOT NULL,
direct_path TEXT NOT NULL,
expiry BIGINT NOT NULL,
gone BOOLEAN NOT NULL DEFAULT false,
PRIMARY KEY (entity_jid, avatar_id)
);

View file

@ -1,15 +0,0 @@
-- v5 (compatible with v3+): Add buffer for history sync notifications
CREATE TABLE whatsapp_history_sync_notification (
-- only: sqlite (line commented)
-- rowid INTEGER PRIMARY KEY,
-- only: postgres
rowid BIGINT PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY,
bridge_id TEXT NOT NULL,
user_login_id TEXT NOT NULL,
data bytea NOT NULL,
CONSTRAINT whatsapp_history_sync_notification_user_login_fkey FOREIGN KEY (bridge_id, user_login_id)
REFERENCES user_login (bridge_id, id) ON UPDATE CASCADE ON DELETE CASCADE
);
CREATE INDEX whatsapp_history_sync_notification_login_idx ON whatsapp_history_sync_notification (bridge_id, user_login_id);

View file

@ -1,4 +0,0 @@
-- v6 (compatible with v3+): Store timestamp for which login a conversation was synced with
ALTER TABLE whatsapp_history_sync_conversation ADD COLUMN synced_login_ts BIGINT;
UPDATE whatsapp_history_sync_conversation SET synced_login_ts = 0 WHERE bridged = true;
ALTER TABLE whatsapp_history_sync_conversation DROP COLUMN bridged;

View file

@ -1,10 +0,0 @@
-- v7 (compatible with v3+): Add cache for avatar URLs when using direct media
CREATE TABLE whatsapp_avatar_cache (
entity_jid TEXT NOT NULL,
avatar_id TEXT NOT NULL,
direct_path TEXT NOT NULL,
expiry BIGINT NOT NULL,
gone BOOLEAN NOT NULL DEFAULT false,
PRIMARY KEY (entity_jid, avatar_id)
);

View file

@ -1,2 +0,0 @@
-- v8 (compatible with v3+): Mark LID DMs for deletion
INSERT INTO kv_store (bridge_id, key, value) VALUES ('', 'whatsapp_lid_dms_deleted', 'false');

View file

@ -1,3 +0,0 @@
-- v9 (compatible with v3+): Mark LID DMs for deletion (again)
DELETE FROM kv_store WHERE bridge_id='' AND key='whatsapp_lid_dms_deleted';
INSERT INTO kv_store (bridge_id, key, value) VALUES ('', 'whatsapp_lid_dms_deleted', 'false');

View file

@ -124,6 +124,10 @@ func getMessageType(waMsg *waE2E.Message) string {
case waMsg.EncEventResponseMessage != nil: case waMsg.EncEventResponseMessage != nil:
return "ignore" // these are ignored for now as they're not meant to be shown as new messages return "ignore" // these are ignored for now as they're not meant to be shown as new messages
//return "encrypted event response" //return "encrypted event response"
case waMsg.CommentMessage != nil:
return "comment"
case waMsg.EncCommentMessage != nil:
return "encrypted comment"
case waMsg.NewsletterAdminInviteMessage != nil: case waMsg.NewsletterAdminInviteMessage != nil:
return "newsletter admin invite" return "newsletter admin invite"
case waMsg.SecretEncryptedMessage != nil: case waMsg.SecretEncryptedMessage != nil:

View file

@ -19,18 +19,15 @@ package msgconv
import ( import (
"bytes" "bytes"
"context" "context"
"encoding/base64"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"image" "image"
"image/color" "image/color"
"image/jpeg" "image/png"
"net/http" "net/http"
"slices" "slices"
"strconv" "strconv"
"strings" "strings"
"time"
"github.com/rs/zerolog" "github.com/rs/zerolog"
"go.mau.fi/util/ffmpeg" "go.mau.fi/util/ffmpeg"
@ -40,6 +37,7 @@ import (
"go.mau.fi/whatsmeow" "go.mau.fi/whatsmeow"
"go.mau.fi/whatsmeow/proto/waE2E" "go.mau.fi/whatsmeow/proto/waE2E"
"go.mau.fi/whatsmeow/types" "go.mau.fi/whatsmeow/types"
"golang.org/x/image/webp"
"google.golang.org/protobuf/proto" "google.golang.org/protobuf/proto"
"maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/database" "maunium.net/go/mautrix/bridgev2/database"
@ -50,13 +48,7 @@ import (
"go.mau.fi/mautrix-whatsapp/pkg/waid" "go.mau.fi/mautrix-whatsapp/pkg/waid"
) )
func (mc *MessageConverter) generateContextInfo( func (mc *MessageConverter) generateContextInfo(ctx context.Context, replyTo *database.Message, portal *bridgev2.Portal) *waE2E.ContextInfo {
ctx context.Context,
replyTo *database.Message,
portal *bridgev2.Portal,
perMessageTimer *event.BeeperDisappearingTimer,
roomMention bool,
) *waE2E.ContextInfo {
contextInfo := &waE2E.ContextInfo{} contextInfo := &waE2E.ContextInfo{}
if replyTo != nil { if replyTo != nil {
msgID, err := waid.ParseMessageID(replyTo.ID) msgID, err := waid.ParseMessageID(replyTo.ID)
@ -64,7 +56,6 @@ func (mc *MessageConverter) generateContextInfo(
contextInfo.StanzaID = proto.String(msgID.ID) contextInfo.StanzaID = proto.String(msgID.ID)
contextInfo.Participant = proto.String(msgID.Sender.String()) contextInfo.Participant = proto.String(msgID.Sender.String())
contextInfo.QuotedMessage = &waE2E.Message{Conversation: proto.String("")} contextInfo.QuotedMessage = &waE2E.Message{Conversation: proto.String("")}
contextInfo.QuotedType = waE2E.ContextInfo_EXPLICIT.Enum()
} else { } else {
zerolog.Ctx(ctx).Warn().Err(err). zerolog.Ctx(ctx).Warn().Err(err).
Stringer("reply_to_event_id", replyTo.MXID). Stringer("reply_to_event_id", replyTo.MXID).
@ -72,21 +63,12 @@ func (mc *MessageConverter) generateContextInfo(
Msg("Failed to parse reply to message ID") Msg("Failed to parse reply to message ID")
} }
} }
var timer time.Duration if portal.Disappear.Timer > 0 {
if perMessageTimer != nil { contextInfo.Expiration = ptr.Ptr(uint32(portal.Disappear.Timer.Seconds()))
timer = perMessageTimer.Timer.Duration
} else {
timer = portal.Disappear.Timer
}
if timer > 0 {
contextInfo.Expiration = ptr.Ptr(uint32(timer.Seconds()))
}
setAt := portal.Metadata.(*waid.PortalMetadata).DisappearingTimerSetAt setAt := portal.Metadata.(*waid.PortalMetadata).DisappearingTimerSetAt
if setAt > 0 && contextInfo.Expiration != nil { if setAt > 0 {
contextInfo.EphemeralSettingTimestamp = ptr.Ptr(setAt) contextInfo.EphemeralSettingTimestamp = ptr.Ptr(setAt)
} }
if roomMention {
contextInfo.NonJIDMentions = proto.Uint32(1)
} }
return contextInfo return contextInfo
} }
@ -107,15 +89,11 @@ func (mc *MessageConverter) ToWhatsApp(
} }
message := &waE2E.Message{} message := &waE2E.Message{}
contextInfo := mc.generateContextInfo(ctx, replyTo, portal, content.BeeperDisappearingTimer, content.Mentions != nil && content.Mentions.Room) contextInfo := mc.generateContextInfo(ctx, replyTo, portal)
switch content.MsgType { switch content.MsgType {
case event.MsgText, event.MsgNotice, event.MsgEmote: case event.MsgText, event.MsgNotice, event.MsgEmote:
var err error message = mc.constructTextMessage(ctx, content, contextInfo)
message, err = mc.constructTextMessage(ctx, content, evt.Content.Raw, contextInfo)
if err != nil {
return nil, nil, err
}
case event.MessageType(event.EventSticker.Type), event.MsgImage, event.MsgVideo, event.MsgAudio, event.MsgFile: case event.MessageType(event.EventSticker.Type), event.MsgImage, event.MsgVideo, event.MsgAudio, event.MsgFile:
uploaded, thumbnail, mime, err := mc.reuploadFileToWhatsApp(ctx, content) uploaded, thumbnail, mime, err := mc.reuploadFileToWhatsApp(ctx, content)
if err != nil { if err != nil {
@ -144,7 +122,7 @@ func (mc *MessageConverter) ToWhatsApp(
return nil, nil, fmt.Errorf("failed to parse message ID: %w", err) return nil, nil, fmt.Errorf("failed to parse message ID: %w", err)
} }
rootMsgInfo := MessageIDToInfo(client, parsedID) rootMsgInfo := MessageIDToInfo(client, parsedID)
message, err = client.EncryptComment(ctx, rootMsgInfo, message) message, err = client.EncryptComment(rootMsgInfo, message)
if err != nil { if err != nil {
return nil, nil, fmt.Errorf("failed to encrypt comment: %w", err) return nil, nil, fmt.Errorf("failed to encrypt comment: %w", err)
} }
@ -202,7 +180,6 @@ func (mc *MessageConverter) constructMediaMessage(
FileSHA256: uploaded.FileSHA256, FileSHA256: uploaded.FileSHA256,
FileLength: proto.Uint64(uploaded.FileLength), FileLength: proto.Uint64(uploaded.FileLength),
URL: proto.String(uploaded.URL), URL: proto.String(uploaded.URL),
IsLottie: proto.Bool(mime == "application/was"),
}, },
} }
case event.MsgAudio: case event.MsgAudio:
@ -272,14 +249,9 @@ func (mc *MessageConverter) constructMediaMessage(
}, },
} }
case event.MsgFile: case event.MsgFile:
fileName := content.FileName
if fileName == "" {
fileName = content.Body
}
msg := &waE2E.Message{ msg := &waE2E.Message{
DocumentMessage: &waE2E.DocumentMessage{ DocumentMessage: &waE2E.DocumentMessage{
FileName: proto.String(fileName), FileName: proto.String(content.FileName),
Caption: proto.String(caption), Caption: proto.String(caption),
JPEGThumbnail: thumbnail, JPEGThumbnail: thumbnail,
@ -321,16 +293,7 @@ func (mc *MessageConverter) parseText(ctx context.Context, content *event.Messag
return return
} }
func (mc *MessageConverter) constructTextMessage( func (mc *MessageConverter) constructTextMessage(ctx context.Context, content *event.MessageEventContent, contextInfo *waE2E.ContextInfo) *waE2E.Message {
ctx context.Context,
content *event.MessageEventContent,
raw map[string]any,
contextInfo *waE2E.ContextInfo,
) (*waE2E.Message, error) {
groupInvite, ok := raw[GroupInviteMetaField].(map[string]any)
if ok {
return mc.constructGroupInviteMessage(ctx, content, groupInvite, contextInfo)
}
text, mentions := mc.parseText(ctx, content) text, mentions := mc.parseText(ctx, content)
if len(mentions) > 0 { if len(mentions) > 0 {
contextInfo.MentionedJID = mentions contextInfo.MentionedJID = mentions
@ -341,44 +304,7 @@ func (mc *MessageConverter) constructTextMessage(
} }
mc.convertURLPreviewToWhatsApp(ctx, content, etm) mc.convertURLPreviewToWhatsApp(ctx, content, etm)
return &waE2E.Message{ExtendedTextMessage: etm}, nil return &waE2E.Message{ExtendedTextMessage: etm}
}
func (mc *MessageConverter) constructGroupInviteMessage(
ctx context.Context,
content *event.MessageEventContent,
inviteMeta map[string]any,
contextInfo *waE2E.ContextInfo,
) (*waE2E.Message, error) {
payload, err := json.Marshal(inviteMeta)
if err != nil {
return nil, fmt.Errorf("failed to marshal invite meta: %w", err)
}
var parsedInviteMeta waid.GroupInviteMeta
err = json.Unmarshal(payload, &parsedInviteMeta)
if err != nil {
return nil, fmt.Errorf("failed to parse invite meta: %w", err)
}
text, mentions := mc.parseText(ctx, content)
if len(mentions) > 0 {
contextInfo.MentionedJID = mentions
}
groupType := waE2E.GroupInviteMessage_DEFAULT
if parsedInviteMeta.IsParentGroup {
groupType = waE2E.GroupInviteMessage_PARENT
}
return &waE2E.Message{
GroupInviteMessage: &waE2E.GroupInviteMessage{
GroupJID: proto.String(parsedInviteMeta.JID.String()),
InviteCode: proto.String(parsedInviteMeta.Code),
InviteExpiration: proto.Int64(parsedInviteMeta.Expiration),
GroupName: proto.String(parsedInviteMeta.GroupName),
JPEGThumbnail: nil,
Caption: proto.String(text),
ContextInfo: contextInfo,
GroupType: groupType.Enum(),
},
}, nil
} }
func (mc *MessageConverter) convertPill(displayname, mxid, eventID string, ctx format.Context) string { func (mc *MessageConverter) convertPill(displayname, mxid, eventID string, ctx format.Context) string {
@ -434,18 +360,18 @@ func (img *PaddedImage) At(x, y int) color.Color {
return img.Image.At(x-img.OffsetX, y-img.OffsetY) return img.Image.At(x-img.OffsetX, y-img.OffsetY)
} }
func (mc *MessageConverter) convertToJPEG(webpImage []byte) ([]byte, error) { func (mc *MessageConverter) convertWebPtoPNG(webpImage []byte) ([]byte, error) {
decoded, _, err := image.Decode(bytes.NewReader(webpImage)) webpDecoded, err := webp.Decode(bytes.NewReader(webpImage))
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to decode webp image: %w", err) return nil, fmt.Errorf("failed to decode webp image: %w", err)
} }
var jpgBuffer bytes.Buffer var pngBuffer bytes.Buffer
if err = jpeg.Encode(&jpgBuffer, decoded, &jpeg.Options{Quality: 80}); err != nil { if err = png.Encode(&pngBuffer, webpDecoded); err != nil {
return nil, fmt.Errorf("failed to encode png image: %w", err) return nil, fmt.Errorf("failed to encode png image: %w", err)
} }
return jpgBuffer.Bytes(), nil return pngBuffer.Bytes(), nil
} }
func (mc *MessageConverter) convertToWebP(img []byte) ([]byte, int, error) { func (mc *MessageConverter) convertToWebP(img []byte) ([]byte, int, error) {
@ -484,17 +410,6 @@ func (mc *MessageConverter) convertToWebP(img []byte) ([]byte, int, error) {
return webpBuffer.Bytes(), size, nil return webpBuffer.Bytes(), size, nil
} }
func (mc *MessageConverter) getOriginalBridgedSticker(ctx context.Context, info *event.BridgedSticker) (*types.StickerPackItem, error) {
if info == nil || info.Network != StickerSourceID || !strings.HasPrefix(info.PackURL, StickerPackURLPrefix) || info.ID == "" {
return nil, nil
}
fileHash, err := base64.StdEncoding.DecodeString(info.ID)
if err != nil {
return nil, nil
}
return mc.GetCachedSticker(ctx, getClient(ctx), strings.TrimPrefix(info.PackURL, StickerPackURLPrefix), fileHash)
}
func (mc *MessageConverter) reuploadFileToWhatsApp( func (mc *MessageConverter) reuploadFileToWhatsApp(
ctx context.Context, content *event.MessageEventContent, ctx context.Context, content *event.MessageEventContent,
) (*whatsmeow.UploadResponse, []byte, string, error) { ) (*whatsmeow.UploadResponse, []byte, string, error) {
@ -503,25 +418,7 @@ func (mc *MessageConverter) reuploadFileToWhatsApp(
if content.FileName != "" { if content.FileName != "" {
fileName = content.FileName fileName = content.FileName
} }
var data []byte data, err := mc.Bridge.Bot.DownloadMedia(ctx, content.URL, content.File)
var err error
var sticker *types.StickerPackItem
if sticker, err = mc.getOriginalBridgedSticker(ctx, content.Info.BridgedSticker); err != nil {
zerolog.Ctx(ctx).Warn().Err(err).
Msg("Failed to get original bridged sticker, falling back to downloading from URL")
data, err = mc.Bridge.Bot.DownloadMedia(ctx, content.URL, content.File)
} else if sticker != nil {
if sticker.MimeType == "application/was" {
data, err = getClient(ctx).Download(ctx, sticker)
mime = sticker.MimeType
} else {
data, err = mc.Bridge.Bot.DownloadMedia(ctx, content.URL, content.File)
}
content.Info.Width = sticker.Width
content.Info.Height = sticker.Height
} else {
data, err = mc.Bridge.Bot.DownloadMedia(ctx, content.URL, content.File)
}
if err != nil { if err != nil {
return nil, nil, "", fmt.Errorf("%w: %w", bridgev2.ErrMediaDownloadFailed, err) return nil, nil, "", fmt.Errorf("%w: %w", bridgev2.ErrMediaDownloadFailed, err)
} }
@ -539,39 +436,27 @@ func (mc *MessageConverter) reuploadFileToWhatsApp(
case event.MessageType(event.EventSticker.Type): case event.MessageType(event.EventSticker.Type):
isSticker = true isSticker = true
mediaType = whatsmeow.MediaImage mediaType = whatsmeow.MediaImage
if mime == "video/lottie+json" { if mime != "image/webp" || content.Info.Width != content.Info.Height {
// This likely won't work
data, err = PackAnimatedSticker(data)
if err != nil {
return nil, nil, mime, fmt.Errorf("%w (packing animated sticker): %w", bridgev2.ErrMediaConvertFailed, err)
}
mime = "application/was"
} else if (mime != "image/webp" || content.Info.Width != content.Info.Height) && mime != "application/was" {
var size int var size int
data, size, err = mc.convertToWebP(data) data, size, err = mc.convertToWebP(data)
if err != nil { if err != nil {
if mime != "image/webp" {
return nil, nil, "image/webp", fmt.Errorf("%w (to webp): %w", bridgev2.ErrMediaConvertFailed, err) return nil, nil, "image/webp", fmt.Errorf("%w (to webp): %w", bridgev2.ErrMediaConvertFailed, err)
} else {
zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to add padding to webp, continuing with original file")
} }
} else {
content.Info.Width = size content.Info.Width = size
content.Info.Height = size content.Info.Height = size
mime = "image/webp" mime = "image/webp"
} }
}
case event.MsgImage: case event.MsgImage:
mediaType = whatsmeow.MediaImage mediaType = whatsmeow.MediaImage
switch mime { switch mime {
case "image/jpeg": case "image/jpeg", "image/png":
// allowed // allowed
case "image/webp", "image/png": case "image/webp":
data, err = mc.convertToJPEG(data) data, err = mc.convertWebPtoPNG(data)
if err != nil { if err != nil {
return nil, nil, "image/webp", fmt.Errorf("%w (webp to png): %s", bridgev2.ErrMediaConvertFailed, err) return nil, nil, "image/webp", fmt.Errorf("%w (webp to png): %s", bridgev2.ErrMediaConvertFailed, err)
} }
mime = "image/jpeg" mime = "image/png"
default: default:
return nil, nil, mime, fmt.Errorf("%w %s in image message", bridgev2.ErrUnsupportedMediaType, mime) return nil, nil, mime, fmt.Errorf("%w %s in image message", bridgev2.ErrUnsupportedMediaType, mime)
} }
@ -579,17 +464,13 @@ func (mc *MessageConverter) reuploadFileToWhatsApp(
switch mime { switch mime {
case "video/mp4", "video/3gpp": case "video/mp4", "video/3gpp":
// allowed // allowed
case "video/webm", "video/quicktime": case "video/webm":
sourceFormat := "webm" data, err = ffmpeg.ConvertBytes(ctx, data, ".mp4", []string{"-f", "webm"}, []string{
if mime == "video/quicktime" {
sourceFormat = "mov"
}
data, err = ffmpeg.ConvertBytes(ctx, data, ".mp4", []string{"-f", sourceFormat}, []string{
"-pix_fmt", "yuv420p", "-c:v", "libx264", "-pix_fmt", "yuv420p", "-c:v", "libx264",
"-filter:v", "crop='floor(in_w/2)*2:floor(in_h/2)*2'", "-filter:v", "crop='floor(in_w/2)*2:floor(in_h/2)*2'",
}, mime) }, mime)
if err != nil { if err != nil {
return nil, nil, mime, fmt.Errorf("%w (%s to mp4): %w", bridgev2.ErrMediaConvertFailed, sourceFormat, err) return nil, nil, "video/webm", fmt.Errorf("%w (webm to mp4): %w", bridgev2.ErrMediaConvertFailed, err)
} }
mime = "video/mp4" mime = "video/mp4"
case "image/gif": case "image/gif":

View file

@ -33,6 +33,7 @@ import (
"go.mau.fi/whatsmeow/types" "go.mau.fi/whatsmeow/types"
_ "golang.org/x/image/webp" _ "golang.org/x/image/webp"
"maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/database"
"maunium.net/go/mautrix/bridgev2/networkid" "maunium.net/go/mautrix/bridgev2/networkid"
"maunium.net/go/mautrix/event" "maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id" "maunium.net/go/mautrix/id"
@ -46,7 +47,6 @@ const (
contextKeyClient contextKey = iota contextKeyClient contextKey = iota
contextKeyIntent contextKeyIntent
contextKeyPortal contextKeyPortal
ContextKeyEditTargetID
) )
func getClient(ctx context.Context) *whatsmeow.Client { func getClient(ctx context.Context) *whatsmeow.Client {
@ -61,39 +61,15 @@ func getPortal(ctx context.Context) *bridgev2.Portal {
return ctx.Value(contextKeyPortal).(*bridgev2.Portal) return ctx.Value(contextKeyPortal).(*bridgev2.Portal)
} }
func getEditTargetID(ctx context.Context) types.MessageID { func (mc *MessageConverter) getBasicUserInfo(ctx context.Context, user networkid.UserID) (id.UserID, string, error) {
editID, _ := ctx.Value(ContextKeyEditTargetID).(types.MessageID) ghost, err := mc.Bridge.GetGhostByID(ctx, user)
return editID
}
func (mc *MessageConverter) getBasicUserInfo(ctx context.Context, user types.JID) (id.UserID, string, error) {
ghost, err := mc.Bridge.GetGhostByID(ctx, waid.MakeUserID(user))
if err != nil { if err != nil {
return "", "", fmt.Errorf("failed to get ghost by ID: %w", err) return "", "", fmt.Errorf("failed to get ghost by ID: %w", err)
} }
var pnJID types.JID login := mc.Bridge.GetCachedUserLoginByID(networkid.UserLoginID(user))
if user.Server == types.DefaultUserServer { if login != nil {
pnJID = user
} else if user.Server == types.HiddenUserServer {
cli := getClient(ctx)
if user.User == cli.Store.GetLID().User {
pnJID = cli.Store.GetJID()
} else {
pnJID, err = cli.Store.LIDs.GetPNForLID(ctx, user)
if err != nil {
zerolog.Ctx(ctx).Err(err).
Stringer("lid", user).
Msg("Failed to get PN for LID in mention bridging")
}
}
}
if !pnJID.IsEmpty() {
portal := getPortal(ctx)
login := mc.Bridge.GetCachedUserLoginByID(waid.MakeUserLoginID(pnJID))
if login != nil && (portal.Receiver == "" || portal.Receiver == login.ID) {
return login.UserMXID, ghost.Name, nil return login.UserMXID, ghost.Name, nil
} }
}
return ghost.Intent.GetMXID(), ghost.Name, nil return ghost.Intent.GetMXID(), ghost.Name, nil
} }
@ -108,7 +84,7 @@ func (mc *MessageConverter) addMentions(ctx context.Context, mentionedJID []stri
zerolog.Ctx(ctx).Err(err).Str("jid", jid).Msg("Failed to parse mentioned JID") zerolog.Ctx(ctx).Err(err).Str("jid", jid).Msg("Failed to parse mentioned JID")
continue continue
} }
mxid, displayname, err := mc.getBasicUserInfo(ctx, parsed) mxid, displayname, err := mc.getBasicUserInfo(ctx, waid.MakeUserID(parsed))
if err != nil { if err != nil {
zerolog.Ctx(ctx).Err(err).Str("jid", jid).Msg("Failed to get user info") zerolog.Ctx(ctx).Err(err).Str("jid", jid).Msg("Failed to get user info")
continue continue
@ -134,15 +110,10 @@ func (mc *MessageConverter) ToMatrix(
client *whatsmeow.Client, client *whatsmeow.Client,
intent bridgev2.MatrixAPI, intent bridgev2.MatrixAPI,
waMsg *waE2E.Message, waMsg *waE2E.Message,
rawWaMsg *waE2E.Message,
info *types.MessageInfo, info *types.MessageInfo,
isViewOnce bool, isViewOnce bool,
isBackfill bool,
previouslyConvertedPart *bridgev2.ConvertedMessagePart, previouslyConvertedPart *bridgev2.ConvertedMessagePart,
) *bridgev2.ConvertedMessage { ) *bridgev2.ConvertedMessage {
if waMsg == nil {
waMsg = &waE2E.Message{}
}
ctx = context.WithValue(ctx, contextKeyClient, client) ctx = context.WithValue(ctx, contextKeyClient, client)
ctx = context.WithValue(ctx, contextKeyIntent, intent) ctx = context.WithValue(ctx, contextKeyIntent, intent)
ctx = context.WithValue(ctx, contextKeyPortal, portal) ctx = context.WithValue(ctx, contextKeyPortal, portal)
@ -179,12 +150,6 @@ func (mc *MessageConverter) ToMatrix(
part, contextInfo = mc.convertPollUpdateMessage(ctx, info, waMsg.PollUpdateMessage) part, contextInfo = mc.convertPollUpdateMessage(ctx, info, waMsg.PollUpdateMessage)
case waMsg.EventMessage != nil: case waMsg.EventMessage != nil:
part, contextInfo = mc.convertEventMessage(ctx, waMsg.EventMessage) part, contextInfo = mc.convertEventMessage(ctx, waMsg.EventMessage)
case waMsg.PinInChatMessage != nil:
part, contextInfo = mc.convertPinInChatMessage(ctx, waMsg.PinInChatMessage)
case waMsg.KeepInChatMessage != nil:
part, contextInfo = mc.convertKeepInChatMessage(ctx, waMsg.KeepInChatMessage)
case waMsg.RichResponseMessage != nil:
part, contextInfo = mc.convertRichResponseMessage(ctx, waMsg.RichResponseMessage)
case waMsg.ImageMessage != nil: case waMsg.ImageMessage != nil:
part, contextInfo = mc.convertMediaMessage(ctx, waMsg.ImageMessage, "photo", info, isViewOnce, previouslyConvertedPart) part, contextInfo = mc.convertMediaMessage(ctx, waMsg.ImageMessage, "photo", info, isViewOnce, previouslyConvertedPart)
case waMsg.StickerMessage != nil: case waMsg.StickerMessage != nil:
@ -201,8 +166,6 @@ func (mc *MessageConverter) ToMatrix(
part, contextInfo = mc.convertMediaMessage(ctx, waMsg.AudioMessage, typeName, info, isViewOnce, previouslyConvertedPart) part, contextInfo = mc.convertMediaMessage(ctx, waMsg.AudioMessage, typeName, info, isViewOnce, previouslyConvertedPart)
case waMsg.DocumentMessage != nil: case waMsg.DocumentMessage != nil:
part, contextInfo = mc.convertMediaMessage(ctx, waMsg.DocumentMessage, "file attachment", info, isViewOnce, previouslyConvertedPart) part, contextInfo = mc.convertMediaMessage(ctx, waMsg.DocumentMessage, "file attachment", info, isViewOnce, previouslyConvertedPart)
case waMsg.AlbumMessage != nil:
part, contextInfo = mc.convertAlbumMessage(ctx, waMsg.AlbumMessage)
case waMsg.LocationMessage != nil: case waMsg.LocationMessage != nil:
part, contextInfo = mc.convertLocationMessage(ctx, waMsg.LocationMessage) part, contextInfo = mc.convertLocationMessage(ctx, waMsg.LocationMessage)
case waMsg.LiveLocationMessage != nil: case waMsg.LiveLocationMessage != nil:
@ -216,11 +179,11 @@ func (mc *MessageConverter) ToMatrix(
case waMsg.GroupInviteMessage != nil: case waMsg.GroupInviteMessage != nil:
part, contextInfo = mc.convertGroupInviteMessage(ctx, info, waMsg.GroupInviteMessage) part, contextInfo = mc.convertGroupInviteMessage(ctx, info, waMsg.GroupInviteMessage)
case waMsg.ProtocolMessage != nil && waMsg.ProtocolMessage.GetType() == waE2E.ProtocolMessage_EPHEMERAL_SETTING: case waMsg.ProtocolMessage != nil && waMsg.ProtocolMessage.GetType() == waE2E.ProtocolMessage_EPHEMERAL_SETTING:
part, contextInfo = mc.convertEphemeralSettingMessage(ctx, waMsg.ProtocolMessage, info.Timestamp, isBackfill) part, contextInfo = mc.convertEphemeralSettingMessage(ctx, waMsg.ProtocolMessage)
case waMsg.EncCommentMessage != nil: case waMsg.EncCommentMessage != nil:
part = failedCommentPart part = failedCommentPart
default: default:
part, contextInfo = mc.convertUnknownMessage(ctx, rawWaMsg) part, contextInfo = mc.convertUnknownMessage(ctx, waMsg)
} }
part.Content.Mentions = &event.Mentions{} part.Content.Mentions = &event.Mentions{}
@ -237,25 +200,16 @@ func (mc *MessageConverter) ToMatrix(
part.Extra["fi.mau.whatsapp.source_broadcast_list"] = info.Chat.String() part.Extra["fi.mau.whatsapp.source_broadcast_list"] = info.Chat.String()
} }
mc.addMentions(ctx, contextInfo.GetMentionedJID(), part.Content) mc.addMentions(ctx, contextInfo.GetMentionedJID(), part.Content)
if contextInfo.GetNonJIDMentions() == 1 {
part.Content.Mentions.Room = true
}
cm := &bridgev2.ConvertedMessage{ cm := &bridgev2.ConvertedMessage{
Parts: []*bridgev2.ConvertedMessagePart{part}, Parts: []*bridgev2.ConvertedMessagePart{part},
} }
if contextInfo.GetExpiration() > 0 { if contextInfo.GetExpiration() > 0 {
cm.Disappear.Timer = time.Duration(contextInfo.GetExpiration()) * time.Second cm.Disappear.Timer = time.Duration(contextInfo.GetExpiration()) * time.Second
cm.Disappear.Type = event.DisappearingTypeAfterSend cm.Disappear.Type = database.DisappearingTypeAfterRead
}
if portal.Disappear.Timer != cm.Disappear.Timer && portal.Metadata.(*waid.PortalMetadata).DisappearingTimerSetAt < contextInfo.GetEphemeralSettingTimestamp() { if portal.Disappear.Timer != cm.Disappear.Timer && portal.Metadata.(*waid.PortalMetadata).DisappearingTimerSetAt < contextInfo.GetEphemeralSettingTimestamp() {
portal.UpdateDisappearingSetting(ctx, cm.Disappear, bridgev2.UpdateDisappearingSettingOpts{ portal.UpdateDisappearingSetting(ctx, cm.Disappear, intent, info.Timestamp, true, true)
Sender: intent, }
Timestamp: info.Timestamp,
Implicit: true,
Save: true,
SendNotice: true,
})
} }
if contextInfo.GetStanzaID() != "" { if contextInfo.GetStanzaID() != "" {
pcp, _ := types.ParseJID(contextInfo.GetParticipant()) pcp, _ := types.ParseJID(contextInfo.GetParticipant())
@ -263,27 +217,6 @@ func (mc *MessageConverter) ToMatrix(
if chat.IsEmpty() { if chat.IsEmpty() {
chat, _ = waid.ParsePortalID(portal.ID) chat, _ = waid.ParsePortalID(portal.ID)
} }
// We reroute all DMs to the phone number JID, so reroute reply participants too
pcp = rerouteMessageKey(ctx, chat, pcp, getPortal(ctx).Metadata.(*waid.PortalMetadata).AddressingMode == types.AddressingModeLID)
if store := getClient(ctx).Store; store != nil && chat.Server == types.DefaultUserServer && pcp.Server == types.HiddenUserServer {
pcpPN, _ := store.LIDs.GetPNForLID(ctx, pcp)
zerolog.Ctx(ctx).Debug().
Stringer("orig_participant", pcp).
Stringer("rerouted_participant", pcpPN).
Msg("Rerouting reply target (PN recipient in LID DM)")
if !pcpPN.IsEmpty() {
pcp = pcpPN
}
} else if store != nil && chat.Server == types.GroupServer && pcp.Server == types.DefaultUserServer && getPortal(ctx).Metadata.(*waid.PortalMetadata).AddressingMode == types.AddressingModeLID {
pcpLID, _ := store.LIDs.GetLIDForPN(ctx, pcp)
zerolog.Ctx(ctx).Debug().
Stringer("orig_participant", pcp).
Stringer("rerouted_participant", pcpLID).
Msg("Rerouting reply target (PN recipient in LID group)")
if !pcpLID.IsEmpty() {
pcp = pcpLID
}
}
cm.ReplyTo = &networkid.MessageOptionalPartID{ cm.ReplyTo = &networkid.MessageOptionalPartID{
MessageID: waid.MakeMessageID(chat, pcp, contextInfo.GetStanzaID()), MessageID: waid.MakeMessageID(chat, pcp, contextInfo.GetStanzaID()),
} }

View file

@ -71,7 +71,7 @@ func (mc *MessageConverter) PollStartToWhatsApp(
if maxAnswers >= len(content.PollStart.Answers) || maxAnswers < 0 { if maxAnswers >= len(content.PollStart.Answers) || maxAnswers < 0 {
maxAnswers = 0 maxAnswers = 0
} }
contextInfo := mc.generateContextInfo(ctx, replyTo, portal, nil, content.Mentions != nil && content.Mentions.Room) contextInfo := mc.generateContextInfo(ctx, replyTo, portal)
var question string var question string
question, contextInfo.MentionedJID = mc.msc1767ToWhatsApp(ctx, content.PollStart.Question, content.Mentions) question, contextInfo.MentionedJID = mc.msc1767ToWhatsApp(ctx, content.PollStart.Question, content.Mentions)
if len(question) == 0 { if len(question) == 0 {
@ -140,7 +140,7 @@ func (mc *MessageConverter) PollVoteToWhatsApp(
} }
} }
} }
pollUpdate, err := client.EncryptPollVote(ctx, pollMsgInfo, &waE2E.PollVoteMessage{ pollUpdate, err := client.EncryptPollVote(pollMsgInfo, &waE2E.PollVoteMessage{
SelectedOptions: optionHashes, SelectedOptions: optionHashes,
}) })
return &waE2E.Message{PollUpdateMessage: pollUpdate}, err return &waE2E.Message{PollUpdateMessage: pollUpdate}, err

View file

@ -17,9 +17,6 @@
package msgconv package msgconv
import ( import (
"sync"
"go.mau.fi/whatsmeow/types"
"maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/format" "maunium.net/go/mautrix/format"
@ -46,16 +43,12 @@ type MessageConverter struct {
DisableViewOnce bool DisableViewOnce bool
DirectMedia bool DirectMedia bool
OldMediaSuffix string OldMediaSuffix string
stickerPackCache map[string]*types.StickerPack
stickerPackCacheLock sync.Mutex
} }
func New(br *bridgev2.Bridge) *MessageConverter { func New(br *bridgev2.Bridge) *MessageConverter {
mc := &MessageConverter{ mc := &MessageConverter{
Bridge: br, Bridge: br,
MaxFileSize: 50 * 1024 * 1024, MaxFileSize: 50 * 1024 * 1024,
stickerPackCache: make(map[string]*types.StickerPack),
} }
mc.HTMLParser = &format.HTMLParser{ mc.HTMLParser = &format.HTMLParser{
PillConverter: mc.convertPill, PillConverter: mc.convertPill,

View file

@ -51,7 +51,7 @@ func (mc *MessageConverter) convertURLPreviewToBeeper(ctx context.Context, msg *
var thumbnailData []byte var thumbnailData []byte
if msg.ThumbnailDirectPath != nil { if msg.ThumbnailDirectPath != nil {
var err error var err error
thumbnailData, err = getClient(ctx).DownloadThumbnail(ctx, msg) thumbnailData, err = getClient(ctx).DownloadThumbnail(msg)
if err != nil { if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to download thumbnail for link preview") zerolog.Ctx(ctx).Err(err).Msg("Failed to download thumbnail for link preview")
} }
@ -60,16 +60,16 @@ func (mc *MessageConverter) convertURLPreviewToBeeper(ctx context.Context, msg *
thumbnailData = msg.JPEGThumbnail thumbnailData = msg.JPEGThumbnail
} }
if thumbnailData != nil { if thumbnailData != nil {
output.ImageHeight = event.IntOrString(msg.GetThumbnailHeight()) output.ImageHeight = int(msg.GetThumbnailHeight())
output.ImageWidth = event.IntOrString(msg.GetThumbnailWidth()) output.ImageWidth = int(msg.GetThumbnailWidth())
if output.ImageHeight == 0 || output.ImageWidth == 0 { if output.ImageHeight == 0 || output.ImageWidth == 0 {
src, _, err := image.Decode(bytes.NewReader(thumbnailData)) src, _, err := image.Decode(bytes.NewReader(thumbnailData))
if err == nil { if err == nil {
imageBounds := src.Bounds() imageBounds := src.Bounds()
output.ImageWidth, output.ImageHeight = event.IntOrString(imageBounds.Max.X), event.IntOrString(imageBounds.Max.Y) output.ImageWidth, output.ImageHeight = imageBounds.Max.X, imageBounds.Max.Y
} }
} }
output.ImageSize = event.IntOrString(len(thumbnailData)) output.ImageSize = len(thumbnailData)
output.ImageType = http.DetectContentType(thumbnailData) output.ImageType = http.DetectContentType(thumbnailData)
var err error var err error
output.ImageURL, output.ImageEncryption, err = getIntent(ctx).UploadMedia( output.ImageURL, output.ImageEncryption, err = getIntent(ctx).UploadMedia(

View file

@ -66,10 +66,10 @@ func (mc *MessageConverter) convertTemplateMessage(ctx context.Context, info *ty
if addButtonText { if addButtonText {
description += "\nUse the WhatsApp app to click buttons" description += "\nUse the WhatsApp app to click buttons"
} }
content = strings.TrimSpace(fmt.Sprintf("%s\n\n%s", content, description)) content = fmt.Sprintf("%s\n\n%s", content, description)
} }
if footer := tpl.GetHydratedFooterText(); footer != "" { if footer := tpl.GetHydratedFooterText(); footer != "" {
content = strings.TrimSpace(fmt.Sprintf("%s\n\n%s", content, footer)) content = fmt.Sprintf("%s\n\n%s", content, footer)
} }
var convertedTitle *bridgev2.ConvertedMessagePart var convertedTitle *bridgev2.ConvertedMessagePart
@ -239,7 +239,7 @@ func (mc *MessageConverter) postProcessBusinessMessage(content string, headerMed
converted.Content.Body += content converted.Content.Body += content
contentHTML := parseWAFormattingToHTML(content, true) contentHTML := parseWAFormattingToHTML(content, true)
if contentHTML != event.TextToHTML(content) || converted.Content.FormattedBody != "" { if contentHTML != event.TextToHTML(content) || converted.Content.FormattedBody != "" {
converted.Content.Format = event.FormatHTML converted.Content.EnsureHasHTML()
if converted.Content.FormattedBody != "" { if converted.Content.FormattedBody != "" {
converted.Content.FormattedBody += "<br><br>" converted.Content.FormattedBody += "<br><br>"
} }

View file

@ -17,6 +17,8 @@
package msgconv package msgconv
import ( import (
"archive/zip"
"bytes"
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
@ -24,18 +26,21 @@ import (
"io" "io"
"net/http" "net/http"
"os" "os"
"path/filepath"
"strconv"
"strings" "strings"
"github.com/rs/zerolog" "github.com/rs/zerolog"
"go.mau.fi/util/exmime" "go.mau.fi/util/exmime"
"go.mau.fi/util/exslices" "go.mau.fi/util/exslices"
"go.mau.fi/util/lottie"
"go.mau.fi/util/random"
"go.mau.fi/whatsmeow" "go.mau.fi/whatsmeow"
"go.mau.fi/whatsmeow/proto/waE2E" "go.mau.fi/whatsmeow/proto/waE2E"
"go.mau.fi/whatsmeow/types" "go.mau.fi/whatsmeow/types"
"maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/database" "maunium.net/go/mautrix/bridgev2/database"
"maunium.net/go/mautrix/event" "maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
"go.mau.fi/mautrix-whatsapp/pkg/waid" "go.mau.fi/mautrix-whatsapp/pkg/waid"
) )
@ -49,15 +54,11 @@ func (mc *MessageConverter) convertMediaMessage(
cachedPart *bridgev2.ConvertedMessagePart, cachedPart *bridgev2.ConvertedMessagePart,
) (part *bridgev2.ConvertedMessagePart, contextInfo *waE2E.ContextInfo) { ) (part *bridgev2.ConvertedMessagePart, contextInfo *waE2E.ContextInfo) {
if mc.DisableViewOnce && isViewOnce { if mc.DisableViewOnce && isViewOnce {
body := "You received a view once message. For added privacy, you can only open it on the WhatsApp app."
if messageInfo.IsFromMe {
body = "You sent a view once message from another device."
}
return &bridgev2.ConvertedMessagePart{ return &bridgev2.ConvertedMessagePart{
Type: event.EventMessage, Type: event.EventMessage,
Content: &event.MessageEventContent{ Content: &event.MessageEventContent{
MsgType: event.MsgNotice, MsgType: event.MsgNotice,
Body: body, Body: fmt.Sprintf("You received a view once %s. For added privacy, you can only open it on the WhatsApp app.", typeName),
}, },
}, nil }, nil
} }
@ -79,18 +80,12 @@ func (mc *MessageConverter) convertMediaMessage(
Type: whatsmeow.GetMediaType(msg), Type: whatsmeow.GetMediaType(msg),
SHA256: msg.GetFileSHA256(), SHA256: msg.GetFileSHA256(),
EncSHA256: msg.GetFileEncSHA256(), EncSHA256: msg.GetFileEncSHA256(),
MimeType: msg.GetMimetype(),
} }
if mc.DirectMedia { if mc.DirectMedia {
if preparedMedia.Info.MimeType == "application/was" {
preparedMedia.Info.MimeType = "video/lottie+json"
preparedMedia.FileName = "sticker.json"
}
preparedMedia.FillFileName() preparedMedia.FillFileName()
var err error var err error
portal := getPortal(ctx) portal := getPortal(ctx)
idOverride := getEditTargetID(ctx) preparedMedia.URL, err = portal.Bridge.Matrix.GenerateContentURI(ctx, waid.MakeMediaID(messageInfo, portal.Receiver))
preparedMedia.URL, err = portal.Bridge.Matrix.GenerateContentURI(ctx, waid.MakeMediaID(messageInfo, idOverride, portal.Receiver))
if err != nil { if err != nil {
panic(fmt.Errorf("failed to generate content URI: %w", err)) panic(fmt.Errorf("failed to generate content URI: %w", err))
} }
@ -119,28 +114,6 @@ func (mc *MessageConverter) convertMediaMessage(
return return
} }
func (mc *MessageConverter) convertAlbumMessage(ctx context.Context, msg *waE2E.AlbumMessage) (*bridgev2.ConvertedMessagePart, *waE2E.ContextInfo) {
parts := make([]string, 0, 2)
if msg.GetExpectedImageCount() > 0 {
parts = append(parts, fmt.Sprintf("%d images", msg.GetExpectedImageCount()))
}
if msg.GetExpectedVideoCount() > 0 {
parts = append(parts, fmt.Sprintf("%d videos", msg.GetExpectedVideoCount()))
}
var partDesc string
if len(parts) > 0 {
partDesc = fmt.Sprintf(" with %s", strings.Join(parts, " and "))
}
body := fmt.Sprintf("Sent an album%s:", partDesc)
return &bridgev2.ConvertedMessagePart{
Type: event.EventMessage,
Content: &event.MessageEventContent{
MsgType: event.MsgNotice,
Body: body,
},
}, msg.GetContextInfo()
}
const FailedMediaField = "fi.mau.whatsapp.failed_media" const FailedMediaField = "fi.mau.whatsapp.failed_media"
type FailedMediaKeys struct { type FailedMediaKeys struct {
@ -150,7 +123,6 @@ type FailedMediaKeys struct {
SHA256 []byte `json:"sha256"` SHA256 []byte `json:"sha256"`
EncSHA256 []byte `json:"enc_sha256"` EncSHA256 []byte `json:"enc_sha256"`
DirectPath string `json:"direct_path,omitempty"` DirectPath string `json:"direct_path,omitempty"`
MimeType string `json:"mime_type,omitempty"`
} }
func (f *FailedMediaKeys) GetDirectPath() string { func (f *FailedMediaKeys) GetDirectPath() string {
@ -193,9 +165,7 @@ type PreparedMedia struct {
} }
func (pm *PreparedMedia) FillFileName() *PreparedMedia { func (pm *PreparedMedia) FillFileName() *PreparedMedia {
if pm.Type == event.EventSticker { if pm.FileName == "" {
pm.FileName = ""
} else if pm.FileName == "" {
pm.FileName = strings.TrimPrefix(string(pm.MsgType), "m.") + exmime.ExtensionFromMimetype(pm.Info.MimeType) pm.FileName = strings.TrimPrefix(string(pm.MsgType), "m.") + exmime.ExtensionFromMimetype(pm.Info.MimeType)
} }
return pm return pm
@ -234,21 +204,6 @@ type MediaMessageWithDuration interface {
GetSeconds() uint32 GetSeconds() uint32
} }
const WhatsAppStickerSize = 190
func fixStickerDimensions(info *event.FileInfo) {
if info.Width == info.Height {
info.Width = WhatsAppStickerSize
info.Height = WhatsAppStickerSize
} else if info.Width > info.Height {
info.Height /= info.Width / WhatsAppStickerSize
info.Width = WhatsAppStickerSize
} else {
info.Width /= info.Height / WhatsAppStickerSize
info.Height = WhatsAppStickerSize
}
}
func prepareMediaMessage(rawMsg MediaMessage) *PreparedMedia { func prepareMediaMessage(rawMsg MediaMessage) *PreparedMedia {
extraInfo := map[string]any{} extraInfo := map[string]any{}
data := &PreparedMedia{ data := &PreparedMedia{
@ -260,22 +215,6 @@ func prepareMediaMessage(rawMsg MediaMessage) *PreparedMedia {
"info": extraInfo, "info": extraInfo,
}, },
} }
if durationMsg, ok := rawMsg.(MediaMessageWithDuration); ok {
data.Info.Duration = int(durationMsg.GetSeconds() * 1000)
}
if dimensionMsg, ok := rawMsg.(MediaMessageWithDimensions); ok {
data.Info.Width = int(dimensionMsg.GetWidth())
data.Info.Height = int(dimensionMsg.GetHeight())
}
if captionMsg, ok := rawMsg.(MediaMessageWithCaption); ok && captionMsg.GetCaption() != "" {
data.Body = captionMsg.GetCaption()
} else {
data.Body = data.FileName
}
data.Info.Size = int(rawMsg.GetFileLength())
data.Info.MimeType = rawMsg.GetMimetype()
data.ContextInfo = rawMsg.GetContextInfo()
switch msg := rawMsg.(type) { switch msg := rawMsg.(type) {
case *waE2E.ImageMessage: case *waE2E.ImageMessage:
data.MsgType = event.MsgImage data.MsgType = event.MsgImage
@ -297,11 +236,12 @@ func prepareMediaMessage(rawMsg MediaMessage) *PreparedMedia {
case *waE2E.StickerMessage: case *waE2E.StickerMessage:
data.Type = event.EventSticker data.Type = event.EventSticker
data.FileName = "sticker" + exmime.ExtensionFromMimetype(msg.GetMimetype()) data.FileName = "sticker" + exmime.ExtensionFromMimetype(msg.GetMimetype())
fixStickerDimensions(data.Info) if msg.GetMimetype() == "application/was" && data.FileName == "sticker" {
data.FileName = "sticker.json"
}
case *waE2E.VideoMessage: case *waE2E.VideoMessage:
data.MsgType = event.MsgVideo data.MsgType = event.MsgVideo
pairedMediaType := msg.GetContextInfo().GetPairedMediaType() if msg.GetGifPlayback() {
if msg.GetGifPlayback() || pairedMediaType == waE2E.ContextInfo_MOTION_PHOTO_PARENT || pairedMediaType == waE2E.ContextInfo_MOTION_PHOTO_CHILD {
extraInfo["fi.mau.gif"] = true extraInfo["fi.mau.gif"] = true
extraInfo["fi.mau.loop"] = true extraInfo["fi.mau.loop"] = true
extraInfo["fi.mau.autoplay"] = true extraInfo["fi.mau.autoplay"] = true
@ -312,7 +252,22 @@ func prepareMediaMessage(rawMsg MediaMessage) *PreparedMedia {
default: default:
panic(fmt.Errorf("unknown media message type %T", rawMsg)) panic(fmt.Errorf("unknown media message type %T", rawMsg))
} }
if durationMsg, ok := rawMsg.(MediaMessageWithDuration); ok {
data.Info.Duration = int(durationMsg.GetSeconds() * 1000)
}
if dimensionMsg, ok := rawMsg.(MediaMessageWithDimensions); ok {
data.Info.Width = int(dimensionMsg.GetWidth())
data.Info.Height = int(dimensionMsg.GetHeight())
}
if captionMsg, ok := rawMsg.(MediaMessageWithCaption); ok && captionMsg.GetCaption() != "" {
data.Body = captionMsg.GetCaption()
} else {
data.Body = data.FileName
}
data.Info.Size = int(rawMsg.GetFileLength())
data.Info.MimeType = rawMsg.GetMimetype()
data.ContextInfo = rawMsg.GetContextInfo()
return data return data
} }
@ -357,16 +312,13 @@ func (mc *MessageConverter) reuploadWhatsAppAttachment(
) error { ) error {
client := getClient(ctx) client := getClient(ctx)
intent := getIntent(ctx) intent := getIntent(ctx)
var roomID id.RoomID portal := getPortal(ctx)
if portal := getPortal(ctx); portal != nil {
roomID = portal.MXID
}
var thumbnailData []byte var thumbnailData []byte
var thumbnailInfo *event.FileInfo var thumbnailInfo *event.FileInfo
if part.Info.Size > uploadFileThreshold { if part.Info.Size > uploadFileThreshold {
var err error var err error
part.URL, part.File, err = intent.UploadMediaStream(ctx, roomID, -1, true, func(file io.Writer) (*bridgev2.FileStreamResult, error) { part.URL, part.File, err = intent.UploadMediaStream(ctx, portal.MXID, -1, true, func(file io.Writer) (*bridgev2.FileStreamResult, error) {
err := client.DownloadToFile(ctx, message, file.(*os.File)) err := client.DownloadToFile(message, file.(*os.File))
if errors.Is(err, whatsmeow.ErrFileLengthMismatch) || errors.Is(err, whatsmeow.ErrInvalidMediaSHA256) { if errors.Is(err, whatsmeow.ErrFileLengthMismatch) || errors.Is(err, whatsmeow.ErrInvalidMediaSHA256) {
zerolog.Ctx(ctx).Warn().Err(err).Msg("Mismatching media checksums in message. Ignoring because WhatsApp seems to ignore them too") zerolog.Ctx(ctx).Warn().Err(err).Msg("Mismatching media checksums in message. Ignoring because WhatsApp seems to ignore them too")
} else if err != nil { } else if err != nil {
@ -387,7 +339,7 @@ func (mc *MessageConverter) reuploadWhatsAppAttachment(
return err return err
} }
} else { } else {
data, err := client.Download(ctx, message) data, err := client.Download(message)
if errors.Is(err, whatsmeow.ErrFileLengthMismatch) || errors.Is(err, whatsmeow.ErrInvalidMediaSHA256) { if errors.Is(err, whatsmeow.ErrFileLengthMismatch) || errors.Is(err, whatsmeow.ErrInvalidMediaSHA256) {
zerolog.Ctx(ctx).Warn().Err(err).Msg("Mismatching media checksums in message. Ignoring because WhatsApp seems to ignore them too") zerolog.Ctx(ctx).Warn().Err(err).Msg("Mismatching media checksums in message. Ignoring because WhatsApp seems to ignore them too")
} else if err != nil { } else if err != nil {
@ -398,14 +350,12 @@ func (mc *MessageConverter) reuploadWhatsAppAttachment(
if err != nil { if err != nil {
return err return err
} }
} else if part.Type == event.EventSticker && part.Info.MimeType == "image/webp" {
mc.fillWebPStickerInfo(ctx, part, data)
} }
if part.Info.MimeType == "" { if part.Info.MimeType == "" {
part.Info.MimeType = http.DetectContentType(data) part.Info.MimeType = http.DetectContentType(data)
} }
part.FillFileName() part.FillFileName()
part.URL, part.File, err = intent.UploadMedia(ctx, roomID, data, part.FileName, part.Info.MimeType) part.URL, part.File, err = intent.UploadMedia(ctx, portal.MXID, data, part.FileName, part.Info.MimeType)
if err != nil { if err != nil {
return fmt.Errorf("%w: %w", bridgev2.ErrMediaReuploadFailed, err) return fmt.Errorf("%w: %w", bridgev2.ErrMediaReuploadFailed, err)
} }
@ -414,7 +364,7 @@ func (mc *MessageConverter) reuploadWhatsAppAttachment(
var err error var err error
part.Info.ThumbnailURL, part.Info.ThumbnailFile, err = intent.UploadMedia( part.Info.ThumbnailURL, part.Info.ThumbnailFile, err = intent.UploadMedia(
ctx, ctx,
roomID, portal.MXID,
thumbnailData, thumbnailData,
"thumbnail"+exmime.ExtensionFromMimetype(thumbnailInfo.MimeType), "thumbnail"+exmime.ExtensionFromMimetype(thumbnailInfo.MimeType),
thumbnailInfo.MimeType, thumbnailInfo.MimeType,
@ -428,6 +378,85 @@ func (mc *MessageConverter) reuploadWhatsAppAttachment(
return nil return nil
} }
func (mc *MessageConverter) extractAnimatedSticker(fileInfo *PreparedMedia, data []byte) ([]byte, error) {
zipReader, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
return nil, fmt.Errorf("failed to read sticker zip: %w", err)
}
animationFile, err := zipReader.Open("animation/animation.json")
if err != nil {
return nil, fmt.Errorf("failed to open animation.json: %w", err)
}
animationFileInfo, err := animationFile.Stat()
if err != nil {
_ = animationFile.Close()
return nil, fmt.Errorf("failed to stat animation.json: %w", err)
} else if animationFileInfo.Size() > uploadFileThreshold {
_ = animationFile.Close()
return nil, fmt.Errorf("animation.json is too large (%.2f MiB)", float64(animationFileInfo.Size())/1024/1024)
}
data, err = io.ReadAll(animationFile)
_ = animationFile.Close()
if err != nil {
return nil, fmt.Errorf("failed to read animation.json: %w", err)
}
fileInfo.Info.MimeType = "video/lottie+json"
fileInfo.FileName = "sticker.json"
return data, nil
}
func (mc *MessageConverter) convertAnimatedSticker(ctx context.Context, fileInfo *PreparedMedia, data []byte) ([]byte, []byte, *event.FileInfo, error) {
data, err := mc.extractAnimatedSticker(fileInfo, data)
if err != nil {
return nil, nil, nil, err
}
c := mc.AnimatedStickerConfig
if c.Target == "disable" {
return data, nil, nil, nil
} else if !lottie.Supported() {
zerolog.Ctx(ctx).Warn().Msg("Animated sticker conversion is enabled, but lottieconverter is not installed")
return data, nil, nil, nil
}
input := bytes.NewReader(data)
fileInfo.Info.MimeType = "image/" + c.Target
fileInfo.FileName = "sticker." + c.Target
switch c.Target {
case "png":
var output bytes.Buffer
err = lottie.Convert(ctx, input, "", &output, c.Target, c.Args.Width, c.Args.Height, "1")
return output.Bytes(), nil, nil, err
case "gif":
var output bytes.Buffer
err = lottie.Convert(ctx, input, "", &output, c.Target, c.Args.Width, c.Args.Height, strconv.Itoa(c.Args.FPS))
return output.Bytes(), nil, nil, err
case "webm", "webp":
tmpFile := filepath.Join(os.TempDir(), fmt.Sprintf("mautrix-whatsapp-lottieconverter-%s.%s", random.String(10), c.Target))
defer func() {
_ = os.Remove(tmpFile)
}()
thumbnailData, err := lottie.FFmpegConvert(ctx, input, tmpFile, c.Args.Width, c.Args.Height, c.Args.FPS)
if err != nil {
return nil, nil, nil, err
}
data, err = os.ReadFile(tmpFile)
if err != nil {
return nil, nil, nil, fmt.Errorf("failed to read converted file: %w", err)
}
var thumbnailInfo *event.FileInfo
if thumbnailData != nil {
thumbnailInfo = &event.FileInfo{
MimeType: "image/png",
Width: c.Args.Width,
Height: c.Args.Height,
Size: len(thumbnailData),
}
}
return data, thumbnailData, thumbnailInfo, nil
default:
return nil, nil, nil, fmt.Errorf("unsupported target format %s", c.Target)
}
}
func (mc *MessageConverter) makeMediaFailure(ctx context.Context, mediaInfo *PreparedMedia, keys *FailedMediaKeys, err error) *bridgev2.ConvertedMessagePart { func (mc *MessageConverter) makeMediaFailure(ctx context.Context, mediaInfo *PreparedMedia, keys *FailedMediaKeys, err error) *bridgev2.ConvertedMessagePart {
logLevel := zerolog.ErrorLevel logLevel := zerolog.ErrorLevel
var extra map[string]any var extra map[string]any

View file

@ -27,7 +27,6 @@ import (
"github.com/rs/zerolog" "github.com/rs/zerolog"
"go.mau.fi/util/exerrors" "go.mau.fi/util/exerrors"
"go.mau.fi/util/ptr" "go.mau.fi/util/ptr"
"go.mau.fi/whatsmeow/proto/waAICommonDeprecated"
"go.mau.fi/whatsmeow/proto/waE2E" "go.mau.fi/whatsmeow/proto/waE2E"
"go.mau.fi/whatsmeow/types" "go.mau.fi/whatsmeow/types"
"google.golang.org/protobuf/proto" "google.golang.org/protobuf/proto"
@ -92,8 +91,6 @@ func (mc *MessageConverter) convertGroupInviteMessage(ctx context.Context, info
Code: msg.GetInviteCode(), Code: msg.GetInviteCode(),
Expiration: msg.GetInviteExpiration(), Expiration: msg.GetInviteExpiration(),
Inviter: info.Sender.ToNonAD(), Inviter: info.Sender.ToNonAD(),
GroupName: msg.GetGroupName(),
IsParentGroup: msg.GetGroupType() == waE2E.GroupInviteMessage_PARENT,
} }
extraAttrs = map[string]any{ extraAttrs = map[string]any{
GroupInviteMetaField: inviteMeta, GroupInviteMetaField: inviteMeta,
@ -117,11 +114,11 @@ func (mc *MessageConverter) convertGroupInviteMessage(ctx context.Context, info
}, msg.GetContextInfo() }, msg.GetContextInfo()
} }
func (mc *MessageConverter) convertEphemeralSettingMessage(ctx context.Context, msg *waE2E.ProtocolMessage, ts time.Time, isBackfill bool) (*bridgev2.ConvertedMessagePart, *waE2E.ContextInfo) { func (mc *MessageConverter) convertEphemeralSettingMessage(ctx context.Context, msg *waE2E.ProtocolMessage) (*bridgev2.ConvertedMessagePart, *waE2E.ContextInfo) {
portal := getPortal(ctx) portal := getPortal(ctx)
portalMeta := portal.Metadata.(*waid.PortalMetadata) portalMeta := portal.Metadata.(*waid.PortalMetadata)
disappear := database.DisappearingSetting{ disappear := database.DisappearingSetting{
Type: event.DisappearingTypeAfterSend, Type: database.DisappearingTypeAfterRead,
Timer: time.Duration(msg.GetEphemeralExpiration()) * time.Second, Timer: time.Duration(msg.GetEphemeralExpiration()) * time.Second,
} }
if disappear.Timer == 0 { if disappear.Timer == 0 {
@ -129,39 +126,26 @@ func (mc *MessageConverter) convertEphemeralSettingMessage(ctx context.Context,
} }
dontBridge := portal.Disappear == disappear dontBridge := portal.Disappear == disappear
content := bridgev2.DisappearingMessageNotice(disappear.Timer, false) content := bridgev2.DisappearingMessageNotice(disappear.Timer, false)
if !isBackfill {
if msg.EphemeralSettingTimestamp == nil || portalMeta.DisappearingTimerSetAt < msg.GetEphemeralSettingTimestamp() { if msg.EphemeralSettingTimestamp == nil || portalMeta.DisappearingTimerSetAt < msg.GetEphemeralSettingTimestamp() {
portal.Disappear = disappear
portalMeta.DisappearingTimerSetAt = msg.GetEphemeralSettingTimestamp() portalMeta.DisappearingTimerSetAt = msg.GetEphemeralSettingTimestamp()
portal.UpdateDisappearingSetting(ctx, disappear, bridgev2.UpdateDisappearingSettingOpts{ err := portal.Save(ctx)
Sender: getIntent(ctx), if err != nil {
Timestamp: ts, zerolog.Ctx(ctx).Err(err).Msg("Failed to save portal after updating expiration timer")
Implicit: false, }
Save: true,
SendNotice: false,
})
} else { } else {
content.Body += ", but the change was ignored." content.Body += ", but the change was ignored."
} }
}
return &bridgev2.ConvertedMessagePart{ return &bridgev2.ConvertedMessagePart{
Type: event.EventMessage, Type: event.EventMessage,
Content: content, Content: content,
Extra: map[string]any{
"com.beeper.action_message": map[string]any{
"type": "disappearing_timer",
"timer": disappear.Timer.Milliseconds(),
"timer_type": disappear.Type,
"implicit": false,
"backfill": isBackfill,
},
},
DontBridge: dontBridge, DontBridge: dontBridge,
}, nil }, nil
} }
const eventMessageTemplate = ` const eventMessageTemplate = `
{{- if .Name -}} {{- if .Name -}}
<h4>{{ .Name }} {{- if .IsCanceled -}}<span> (Canceled)</span>{{- end -}}</h4> <h4>{{ .Name }}</h4>
{{- end -}} {{- end -}}
{{- if .StartTime -}} {{- if .StartTime -}}
<p> <p>
@ -187,7 +171,6 @@ var eventMessageTplParsed = exerrors.Must(template.New("eventmessage").Parse(str
type eventMessageParams struct { type eventMessageParams struct {
Name string Name string
IsCanceled bool
JoinLink string JoinLink string
StartTimeISO string StartTimeISO string
StartTime string StartTime string
@ -200,7 +183,6 @@ type eventMessageParams struct {
func (mc *MessageConverter) convertEventMessage(ctx context.Context, msg *waE2E.EventMessage) (*bridgev2.ConvertedMessagePart, *waE2E.ContextInfo) { func (mc *MessageConverter) convertEventMessage(ctx context.Context, msg *waE2E.EventMessage) (*bridgev2.ConvertedMessagePart, *waE2E.ContextInfo) {
params := &eventMessageParams{ params := &eventMessageParams{
Name: msg.GetName(), Name: msg.GetName(),
IsCanceled: msg.GetIsCanceled(),
JoinLink: msg.GetJoinLink(), JoinLink: msg.GetJoinLink(),
Location: msg.GetLocation().GetName(), Location: msg.GetLocation().GetName(),
DescriptionHTML: template.HTML(parseWAFormattingToHTML(msg.GetDescription(), false)), DescriptionHTML: template.HTML(parseWAFormattingToHTML(msg.GetDescription(), false)),
@ -232,53 +214,3 @@ func (mc *MessageConverter) convertEventMessage(ctx context.Context, msg *waE2E.
Content: &content, Content: &content,
}, msg.GetContextInfo() }, msg.GetContextInfo()
} }
func (mc *MessageConverter) convertPinInChatMessage(ctx context.Context, msg *waE2E.PinInChatMessage) (*bridgev2.ConvertedMessagePart, *waE2E.ContextInfo) {
body := "Pinned a message"
if msg.GetType() == waE2E.PinInChatMessage_UNPIN_FOR_ALL {
body = "Unpinned a message"
}
return &bridgev2.ConvertedMessagePart{
Type: event.EventMessage,
Content: &event.MessageEventContent{
MsgType: event.MsgNotice,
Body: body,
},
}, nil
}
func (mc *MessageConverter) convertKeepInChatMessage(ctx context.Context, msg *waE2E.KeepInChatMessage) (*bridgev2.ConvertedMessagePart, *waE2E.ContextInfo) {
body := "Kept a message"
if msg.GetKeepType() == waE2E.KeepType_UNDO_KEEP_FOR_ALL {
body = "Unkept a message"
}
return &bridgev2.ConvertedMessagePart{
Type: event.EventMessage,
Content: &event.MessageEventContent{
MsgType: event.MsgNotice,
Body: body,
},
}, nil
}
func (mc *MessageConverter) convertRichResponseMessage(ctx context.Context, msg *waE2E.AIRichResponseMessage) (*bridgev2.ConvertedMessagePart, *waE2E.ContextInfo) {
var body strings.Builder
// TODO switch to new format?
for i, submsg := range msg.GetSubmessages() {
if submsg.GetMessageType() == waAICommonDeprecated.AIRichResponseSubMessageType_AI_RICH_RESPONSE_TEXT {
if i > 0 {
body.WriteString("\n")
}
body.WriteString(submsg.GetMessageText())
}
}
content := format.RenderMarkdown(body.String(), true, false)
return &bridgev2.ConvertedMessagePart{
Type: event.EventMessage,
Content: &content,
}, msg.GetContextInfo()
}

View file

@ -24,6 +24,7 @@ import (
"strings" "strings"
"github.com/rs/zerolog" "github.com/rs/zerolog"
"go.mau.fi/util/ptr"
"go.mau.fi/whatsmeow" "go.mau.fi/whatsmeow"
"go.mau.fi/whatsmeow/proto/waCommon" "go.mau.fi/whatsmeow/proto/waCommon"
"go.mau.fi/whatsmeow/proto/waE2E" "go.mau.fi/whatsmeow/proto/waE2E"
@ -94,31 +95,7 @@ func (mc *MessageConverter) convertPollCreationMessage(ctx context.Context, msg
}, msg.GetContextInfo() }, msg.GetContextInfo()
} }
func rerouteMessageKey(ctx context.Context, chat, sender types.JID, groupLIDAddressing bool) types.JID { func KeyToMessageID(client *whatsmeow.Client, chat, sender types.JID, key *waCommon.MessageKey) networkid.MessageID {
if store := getClient(ctx).Store; store != nil && chat.Server == types.DefaultUserServer && sender.Server == types.HiddenUserServer {
senderPN, _ := store.LIDs.GetPNForLID(ctx, sender)
zerolog.Ctx(ctx).Debug().
Stringer("orig_participant", sender).
Stringer("rerouted_participant", senderPN).
Msg("Rerouting message key (PN recipient in LID DM)")
if !senderPN.IsEmpty() {
return senderPN
}
} else if store != nil && chat.Server == types.GroupServer && sender.Server == types.DefaultUserServer && groupLIDAddressing {
senderLID, _ := store.LIDs.GetLIDForPN(ctx, sender)
zerolog.Ctx(ctx).Debug().
Stringer("orig_participant", sender).
Stringer("rerouted_participant", senderLID).
Msg("Rerouting message key (PN recipient in LID group)")
if !senderLID.IsEmpty() {
return senderLID
}
}
return sender
}
func KeyToMessageID(ctx context.Context, client *whatsmeow.Client, chat, sender types.JID, key *waCommon.MessageKey) networkid.MessageID {
groupLIDAddressing := sender.Server == types.HiddenUserServer
sender = sender.ToNonAD() sender = sender.ToNonAD()
var err error var err error
if !key.GetFromMe() { if !key.GetFromMe() {
@ -132,21 +109,14 @@ func KeyToMessageID(ctx context.Context, client *whatsmeow.Client, chat, sender
sender.Server = types.DefaultUserServer sender.Server = types.DefaultUserServer
} }
} else if chat.Server == types.DefaultUserServer || chat.Server == types.BotServer { } else if chat.Server == types.DefaultUserServer || chat.Server == types.BotServer {
if sender.User == client.Store.GetJID().User || sender.User == client.Store.GetLID().User { ownID := ptr.Val(client.Store.ID).ToNonAD()
// Message key is not from the sender, but message sender (containing key) is me, if sender.User == ownID.User {
// so message key sender is the other user in the DM
sender = chat sender = chat
} else { } else {
// Message key is not from the sender, but message sender (containing key) is not me, sender = ownID
// so message key sender is me
sender = client.Store.GetJID().ToNonAD()
} }
} else { } else {
zerolog.Ctx(ctx).Warn(). // TODO log somehow?
Stringer("chat", chat).
Stringer("sender", sender).
Any("key", key).
Msg("Failed to get message ID from key")
return "" return ""
} }
} }
@ -157,10 +127,6 @@ func KeyToMessageID(ctx context.Context, client *whatsmeow.Client, chat, sender
chat = remoteJID chat = remoteJID
} }
} }
sender = rerouteMessageKey(
context.WithValue(ctx, contextKeyClient, client),
chat, sender, groupLIDAddressing,
)
return waid.MakeMessageID(chat, sender, key.GetID()) return waid.MakeMessageID(chat, sender, key.GetID())
} }
@ -172,16 +138,13 @@ var failedPollUpdatePart = &bridgev2.ConvertedMessagePart{
func (mc *MessageConverter) convertPollUpdateMessage(ctx context.Context, info *types.MessageInfo, msg *waE2E.PollUpdateMessage) (*bridgev2.ConvertedMessagePart, *waE2E.ContextInfo) { func (mc *MessageConverter) convertPollUpdateMessage(ctx context.Context, info *types.MessageInfo, msg *waE2E.PollUpdateMessage) (*bridgev2.ConvertedMessagePart, *waE2E.ContextInfo) {
log := zerolog.Ctx(ctx) log := zerolog.Ctx(ctx)
pollMessageID := KeyToMessageID(ctx, getClient(ctx), info.Chat, info.Sender, msg.PollCreationMessageKey) pollMessageID := KeyToMessageID(getClient(ctx), info.Chat, info.Sender, msg.PollCreationMessageKey)
pollMessage, err := mc.Bridge.DB.Message.GetPartByID(ctx, getPortal(ctx).Receiver, pollMessageID, "") pollMessage, err := mc.Bridge.DB.Message.GetPartByID(ctx, getPortal(ctx).Receiver, pollMessageID, "")
if err != nil { if err != nil {
log.Err(err).Msg("Failed to get poll update target message") log.Err(err).Msg("Failed to get poll update target message")
return failedPollUpdatePart, nil return failedPollUpdatePart, nil
} else if pollMessage == nil {
log.Warn().Str("target_message_id", string(pollMessageID)).Msg("Poll update target message not found")
return failedPollUpdatePart, nil
} }
vote, err := getClient(ctx).DecryptPollVote(ctx, &events.Message{ vote, err := getClient(ctx).DecryptPollVote(&events.Message{
Info: *info, Info: *info,
Message: &waE2E.Message{PollUpdateMessage: msg}, Message: &waE2E.Message{PollUpdateMessage: msg},
}) })

View file

@ -1,455 +0,0 @@
// mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge.
// Copyright (C) 2026 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package msgconv
import (
"archive/zip"
"bytes"
"context"
"encoding/base64"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"io"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/rs/zerolog"
"github.com/tidwall/gjson"
"go.mau.fi/util/exstrings"
"go.mau.fi/util/lottie"
"go.mau.fi/util/random"
"go.mau.fi/whatsmeow"
"go.mau.fi/whatsmeow/types"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/database"
"maunium.net/go/mautrix/bridgev2/networkid"
"maunium.net/go/mautrix/event"
"go.mau.fi/mautrix-whatsapp/pkg/waid"
)
func (mc *MessageConverter) GetCachedStickerPack(ctx context.Context, client *whatsmeow.Client, packID string) (*types.StickerPack, error) {
mc.stickerPackCacheLock.Lock()
defer mc.stickerPackCacheLock.Unlock()
cached, ok := mc.stickerPackCache[packID]
if ok {
if cached == nil {
return nil, bridgev2.RespError(mautrix.MNotFound.WithMessage("sticker pack not found (cached)"))
}
return cached, nil
}
pack, err := client.FetchStickerPack(ctx, packID)
if errors.Is(err, whatsmeow.ErrMediaDownloadFailedWith404) {
mc.stickerPackCache[packID] = nil
return nil, bridgev2.WrapRespErr(err, mautrix.MNotFound)
} else if err != nil {
return nil, err
}
mc.stickerPackCache[packID] = pack
if packID != pack.StickerPackID {
mc.stickerPackCache[pack.StickerPackID] = pack
}
return pack, nil
}
func (mc *MessageConverter) GetCachedSticker(ctx context.Context, client *whatsmeow.Client, packID string, hash []byte) (*types.StickerPackItem, error) {
pack, err := mc.GetCachedStickerPack(ctx, client, packID)
if err != nil {
return nil, err
}
for _, sticker := range pack.Stickers {
if bytes.Equal(sticker.FileHash, hash) {
return sticker, nil
}
}
return nil, nil
}
func (mc *MessageConverter) DownloadImagePack(ctx context.Context, userLoginID networkid.UserLoginID, client *whatsmeow.Client, inputURL string) (*bridgev2.ImportedImagePack, error) {
parsedURL, err := url.Parse(inputURL)
if err != nil {
return nil, bridgev2.WrapRespErr(err, mautrix.MNotFound)
} else if parsedURL.Host != "api.whatsapp.com" && parsedURL.Host != "wa.me" {
return nil, bridgev2.WrapRespErr(fmt.Errorf("invalid host %q", parsedURL.Host), mautrix.MNotFound)
} else if !strings.HasPrefix(parsedURL.Path, "/stickerpack/") {
return nil, bridgev2.WrapRespErr(fmt.Errorf("invalid path %q", parsedURL.Path), mautrix.MNotFound)
}
packName := strings.Split(strings.TrimPrefix(parsedURL.Path, "/stickerpack/"), "/")[0]
if packName == "" {
return nil, bridgev2.WrapRespErr(fmt.Errorf("empty pack name"), mautrix.MNotFound)
}
pack, err := mc.GetCachedStickerPack(ctx, client, packName)
if err != nil {
return nil, err
}
canonicalURL := "https://wa.me/stickerpack/" + pack.StickerPackID
topLevelExtra := map[string]any{
"fi.mau.whatsapp.stickerpack": map[string]any{
"id": pack.StickerPackID,
"name": pack.Name,
"description": pack.Description,
"publisher": pack.Publisher,
"animated": pack.Animated > 0,
"lottie": pack.Lottie > 0,
},
}
content := &event.ImagePackEventContent{
Images: make(map[string]*event.ImagePackImage, len(pack.Stickers)),
Metadata: event.ImagePackMetadata{
DisplayName: pack.Name,
AvatarURL: "",
Usage: []event.ImagePackUsage{event.ImagePackUsageSticker},
Attribution: fmt.Sprintf("By %s on WhatsApp %s", pack.Publisher, canonicalURL),
BridgedPack: &event.BridgedStickerPack{
Network: StickerSourceID,
URL: canonicalURL,
},
},
}
ctx = context.WithValue(ctx, contextKeyClient, client)
ctx = context.WithValue(ctx, contextKeyIntent, mc.Bridge.Bot)
ctx = context.WithValue(ctx, contextKeyPortal, (*bridgev2.Portal)(nil))
for i, sticker := range pack.Stickers {
shortcode := sticker.PreviewWebpID
if shortcode == "" {
shortcode = fmt.Sprintf("%s_img%d", pack.StickerPackID, i+1)
}
body := sticker.AccessibilityText
var emoji string
if len(sticker.Emojis) > 0 {
emoji = sticker.Emojis[0]
if body == "" {
body = strings.Join(sticker.Emojis, " ")
}
}
part := &PreparedMedia{
Type: event.EventSticker,
MessageEventContent: &event.MessageEventContent{
Body: body,
Info: &event.FileInfo{
MimeType: sticker.MimeType,
Width: sticker.Width,
Height: sticker.Height,
Size: int(sticker.FileSize),
BridgedSticker: &event.BridgedSticker{
Network: StickerSourceID,
ID: base64.StdEncoding.EncodeToString(sticker.FileHash),
Emoji: emoji,
PackURL: canonicalURL,
},
},
},
TypeDescription: "sticker",
}
dbKey := database.Key(fmt.Sprintf("stickercache:%x", part.Info.BridgedSticker.ID))
fixStickerDimensions(part.Info)
var packed *event.ImagePackImage
if mc.DirectMedia {
dbKey = ""
if part.Info.MimeType == "application/was" {
part.Info.MimeType = "video/lottie+json"
}
part.URL, err = mc.Bridge.Matrix.GenerateContentURI(ctx, waid.MakeStickerPackMediaID(pack.StickerPackID, sticker.FileHash, userLoginID))
if err != nil {
panic(fmt.Errorf("failed to generate content URI: %w", err))
}
} else if cached := mc.Bridge.DB.KV.Get(ctx, dbKey); cached != "" {
err = json.Unmarshal([]byte(cached), &packed)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal cached sticker data: %w", err)
}
} else {
err = mc.reuploadWhatsAppAttachment(ctx, sticker, part)
if err != nil {
return nil, fmt.Errorf("failed to reupload sticker %q: %w", sticker.GetDirectPath(), err)
}
}
if packed == nil {
packed = &event.ImagePackImage{
URL: part.URL,
Body: part.Body,
Info: part.Info,
}
if dbKey != "" {
data, _ := json.Marshal(packed)
if data != nil {
mc.Bridge.DB.KV.Set(ctx, dbKey, string(data))
}
}
}
content.Images[shortcode] = packed
}
return &bridgev2.ImportedImagePack{
Content: content,
Extra: topLevelExtra,
Shortcode: pack.StickerPackID,
}, nil
}
type StickerMetadata struct {
StickerPackID string `json:"sticker-pack-id"`
AccessibilityText string `json:"accessibility-text"`
Emojis []string `json:"emojis"`
IsFirstPartySticker int `json:"is-first-party-sticker"`
}
func (sm *StickerMetadata) ToMatrix(content *event.MessageEventContent) {
if sm == nil {
return
}
if sm.StickerPackID != "" && content.Info.BridgedSticker == nil {
content.Info.BridgedSticker = &event.BridgedSticker{
Network: StickerSourceID,
PackURL: StickerPackURLPrefix + sm.StickerPackID,
}
if len(sm.Emojis) > 0 {
content.Info.BridgedSticker.Emoji = sm.Emojis[0]
}
}
if sm.AccessibilityText != "" {
content.Body = sm.AccessibilityText
} else if len(sm.Emojis) > 0 {
content.Body = strings.Join(sm.Emojis, " ")
}
}
const StickerSourceID = "whatsapp"
const StickerPackURLPrefix = "https://wa.me/stickerpack/"
func PackAnimatedSticker(data []byte) ([]byte, error) {
var buf bytes.Buffer
zipWriter := zip.NewWriter(&buf)
f, err := zipWriter.Create("animation/animation.json")
if err != nil {
return nil, fmt.Errorf("failed to create zip entry: %w", err)
}
_, err = f.Write(data)
if err != nil {
return nil, fmt.Errorf("failed to write zip entry: %w", err)
}
err = zipWriter.Close()
if err != nil {
return nil, fmt.Errorf("failed to close zip writer: %w", err)
}
return buf.Bytes(), nil
}
func ExtractAnimatedSticker(data []byte) ([]byte, *StickerMetadata, error) {
zipReader, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
return nil, nil, fmt.Errorf("failed to read sticker zip: %w", err)
}
animationFile, err := zipReader.Open("animation/animation.json")
if err != nil {
return nil, nil, fmt.Errorf("failed to open animation.json: %w", err)
}
animationFileInfo, err := animationFile.Stat()
if err != nil {
_ = animationFile.Close()
return nil, nil, fmt.Errorf("failed to stat animation.json: %w", err)
} else if animationFileInfo.Size() > uploadFileThreshold {
_ = animationFile.Close()
return nil, nil, fmt.Errorf("animation.json is too large (%.2f MiB)", float64(animationFileInfo.Size())/1024/1024)
}
data, err = io.ReadAll(animationFile)
_ = animationFile.Close()
if err != nil {
return nil, nil, fmt.Errorf("failed to read animation.json: %w", err)
}
var meta StickerMetadata
metaFile, err := zipReader.Open("animation/animation.json.overridden_metadata")
if err == nil {
_ = json.NewDecoder(metaFile).Decode(&meta)
_ = metaFile.Close()
}
if meta.StickerPackID == "" {
res := gjson.GetBytes(data, "metadata.customProps")
if res.IsObject() {
_ = json.Unmarshal(exstrings.UnsafeBytes(res.Raw), &meta)
}
}
return data, &meta, nil
}
func (mc *MessageConverter) extractAnimatedSticker(fileInfo *PreparedMedia, data []byte) ([]byte, error) {
data, meta, err := ExtractAnimatedSticker(data)
if err != nil {
return nil, err
}
meta.ToMatrix(fileInfo.MessageEventContent)
fileInfo.Info.MimeType = "video/lottie+json"
fileInfo.FileName = "sticker.json"
return data, nil
}
func (mc *MessageConverter) convertAnimatedSticker(ctx context.Context, fileInfo *PreparedMedia, data []byte) ([]byte, []byte, *event.FileInfo, error) {
data, err := mc.extractAnimatedSticker(fileInfo, data)
if err != nil {
return nil, nil, nil, err
}
c := mc.AnimatedStickerConfig
if c.Target == "disable" {
return data, nil, nil, nil
} else if !lottie.Supported() {
zerolog.Ctx(ctx).Warn().Msg("Animated sticker conversion is enabled, but lottieconverter is not installed")
return data, nil, nil, nil
}
input := bytes.NewReader(data)
fileInfo.Info.MimeType = "image/" + c.Target
fileInfo.FileName = "sticker." + c.Target
switch c.Target {
case "png":
var output bytes.Buffer
err = lottie.Convert(ctx, input, "", &output, c.Target, c.Args.Width, c.Args.Height, "1")
return output.Bytes(), nil, nil, err
case "gif":
var output bytes.Buffer
err = lottie.Convert(ctx, input, "", &output, c.Target, c.Args.Width, c.Args.Height, strconv.Itoa(c.Args.FPS))
return output.Bytes(), nil, nil, err
case "webm", "webp":
tmpFile := filepath.Join(os.TempDir(), fmt.Sprintf("mautrix-whatsapp-lottieconverter-%s.%s", random.String(10), c.Target))
defer func() {
_ = os.Remove(tmpFile)
}()
thumbnailData, err := lottie.FFmpegConvert(ctx, input, tmpFile, c.Args.Width, c.Args.Height, c.Args.FPS)
if err != nil {
return nil, nil, nil, err
}
data, err = os.ReadFile(tmpFile)
if err != nil {
return nil, nil, nil, fmt.Errorf("failed to read converted file: %w", err)
}
var thumbnailInfo *event.FileInfo
if thumbnailData != nil {
thumbnailInfo = &event.FileInfo{
MimeType: "image/png",
Width: c.Args.Width,
Height: c.Args.Height,
Size: len(thumbnailData),
}
}
return data, thumbnailData, thumbnailInfo, nil
default:
return nil, nil, nil, fmt.Errorf("unsupported target format %s", c.Target)
}
}
func (mc *MessageConverter) fillWebPStickerInfo(ctx context.Context, fileInfo *PreparedMedia, data []byte) {
meta, err := extractWebPStickerMetadata(data)
if err != nil {
zerolog.Ctx(ctx).Debug().Err(err).Msg("Failed to extract webp sticker metadata")
return
}
meta.ToMatrix(fileInfo.MessageEventContent)
}
// stickerMetadataEXIFTag is the custom EXIF tag WhatsApp uses to embed
// sticker pack metadata as a JSON object inside non-animated webp stickers.
const stickerMetadataEXIFTag = 0x5741
// extractWebPStickerMetadata parses the WhatsApp sticker pack metadata JSON
// embedded in EXIF tag 0x5741 of a non-animated webp sticker.
func extractWebPStickerMetadata(data []byte) (*StickerMetadata, error) {
exif, err := findWebPChunk(data, "EXIF")
if err != nil {
return nil, err
}
raw, err := findEXIFTagValue(exif, stickerMetadataEXIFTag)
if err != nil {
return nil, err
}
var meta StickerMetadata
err = json.Unmarshal(raw, &meta)
if err != nil {
return nil, fmt.Errorf("failed to parse sticker metadata JSON: %w", err)
}
return &meta, nil
}
func findWebPChunk(data []byte, chunkType string) ([]byte, error) {
if len(data) < 12 || string(data[0:4]) != "RIFF" || string(data[8:12]) != "WEBP" {
return nil, fmt.Errorf("not a webp file")
}
for pos := 12; pos+8 <= len(data); {
size := binary.LittleEndian.Uint32(data[pos+4 : pos+8])
start := pos + 8
end := start + int(size)
if end > len(data) {
return nil, fmt.Errorf("webp chunk %q extends past end of file", data[pos:pos+4])
}
if string(data[pos:pos+4]) == chunkType {
return data[start:end], nil
}
pos = end
if pos%2 != 0 {
pos++
}
}
return nil, fmt.Errorf("webp chunk %q not found", chunkType)
}
func findEXIFTagValue(exif []byte, tag uint16) ([]byte, error) {
if len(exif) < 8 {
return nil, fmt.Errorf("exif data too short")
}
var bo binary.ByteOrder
switch string(exif[0:2]) {
case "II":
bo = binary.LittleEndian
case "MM":
bo = binary.BigEndian
default:
return nil, fmt.Errorf("invalid TIFF byte order %q", exif[0:2])
}
if bo.Uint16(exif[2:4]) != 0x002A {
return nil, fmt.Errorf("invalid TIFF magic")
}
ifdOffset := int(bo.Uint32(exif[4:8]))
if ifdOffset < 0 || ifdOffset+2 > len(exif) {
return nil, fmt.Errorf("IFD offset out of range")
}
count := int(bo.Uint16(exif[ifdOffset : ifdOffset+2]))
entries := ifdOffset + 2
if entries+count*12 > len(exif) {
return nil, fmt.Errorf("IFD entries out of range")
}
for i := 0; i < count; i++ {
entry := exif[entries+i*12 : entries+(i+1)*12]
if bo.Uint16(entry[0:2]) != tag {
continue
}
// Tag 0x5741 stores JSON as type 7 (UNDEFINED), where size == count bytes.
size := int(bo.Uint32(entry[4:8]))
if size <= 4 {
return entry[8 : 8+size], nil
}
offset := int(bo.Uint32(entry[8:12]))
if offset+size > len(exif) {
return nil, fmt.Errorf("exif tag value out of range")
}
return exif[offset : offset+size], nil
}
return nil, fmt.Errorf("exif tag 0x%04x not found", tag)
}

View file

@ -20,30 +20,24 @@ import (
"crypto/ecdh" "crypto/ecdh"
"crypto/rand" "crypto/rand"
"encoding/json" "encoding/json"
"time"
"go.mau.fi/util/exerrors" "go.mau.fi/util/exerrors"
"go.mau.fi/util/jsontime" "go.mau.fi/util/jsontime"
"go.mau.fi/util/random" "go.mau.fi/util/random"
"go.mau.fi/whatsmeow/appstate"
"go.mau.fi/whatsmeow/types" "go.mau.fi/whatsmeow/types"
) )
type UserLoginMetadata struct { type UserLoginMetadata struct {
WADeviceID uint16 `json:"wa_device_id"` WADeviceID uint16 `json:"wa_device_id"`
WALID string `json:"wa_lid"`
PhoneLastSeen jsontime.Unix `json:"phone_last_seen"` PhoneLastSeen jsontime.Unix `json:"phone_last_seen"`
PhoneLastPinged jsontime.Unix `json:"phone_last_pinged"` PhoneLastPinged jsontime.Unix `json:"phone_last_pinged"`
Timezone string `json:"timezone"` Timezone string `json:"timezone"`
PushKeys *PushKeys `json:"push_keys,omitempty"` PushKeys *PushKeys `json:"push_keys,omitempty"`
APNSEncPubKey []byte `json:"apns_enc_pubkey,omitempty"` APNSEncPubKey []byte `json:"apns_enc_pubkey,omitempty"`
APNSEncPrivKey []byte `json:"apns_enc_privkey,omitempty"` APNSEncPrivKey []byte `json:"apns_enc_privkey,omitempty"`
LoggedInAt jsontime.Unix `json:"logged_in_at,omitempty"`
AppStateRecoveryAttempted map[appstate.WAPatchName]time.Time `json:"app_state_recovery_attempted,omitempty"`
HistorySyncPortalsNeedCreating bool `json:"history_sync_portals_need_creating,omitempty"` HistorySyncPortalsNeedCreating bool `json:"history_sync_portals_need_creating,omitempty"`
MData json.RawMessage `json:"mdata,omitempty"`
} }
type PushKeys struct { type PushKeys struct {
@ -74,9 +68,6 @@ type GroupInviteMeta struct {
Code string `json:"code"` Code string `json:"code"`
Expiration int64 `json:"expiration,string"` Expiration int64 `json:"expiration,string"`
Inviter types.JID `json:"inviter"` Inviter types.JID `json:"inviter"`
GroupName string `json:"group_name,omitempty"`
IsParentGroup bool `json:"is_parent_group,omitempty"`
} }
type MessageMetadata struct { type MessageMetadata struct {
@ -115,11 +106,9 @@ type ReactionMetadata struct {
type PortalMetadata struct { type PortalMetadata struct {
DisappearingTimerSetAt int64 `json:"disappearing_timer_set_at,omitempty"` DisappearingTimerSetAt int64 `json:"disappearing_timer_set_at,omitempty"`
TopicID string `json:"topic_id,omitempty"`
LastSync jsontime.Unix `json:"last_sync,omitempty"` LastSync jsontime.Unix `json:"last_sync,omitempty"`
CommunityAnnouncementGroup bool `json:"is_cag,omitempty"` CommunityAnnouncementGroup bool `json:"is_cag,omitempty"`
AddressingMode types.AddressingMode `json:"addressing_mode,omitempty"` AddressingMode types.AddressingMode `json:"addressing_mode,omitempty"`
LIDMigrationAttempted bool `json:"lid_migration_attempted,omitempty"`
} }
type GhostMetadata struct { type GhostMetadata struct {

View file

@ -17,6 +17,7 @@
package waid package waid
import ( import (
"bytes"
"encoding/binary" "encoding/binary"
"encoding/hex" "encoding/hex"
"fmt" "fmt"
@ -28,26 +29,12 @@ import (
"maunium.net/go/mautrix/bridgev2/networkid" "maunium.net/go/mautrix/bridgev2/networkid"
) )
const ( func MakeMediaID(messageInfo *types.MessageInfo, receiver networkid.UserLoginID) networkid.MediaID {
// Media ID types start from 255, because old media IDs didn't have a type byte and had the length at the start.
mediaIDTypeMessage = 255
mediaIDTypeAvatar = 254
mediaIDTypeCommunityAvatar = 253
mediaIDTypeStickerPackItem = 252
)
func MakeMediaID(messageInfo *types.MessageInfo, idOverride types.MessageID, receiver networkid.UserLoginID) networkid.MediaID {
compactChat := compactJID(messageInfo.Chat.ToNonAD()) compactChat := compactJID(messageInfo.Chat.ToNonAD())
compactSender := compactJID(messageInfo.Sender.ToNonAD()) compactSender := compactJID(messageInfo.Sender.ToNonAD())
receiverID := compactJID(ParseUserLoginID(receiver, 0)) receiverID := compactJID(ParseUserLoginID(receiver, 0))
var compactID []byte compactID := compactMsgID(messageInfo.ID)
if idOverride != "" { mediaID := make([]byte, 0, 3+len(compactChat)+len(compactSender)+len(compactID))
compactID = compactMsgID(idOverride)
} else {
compactID = compactMsgID(messageInfo.ID)
}
mediaID := make([]byte, 0, 5+len(compactChat)+len(compactSender)+len(receiverID)+len(compactID))
mediaID = append(mediaID, mediaIDTypeMessage)
mediaID = append(mediaID, byte(len(compactChat))) mediaID = append(mediaID, byte(len(compactChat)))
mediaID = append(mediaID, compactChat...) mediaID = append(mediaID, compactChat...)
mediaID = append(mediaID, byte(len(compactSender))) mediaID = append(mediaID, byte(len(compactSender)))
@ -59,131 +46,29 @@ func MakeMediaID(messageInfo *types.MessageInfo, idOverride types.MessageID, rec
return mediaID return mediaID
} }
func MakeAvatarMediaID(targetJID types.JID, id string, receiver networkid.UserLoginID, community bool) networkid.MediaID { func ParseMediaID(mediaID networkid.MediaID) (*ParsedMessageID, networkid.UserLoginID, error) {
compactTarget := compactJID(targetJID.ToNonAD()) reader := bytes.NewReader(mediaID)
receiverID := compactJID(ParseUserLoginID(receiver, 0)) chatJID, err := readCompact(reader, parseCompactJID)
mediaID := make([]byte, 0, 4+len(compactTarget)+len(id)+len(receiverID))
if community {
mediaID = append(mediaID, mediaIDTypeCommunityAvatar)
} else {
mediaID = append(mediaID, mediaIDTypeAvatar)
}
mediaID = append(mediaID, byte(len(compactTarget)))
mediaID = append(mediaID, compactTarget...)
mediaID = append(mediaID, byte(len(id)))
mediaID = append(mediaID, id...)
mediaID = append(mediaID, byte(len(receiverID)))
mediaID = append(mediaID, receiverID...)
return mediaID
}
type AvatarMediaInfo struct {
TargetJID types.JID
AvatarID string
Community bool
}
func MakeStickerPackMediaID(packID string, fileHash []byte, receiver networkid.UserLoginID) networkid.MediaID {
receiverID := compactJID(ParseUserLoginID(receiver, 0))
mediaID := make([]byte, 0, 4+len(packID)+len(fileHash)+len(receiverID))
mediaID = append(mediaID, mediaIDTypeStickerPackItem)
mediaID = append(mediaID, byte(len(packID)))
mediaID = append(mediaID, packID...)
mediaID = append(mediaID, byte(len(fileHash)))
mediaID = append(mediaID, fileHash...)
mediaID = append(mediaID, byte(len(receiverID)))
mediaID = append(mediaID, receiverID...)
return mediaID
}
type StickerPackMediaInfo struct {
PackID string
FileHash []byte
}
type ParsedMediaID struct {
Message *ParsedMessageID
Avatar *AvatarMediaInfo
Sticker *StickerPackMediaInfo
UserLogin networkid.UserLoginID
}
func ParseMediaID(mediaID networkid.MediaID) (*ParsedMediaID, error) {
mediaIDType := mediaIDTypeMessage
if mediaID[0] > 127 {
mediaIDType = int(mediaID[0])
mediaID = mediaID[1:]
}
var parsed ParsedMediaID
switch mediaIDType {
case mediaIDTypeMessage:
chatJID, err := readCompact(&mediaID, parseCompactJID)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to parse chat JID: %w", err) return nil, "", fmt.Errorf("failed to parse chat JID: %w", err)
} }
senderJID, err := readCompact(&mediaID, parseCompactJID) senderJID, err := readCompact(reader, parseCompactJID)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to parse sender JID: %w", err) return nil, "", fmt.Errorf("failed to parse sender JID: %w", err)
} }
receiverID, err := readCompact(&mediaID, parseCompactJID) receiverID, err := readCompact(reader, parseCompactJID)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to parse receiver JID: %w", err) return nil, "", fmt.Errorf("failed to parse receiver JID: %w", err)
} }
id, err := readCompact(&mediaID, parseCompactMsgID) id, err := readCompact(reader, parseCompactMsgID)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to parse message ID: %w", err) return nil, "", fmt.Errorf("failed to parse message ID: %w", err)
} }
parsed.Message = &ParsedMessageID{ return &ParsedMessageID{
Chat: chatJID, Chat: chatJID,
Sender: senderJID, Sender: senderJID,
ID: id, ID: id,
} }, MakeUserLoginID(receiverID), nil
parsed.UserLogin = MakeUserLoginID(receiverID)
case mediaIDTypeAvatar, mediaIDTypeCommunityAvatar:
targetJID, err := readCompact(&mediaID, parseCompactJID)
if err != nil {
return nil, fmt.Errorf("failed to parse target JID: %w", err)
}
avatarID, err := readCompact(&mediaID, parseString)
if err != nil {
return nil, fmt.Errorf("failed to parse avatar ID: %w", err)
}
receiverID, err := readCompact(&mediaID, parseCompactJID)
if err != nil {
return nil, fmt.Errorf("failed to parse receiver JID: %w", err)
}
parsed.Avatar = &AvatarMediaInfo{
TargetJID: targetJID,
AvatarID: avatarID,
Community: mediaIDType == mediaIDTypeCommunityAvatar,
}
parsed.UserLogin = MakeUserLoginID(receiverID)
case mediaIDTypeStickerPackItem:
packID, err := readCompact(&mediaID, parseString)
if err != nil {
return nil, fmt.Errorf("failed to parse sticker pack ID: %w", err)
}
fileHash, err := readCompact(&mediaID, rawBytes)
if err != nil {
return nil, fmt.Errorf("failed to parse sticker file hash: %w", err)
}
receiverID, err := readCompact(&mediaID, parseCompactJID)
if err != nil {
return nil, fmt.Errorf("failed to parse receiver JID: %w", err)
}
parsed.Sticker = &StickerPackMediaInfo{
PackID: packID,
FileHash: fileHash,
}
parsed.UserLogin = MakeUserLoginID(receiverID)
default:
return nil, fmt.Errorf("unknown media ID type %d", mediaIDType)
}
return &parsed, nil
}
func parseString(data []byte) (string, error) {
return string(data), nil
} }
func isUpperHex(str string) bool { func isUpperHex(str string) bool {
@ -284,20 +169,16 @@ func parseCompactJID(jid []byte) (types.JID, error) {
} }
} }
func rawBytes(data []byte) ([]byte, error) { func readCompact[T any](reader *bytes.Reader, fn func(data []byte) (T, error)) (T, error) {
return data, nil
}
func readCompact[T any](data *networkid.MediaID, fn func(data []byte) (T, error)) (T, error) {
var defVal T var defVal T
if len(*data) < 1 { length, err := reader.ReadByte()
return defVal, fmt.Errorf("%w (data too short to read length)", io.ErrUnexpectedEOF) if err != nil {
return defVal, err
} }
length := int((*data)[0]) data := make([]byte, length)
if len(*data) < length+1 { _, err = io.ReadFull(reader, data)
return defVal, fmt.Errorf("%w (wanted %d+1 bytes, only have %d)", io.ErrUnexpectedEOF, length, len(*data)) if err != nil {
return defVal, err
} }
dataToParse := (*data)[1 : length+1] return fn(data)
*data = (*data)[length+1:]
return fn(dataToParse)
} }