diff --git a/.github/ISSUE_TEMPLATE/bug.md b/.github/ISSUE_TEMPLATE/bug.md index 4b3b934..18862a5 100644 --- a/.github/ISSUE_TEMPLATE/bug.md +++ b/.github/ISSUE_TEMPLATE/bug.md @@ -1,18 +1,14 @@ --- name: Bug report 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 - is strongly recommended. -type: Bug + file a bug report. Remember to include relevant logs. +labels: bug --- - + - -* [ ] 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: `` +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. +--> diff --git a/.github/ISSUE_TEMPLATE/enhancement.md b/.github/ISSUE_TEMPLATE/enhancement.md index a04fe58..264e67f 100644 --- a/.github/ISSUE_TEMPLATE/enhancement.md +++ b/.github/ISSUE_TEMPLATE/enhancement.md @@ -1,6 +1,6 @@ --- name: Enhancement request about: Submit a feature request or other suggestion -type: Feature +labels: enhancement --- diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 343443b..e881884 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -11,14 +11,14 @@ jobs: strategy: fail-fast: false matrix: - go-version: ["1.25", "1.26"] - name: Lint ${{ matrix.go-version == '1.26' && '(latest)' || '(old)' }} + go-version: ["1.23", "1.24"] + name: Lint ${{ matrix.go-version == '1.24' && '(latest)' || '(old)' }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v4 - name: Set up Go - uses: actions/setup-go@v6 + uses: actions/setup-go@v5 with: go-version: ${{ matrix.go-version }} cache: true diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index cae8a4d..0ae50e4 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -17,7 +17,7 @@ jobs: lock-stale: runs-on: ubuntu-latest steps: - - uses: dessant/lock-threads@v6 + - uses: dessant/lock-threads@v5 id: lock with: issue-inactive-days: 90 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1bfb536..b8d2d93 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v6.0.0 + rev: v5.0.0 hooks: - id: trailing-whitespace exclude_types: [markdown] @@ -9,7 +9,7 @@ repos: - id: check-added-large-files - repo: https://github.com/tekwizely/pre-commit-golang - rev: v1.0.0-rc.4 + rev: v1.0.0-rc.1 hooks: - id: go-imports-repo args: diff --git a/CHANGELOG.md b/CHANGELOG.md index 892be2e..9b7f100 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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-` instead of just ``. -* Updated Docker image to Alpine 3.22. -* Fixed network errors on first connect not triggering automatic reconnect. -* Fixed animated sticker zips not being extracted when using direct media. - -# v0.12.1 (2025-05-16) - -* Added prefix to identify forwarded messages on WhatsApp. -* Updated mime type of unconverted animated stickers to `video/lottie+json` - which is now registered with IANA. -* Changed relogin command to not require entering phone number twice when using - phone code login. -* Fixed outgoing messages being rejected if they replied to a fake message - generated by the bridge. -* Fixed backfilling messages in existing portals after relogining. - # v0.12.0 (2025-04-16) * Migrated Signal session store to use new `@lid` identifiers to support future chats that don't expose phone numbers. - * **N.B.** Old registration files may have `[0-9]+` in the `users` regex. You - must change it to `.+`, as the new `lid` identifiers are bridged as - `lid-` instead of just ``. * Added fallbacks for various business message types. * Added support for bridging invites, kicks and leaves in groups. * Re-added `invite-link`, `join` and `sync` commands for groups. diff --git a/Dockerfile b/Dockerfile index 4efc9d5..34fba37 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 @@ -6,12 +6,12 @@ COPY . /build WORKDIR /build RUN ./build.sh -FROM alpine:3.23 +FROM alpine:3.21 ENV UID=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/docker-run.sh /docker-run.sh diff --git a/Dockerfile.ci b/Dockerfile.ci index cb3be51..e8e79e1 100644 --- a/Dockerfile.ci +++ b/Dockerfile.ci @@ -1,11 +1,9 @@ -ARG DOCKER_HUB="docker.io" - -FROM ${DOCKER_HUB}/alpine:3.23 +FROM alpine:3.21 ENV UID=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 COPY $EXECUTABLE /usr/bin/mautrix-whatsapp diff --git a/ROADMAP.md b/ROADMAP.md index 980606d..91f4371 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -14,10 +14,10 @@ * [x] Typing notifications * [x] Read receipts * [ ] Power level - * [x] Membership actions - * [x] Invite - * [x] Leave - * [x] Kick + * [ ] Membership actions + * [ ] Invite + * [ ] Leave + * [ ] Kick * [ ] Room metadata changes * [ ] Name * [ ] Avatar diff --git a/build.sh b/build.sh index 2442135..0676fdd 100755 --- a/build.sh +++ b/build.sh @@ -1,2 +1,4 @@ #!/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 diff --git a/cmd/mautrix-whatsapp/legacymigrate.go b/cmd/mautrix-whatsapp/legacymigrate.go new file mode 100644 index 0000000..34bcf64 --- /dev/null +++ b/cmd/mautrix-whatsapp/legacymigrate.go @@ -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"}) +} diff --git a/cmd/mautrix-whatsapp/legacymigrate.sql b/cmd/mautrix-whatsapp/legacymigrate.sql new file mode 100644 index 0000000..013519f --- /dev/null +++ b/cmd/mautrix-whatsapp/legacymigrate.sql @@ -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; diff --git a/cmd/mautrix-whatsapp/legacyprovision.go b/cmd/mautrix-whatsapp/legacyprovision.go index f0527b2..30e43c5 100644 --- a/cmd/mautrix-whatsapp/legacyprovision.go +++ b/cmd/mautrix-whatsapp/legacyprovision.go @@ -1,23 +1,48 @@ package main import ( + "context" + "errors" "net/http" + "regexp" "strings" + "github.com/gorilla/mux" + "github.com/gorilla/websocket" "github.com/rs/zerolog/hlog" "go.mau.fi/util/exhttp" - "go.mau.fi/whatsmeow" - "go.mau.fi/whatsmeow/appstate" "go.mau.fi/whatsmeow/types" "maunium.net/go/mautrix" "maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2/matrix" + "maunium.net/go/mautrix/bridgev2/status" "maunium.net/go/mautrix/id" "go.mau.fi/mautrix-whatsapp/pkg/connector" "go.mau.fi/mautrix-whatsapp/pkg/waid" ) +var upgrader = websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { + return true + }, + Subprotocols: []string{"net.maunium.whatsapp.login"}, +} + +func legacyProvAuth(r *http.Request) string { + if !strings.HasSuffix(r.URL.Path, "/v1/login") { + return "" + } + authParts := strings.Split(r.Header.Get("Sec-WebSocket-Protocol"), ",") + for _, part := range authParts { + part = strings.TrimSpace(part) + if strings.HasPrefix(part, "net.maunium.whatsapp.auth-") { + return strings.TrimPrefix(part, "net.maunium.whatsapp.auth-") + } + } + return "" +} + type OtherUserInfo struct { MXID id.UserID `json:"mxid"` JID types.JID `json:"jid"` @@ -38,12 +63,180 @@ type Error struct { ErrCode string `json:"errcode"` } +type Response struct { + Success bool `json:"success"` + Status string `json:"status"` +} + +func respondWebsocketWithError(conn *websocket.Conn, err error, message string) { + var mautrixRespErr mautrix.RespError + var bv2RespErr bridgev2.RespError + if errors.As(err, &bv2RespErr) { + mautrixRespErr = mautrix.RespError(bv2RespErr) + } else if !errors.As(err, &mautrixRespErr) { + mautrixRespErr = mautrix.RespError{ + Err: message, + ErrCode: "M_UNKNOWN", + StatusCode: http.StatusInternalServerError, + } + } + _ = conn.WriteJSON(&mautrixRespErr) +} + +var notNumbers = regexp.MustCompile("[^0-9]") + +func legacyProvLogin(w http.ResponseWriter, r *http.Request) { + log := hlog.FromRequest(r) + + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + log.Err(err).Msg("Failed to upgrade connection to websocket") + return + } + defer func() { + err := conn.Close() + if err != nil { + log.Debug().Err(err).Msg("Error closing websocket") + } + }() + + go func() { + // Read everything so SetCloseHandler() works + for { + _, _, err = conn.ReadMessage() + if err != nil { + break + } + } + }() + ctx, cancel := context.WithCancel(context.Background()) + conn.SetCloseHandler(func(code int, text string) error { + log.Debug().Int("close_code", code).Msg("Login websocket closed, cancelling login") + cancel() + return nil + }) + + user := m.Matrix.Provisioning.GetUser(r) + loginFlowID := connector.LoginFlowIDQR + phoneNum := r.URL.Query().Get("phone_number") + if phoneNum != "" { + phoneNum = notNumbers.ReplaceAllString(phoneNum, "") + if len(phoneNum) < 7 || strings.HasPrefix(phoneNum, "0") { + errorMsg := "Invalid phone number" + if len(phoneNum) > 6 { + errorMsg = "Please enter the phone number in international format" + } + _ = conn.WriteJSON(Error{ + Error: errorMsg, + ErrCode: "invalid phone number", + }) + return + } + loginFlowID = connector.LoginFlowIDPhone + } + login, err := c.CreateLogin(ctx, user, loginFlowID) + if err != nil { + log.Err(err).Msg("Failed to create login") + respondWebsocketWithError(conn, err, "Failed to create login") + return + } + waLogin := login.(*connector.WALogin) + waLogin.Timezone = r.URL.Query().Get("tz") + step, err := waLogin.Start(ctx) + if err != nil { + log.Err(err).Msg("Failed to start login") + respondWebsocketWithError(conn, err, "Failed to start login") + return + } + if phoneNum != "" { + if step.StepID != connector.LoginStepIDPhoneNumber { + respondWebsocketWithError(conn, errors.New("unexpected step"), "Unexpected step while starting phone number login") + waLogin.Cancel() + return + } + step, err = waLogin.SubmitUserInput(ctx, map[string]string{"phone_number": phoneNum}) + if err != nil { + log.Err(err).Msg("Failed to submit phone number") + respondWebsocketWithError(conn, err, "Failed to start phone code login") + return + } else if step.StepID != connector.LoginStepIDCode { + respondWebsocketWithError(conn, errors.New("unexpected step"), "Unexpected step after submitting phone number") + waLogin.Cancel() + return + } + _ = conn.WriteJSON(map[string]any{ + "pairing_code": step.DisplayAndWaitParams.Data, + "timeout": 180, + }) + } else if step.StepID != connector.LoginStepIDQR { + respondWebsocketWithError(conn, errors.New("unexpected step"), "Unexpected step while starting QR login") + waLogin.Cancel() + return + } else { + _ = conn.WriteJSON(map[string]any{ + "code": step.DisplayAndWaitParams.Data, + "timeout": 60, + }) + } + for { + step, err = waLogin.Wait(ctx) + if err != nil { + log.Err(err).Msg("Failed to wait for login") + respondWebsocketWithError(conn, err, "Failed to wait for login") + } else if step.StepID == connector.LoginStepIDQR { + _ = conn.WriteJSON(map[string]any{ + "code": step.DisplayAndWaitParams.Data, + "timeout": 20, + }) + continue + } else if step.StepID != connector.LoginStepIDComplete { + respondWebsocketWithError(conn, errors.New("unexpected step"), "Unexpected step while waiting for login") + waLogin.Cancel() + } else { + // TODO delete old logins + _ = conn.WriteJSON(map[string]any{ + "success": true, + "jid": waid.ParseUserLoginID(step.CompleteParams.UserLoginID, step.CompleteParams.UserLogin.Metadata.(*waid.UserLoginMetadata).WADeviceID).String(), + "platform": step.CompleteParams.UserLogin.Client.(*connector.WhatsAppClient).Device.Platform, + "phone": step.CompleteParams.UserLogin.RemoteProfile.Phone, + }) + go handleLoginComplete(context.WithoutCancel(ctx), user, step.CompleteParams.UserLogin) + } + break + } +} +func handleLoginComplete(ctx context.Context, user *bridgev2.User, newLogin *bridgev2.UserLogin) { + allLogins := user.GetUserLogins() + for _, login := range allLogins { + if login.ID != newLogin.ID { + login.Delete(ctx, status.BridgeState{StateEvent: status.StateLoggedOut, Reason: "LOGIN_OVERRIDDEN"}, bridgev2.DeleteOpts{}) + } + } +} + +func legacyProvLogout(w http.ResponseWriter, r *http.Request) { + user := m.Matrix.Provisioning.GetUser(r) + allLogins := user.GetUserLogins() + if len(allLogins) == 0 { + exhttp.WriteJSONResponse(w, http.StatusOK, Error{ + Error: "You're not logged in", + ErrCode: "not logged in", + }) + return + } + for _, login := range allLogins { + // Intentionally don't delete the user login, only logout remote + login.Client.(*connector.WhatsAppClient).LogoutRemote(r.Context()) + } + exhttp.WriteJSONResponse(w, http.StatusOK, Response{true, "Logged out successfully"}) +} + func legacyProvContacts(w http.ResponseWriter, r *http.Request) { userLogin := m.Matrix.Provisioning.GetLoginForRequest(w, r) if userLogin == nil { return } - if contacts, err := userLogin.Client.(*connector.WhatsAppClient).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") exhttp.WriteJSONResponse(w, http.StatusInternalServerError, Error{ 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) { - number := r.PathValue("number") + number := mux.Vars(r)["number"] userLogin := m.Matrix.Provisioning.GetLoginForRequest(w, r) if userLogin == nil { 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) - } -} diff --git a/cmd/mautrix-whatsapp/main.go b/cmd/mautrix-whatsapp/main.go index b97bc7f..4867132 100644 --- a/cmd/mautrix-whatsapp/main.go +++ b/cmd/mautrix-whatsapp/main.go @@ -1,9 +1,13 @@ package main import ( + "net/http" + + "maunium.net/go/mautrix/bridgev2/bridgeconfig" "maunium.net/go/mautrix/bridgev2/matrix/mxmain" "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. @@ -14,23 +18,37 @@ var ( BuildTime = "unknown" ) +var c = &connector.WhatsAppConnector{} var m = mxmain.BridgeMain{ Name: "mautrix-whatsapp", URL: "https://github.com/mautrix/whatsapp", Description: "A Matrix-WhatsApp puppeting bridge.", - Version: "26.04", - SemCalVer: true, - Connector: &connector.WhatsAppConnector{}, + Version: "0.12.0", + Connector: c, } func main() { + bridgeconfig.HackyMigrateLegacyNetworkConfig = migrateLegacyConfig + m.PostInit = func() { + m.CheckLegacyDB( + 57, + "v0.8.6", + "v0.11.0", + m.LegacyMigrateWithAnotherUpgrader( + legacyMigrateRenameTables, legacyMigrateCopyData, 17, + upgrades.Table, "whatsapp_version", 3, + ), + true, + ) + } m.PostStart = func() { if m.Matrix.Provisioning != nil { - m.Matrix.Provisioning.Router.HandleFunc("GET /v1/contacts", legacyProvContacts) - m.Matrix.Provisioning.Router.HandleFunc("GET /v1/resolve_identifier/{number}", legacyProvResolveIdentifier) - m.Matrix.Provisioning.Router.HandleFunc("POST /v1/pm/{number}", legacyProvResolveIdentifier) - m.Matrix.Provisioning.Router.HandleFunc("POST /v1/debug/appstate/{patch}", provAppStateDebug) - m.Matrix.Provisioning.Router.HandleFunc("POST /v1/debug/recover-appstate/{patch}", provRecoverAppStateDebug) + m.Matrix.Provisioning.Router.HandleFunc("/v1/login", legacyProvLogin).Methods(http.MethodGet) + m.Matrix.Provisioning.Router.HandleFunc("/v1/logout", legacyProvLogout).Methods(http.MethodPost) + m.Matrix.Provisioning.Router.HandleFunc("/v1/contacts", legacyProvContacts).Methods(http.MethodGet) + m.Matrix.Provisioning.Router.HandleFunc("/v1/resolve_identifier/{number}", legacyProvResolveIdentifier).Methods(http.MethodGet) + m.Matrix.Provisioning.Router.HandleFunc("/v1/pm/{number}", legacyProvResolveIdentifier).Methods(http.MethodPost) + m.Matrix.Provisioning.GetAuthFromRequest = legacyProvAuth } } m.InitVersion(Tag, Commit, BuildTime) diff --git a/cmd/mautrix-whatsapp/plugin.go b/cmd/mautrix-whatsapp/plugin.go deleted file mode 100644 index a1c9ca0..0000000 --- a/cmd/mautrix-whatsapp/plugin.go +++ /dev/null @@ -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) -} diff --git a/go.mod b/go.mod index 9cd6c8f..071d58d 100644 --- a/go.mod +++ b/go.mod @@ -1,53 +1,48 @@ module go.mau.fi/mautrix-whatsapp -go 1.25.0 +go 1.23.0 -toolchain go1.26.2 - -tool go.mau.fi/util/cmd/maubuild +toolchain go1.24.2 require ( - github.com/lib/pq v1.12.3 - github.com/rs/zerolog v1.35.1 - github.com/tidwall/gjson v1.18.0 - go.mau.fi/util v0.9.9-0.20260511124621-9241e81bdf25 + github.com/gorilla/mux v1.8.0 + github.com/gorilla/websocket v1.5.0 + github.com/lib/pq v1.10.9 + 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/whatsmeow v0.0.0-20260513140310-c551a4055c0f - golang.org/x/image v0.39.0 - golang.org/x/net v0.53.0 - golang.org/x/sync v0.20.0 - google.golang.org/protobuf v1.36.11 + go.mau.fi/whatsmeow v0.0.0-20250501130609-4c93ee4e6efa + golang.org/x/image v0.26.0 + golang.org/x/net v0.39.0 + golang.org/x/sync v0.13.0 + google.golang.org/protobuf v1.36.6 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 ( - filippo.io/edwards25519 v1.2.0 // indirect - github.com/beeper/argo-go v1.1.2 // 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 + filippo.io/edwards25519 v1.1.0 // indirect + github.com/coreos/go-systemd/v22 v22.5.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/kr/pretty v0.3.1 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-sqlite3 v1.14.44 // indirect - github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 // indirect + github.com/mattn/go-sqlite3 v1.14.27 // indirect + github.com/petermattis/goid v0.0.0-20250319124200-ccd6737f222a // indirect github.com/rogpeppe/go-internal v1.10.0 // indirect github.com/rs/xid v1.6.0 // indirect github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect - github.com/tidwall/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/sjson v1.2.5 // indirect - github.com/vektah/gqlparser/v2 v2.5.27 // indirect - github.com/yuin/goldmark v1.8.2 // indirect - go.mau.fi/libsignal v0.2.1 // indirect - go.mau.fi/zeroconfig v0.2.0 // indirect - golang.org/x/crypto v0.50.0 // indirect - golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect - golang.org/x/mod v0.35.0 // indirect - golang.org/x/sys v0.43.0 // indirect - golang.org/x/text v0.36.0 // indirect + github.com/yuin/goldmark v1.7.10 // indirect + go.mau.fi/libsignal v0.1.2 // indirect + go.mau.fi/zeroconfig v0.1.3 // indirect + golang.org/x/crypto v0.37.0 // indirect + golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect + golang.org/x/sys v0.32.0 // indirect + golang.org/x/text v0.24.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect maunium.net/go/mauflag v1.0.0 // indirect diff --git a/go.sum b/go.sum index be22fdf..cc37ba5 100644 --- a/go.sum +++ b/go.sum @@ -1,26 +1,21 @@ -filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= -filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= -github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM= -github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU= -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/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 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/elliotchance/orderedmap/v3 v3.1.0 h1:j4DJ5ObEmMBt/lcwIecKcoRxIQUEnw0L804lXYDt/pg= -github.com/elliotchance/orderedmap/v3 v3.1.0/go.mod h1:G+Hc2RwaZvJMcS4JpGCOyViCnGeKf0bTYCGTO4uhjSo= -github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= -github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/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.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 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.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 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.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 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-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/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8= -github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= -github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 h1:WDsQxOJDy0N1VRAjXLpi8sCEZRSGarLWQevDxpTBRrM= -github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= +github.com/mattn/go-sqlite3 v1.14.27 h1:drZCnuvf37yPfs95E5jd9s3XhdVWLal+6BOK6qrv6IU= +github.com/mattn/go-sqlite3 v1.14.27/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/petermattis/goid v0.0.0-20250319124200-ccd6737f222a h1:S+AGcmAESQ0pXCUNnRH7V+bOUIgkSX5qVt2cNKCrm0Q= +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/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= @@ -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/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= 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.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw= -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/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= +github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= 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/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +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.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= 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.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.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= 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/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= -github.com/vektah/gqlparser/v2 v2.5.27 h1:RHPD3JOplpk5mP5JGX8RKZkt2/Vwj/PZv0HxTdwFp0s= -github.com/vektah/gqlparser/v2 v2.5.27/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo= -github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE= -github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= -go.mau.fi/libsignal v0.2.1 h1:vRZG4EzTn70XY6Oh/pVKrQGuMHBkAWlGRC22/85m9L0= -go.mau.fi/libsignal v0.2.1/go.mod h1:iVvjrHyfQqWajOUaMEsIfo3IqgVMrhWcPiiEzk7NgoU= -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= +github.com/yuin/goldmark v1.7.10 h1:S+LrtBjRmqMac2UdtB6yyCEJm+UILZ2fefI4p7o0QpI= +github.com/yuin/goldmark v1.7.10/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +go.mau.fi/libsignal v0.1.2 h1:Vs16DXWxSKyzVtI+EEXLCSy5pVWzzCzp/2eqFGvLyP0= +go.mau.fi/libsignal v0.1.2/go.mod h1:JpnLSSJptn/s1sv7I56uEMywvz8x4YzxeF5OzdPb6PE= +go.mau.fi/util v0.8.7-0.20250427215252-d2d18a7e463c h1:qfJyMZq1pPyuXKoVWwHs6OmR9CzO3pHFRPYT/QpaaaA= +go.mau.fi/util v0.8.7-0.20250427215252-d2d18a7e463c/go.mod h1:uNB3UTXFbkpp7xL1M/WvQks90B/L4gvbLpbS0603KOE= go.mau.fi/webp v0.2.0 h1:QVMenHw7JDb4vall5sV75JNBQj9Hw4u8AKbi1QetHvg= 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-20260513140310-c551a4055c0f/go.mod h1:ijfkzOXauA/Vz/htXEMfOAJSUgglribW5oQeYC9tSSg= -go.mau.fi/zeroconfig v0.2.0 h1:e/OGEERqVRRKlgaro7E6bh8xXiKFSXB3eNNIud7FUjU= -go.mau.fi/zeroconfig v0.2.0/go.mod h1:J0Vn0prHNOm493oZoQ84kq83ZaNCYZnq+noI1b1eN8w= -golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= -golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= -golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= -golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= -golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww= -golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA= -golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= -golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= -golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= -golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +go.mau.fi/whatsmeow v0.0.0-20250501130609-4c93ee4e6efa h1:+bQKfMtnhX2jVoCSaneH4Ctk51IVT1K2gvjyqfFjVW0= +go.mau.fi/whatsmeow v0.0.0-20250501130609-4c93ee4e6efa/go.mod h1:NlPtoLdpX3RnltqCTCZQ6kIUfprqLirtSK1gHvwoNx0= +go.mau.fi/zeroconfig v0.1.3 h1:As9wYDKmktjmNZW5i1vn8zvJlmGKHeVxHVIBMXsm4kM= +go.mau.fi/zeroconfig v0.1.3/go.mod h1:NcSJkf180JT+1IId76PcMuLTNa1CzsFFZ0nBygIQM70= +golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= +golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= +golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= +golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= +golang.org/x/image v0.26.0 h1:4XjIFEZWQmCZi6Wv8BoxsDhRU3RVnLX04dToTDAEPlY= +golang.org/x/image v0.26.0/go.mod h1:lcxbMFAovzpnJxzXS3nyL83K27tmqtKzIJpctK8YO5c= +golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= +golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= +golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= +golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/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.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= -golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= -google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= -google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= +golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= +golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= +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 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 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= 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/mautrix v0.27.1-0.20260513120123-5fba7e3afae4 h1:zNC9eVAhw8FhKpM3AxNAh/iy75UEYX91uJUvqqAYlvo= -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 h1:cia1//Az4ApDJVg15RxVX6j5LRs7ap3lGbD3IltEGyQ= +maunium.net/go/mautrix v0.23.4-0.20250505203826-970ea996a2f4/go.mod h1:pT4G5RZQ+nLfKzsmeDa4NhHghOVTrasLLwY9tZ2mO08= diff --git a/pkg/connector/backfill.go b/pkg/connector/backfill.go index b4ef181..56cb847 100644 --- a/pkg/connector/backfill.go +++ b/pkg/connector/backfill.go @@ -8,7 +8,6 @@ import ( "strconv" "strings" "sync" - "sync/atomic" "time" "github.com/rs/zerolog" @@ -29,149 +28,35 @@ import ( var _ bridgev2.BackfillingNetworkAPI = (*WhatsAppClient)(nil) -func (wa *WhatsAppClient) historySyncLoop(ctx context.Context) { - dispatchTimer := time.NewTimer(wa.Main.Config.HistorySync.DispatchWait) +const historySyncDispatchWait = 30 * time.Second + +func (wa *WhatsAppClient) historySyncLoop(ctx context.Context) { + dispatchTimer := time.NewTimer(historySyncDispatchWait) - var timerPending atomic.Bool if !wa.isNewLogin && wa.UserLogin.Metadata.(*waid.UserLoginMetadata).HistorySyncPortalsNeedCreating { dispatchTimer.Reset(5 * time.Second) - timerPending.Store(true) } else { dispatchTimer.Stop() } - if wa.Client.ManualHistorySyncDownload { - // 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 { - select { - case <-dispatchTimer.C: - timerPending.Store(false) - wa.createPortalsFromHistorySync(ctx) - case <-ctx.Done(): - wa.UserLogin.Log.Debug().Msg("Stopping portal creation history sync loop") - return - } - } - }() + wa.UserLogin.Log.Debug().Msg("Starting history sync loop") for { - var resetTimer bool select { - case <-wa.historySyncWakeup: + case evt := <-wa.historySyncs: 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: - } - } + wa.handleWAHistorySync(ctx, evt) + dispatchTimer.Reset(historySyncDispatchWait) + case <-dispatchTimer.C: + wa.createPortalsFromHistorySync(ctx) case <-ctx.Done(): - wa.UserLogin.Log.Debug().Msg("Stopping main history sync loop") + wa.UserLogin.Log.Debug().Msg("Stopping 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) { - 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 { +func (wa *WhatsAppClient) handleWAHistorySync(ctx context.Context, evt *waHistorySync.HistorySync) { if evt == nil || evt.SyncType == nil { - return nil + return } log := wa.UserLogin.Log.With(). Str("action", "store history sync"). @@ -183,12 +68,7 @@ func (wa *WhatsAppClient) handleWAHistorySync( if evt.GetGlobalSettings() != nil { log.Debug().Interface("global_settings", evt.GetGlobalSettings()).Msg("Got global settings in history sync") } - if evt.GetSyncType() == waHistorySync.HistorySync_INITIAL_STATUS_V3 || - evt.GetSyncType() == waHistorySync.HistorySync_PUSH_NAME || - evt.GetSyncType() == waHistorySync.HistorySync_NON_BLOCKING_DATA { - if evt.GetSyncType() == waHistorySync.HistorySync_PUSH_NAME { - wa.pushNamesSynced.Set() - } + if evt.GetSyncType() == waHistorySync.HistorySync_INITIAL_STATUS_V3 || evt.GetSyncType() == waHistorySync.HistorySync_PUSH_NAME || evt.GetSyncType() == waHistorySync.HistorySync_NON_BLOCKING_DATA { log.Debug(). Int("conversation_count", len(evt.GetConversations())). Int("pushname_count", len(evt.GetPushnames())). @@ -196,57 +76,35 @@ func (wa *WhatsAppClient) handleWAHistorySync( Int("recent_sticker_count", len(evt.GetRecentStickers())). Int("past_participant_count", len(evt.GetPastParticipants())). Msg("Ignoring history sync") - return nil + return } log.Info(). Int("conversation_count", len(evt.GetConversations())). 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") - start := time.Now() successfullySavedTotal := 0 failedToSaveTotal := 0 totalMessageCount := 0 for _, conv := range evt.GetConversations() { - log := log.With(). - Int("msg_count", len(conv.GetMessages())). - Logger() jid, err := types.ParseJID(conv.GetID()) if err != nil { totalMessageCount += len(conv.GetMessages()) log.Warn().Err(err). Str("chat_jid", conv.GetID()). + Int("msg_count", len(conv.GetMessages())). Msg("Failed to parse chat JID in history sync") continue } else if jid.Server == types.BroadcastServer { log.Debug().Stringer("chat_jid", jid).Msg("Skipping broadcast list in history sync") continue - } else { - totalMessageCount += len(conv.GetMessages()) } - if jid.Server == types.HiddenUserServer { - pn, err := wa.GetStore().LIDs.GetPNForLID(ctx, jid) - if err != nil { - 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) - }) + totalMessageCount += len(conv.GetMessages()) + log := log.With(). + Stringer("chat_jid", jid). + Int("msg_count", len(conv.GetMessages())). + Logger() - var minTime, maxTime, firstItemTime, lastItemTime time.Time + var minTime, maxTime time.Time var minTimeIndex, maxTimeIndex int ignoredTypes := 0 @@ -262,10 +120,6 @@ func (wa *WhatsAppClient) handleWAHistorySync( Msg("Dropping historical message due to parse error") continue } - if firstItemTime.IsZero() { - firstItemTime = msgEvt.Info.Timestamp - } - lastItemTime = msgEvt.Info.Timestamp if minTime.IsZero() || msgEvt.Info.Timestamp.Before(minTime) { minTime = msgEvt.Info.Timestamp minTimeIndex = i @@ -298,9 +152,6 @@ func (wa *WhatsAppClient) handleWAHistorySync( Int("lowest_time_index", minTimeIndex). Time("highest_time", maxTime). 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(). Uint32("ephemeral_expiration", conv.GetEphemeralExpiration()). Int64("ephemeral_setting_timestamp", conv.GetEphemeralSettingTimestamp()). @@ -309,47 +160,30 @@ func (wa *WhatsAppClient) handleWAHistorySync( Bool("archived", conv.GetArchived()). Uint32("pinned", conv.GetPinned()). Uint64("mute_end", conv.GetMuteEndTime()). - Uint32("unread_count", conv.GetUnreadCount()). - Bool("end_of_history", conv.GetEndOfHistoryTransfer()). - Stringer("end_of_history_type", conv.GetEndOfHistoryTransferType()), + Uint32("unread_count", conv.GetUnreadCount()), ). Msg("Collected messages to save from history sync conversation") if len(messages) > 0 { err = wa.Main.DB.Conversation.Put(ctx, wadb.NewConversation(wa.UserLogin.ID, jid, conv, maxTime)) 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") continue } err = wa.Main.DB.Message.Put(ctx, wa.UserLogin.ID, jid, messages) 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") failedToSaveTotal += len(messages) } else { 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(). Int("total_saved_count", successfullySavedTotal). Int("total_failed_count", failedToSaveTotal). Int("total_message_count", totalMessageCount). - Dur("duration", time.Since(start)). Msg("Finished storing history sync") - return nil } func (wa *WhatsAppClient) createPortalsFromHistorySync(ctx context.Context) { @@ -358,17 +192,13 @@ func (wa *WhatsAppClient) createPortalsFromHistorySync(ctx context.Context) { Logger() ctx = log.WithContext(ctx) limit := wa.Main.Config.HistorySync.MaxInitialConversations - loginTS := wa.UserLogin.Metadata.(*waid.UserLoginMetadata).LoggedInAt - conversations, err := wa.Main.DB.Conversation.GetRecent(ctx, wa.UserLogin.ID, limit, loginTS) + log.Info().Int("limit", limit).Msg("Creating portals from history sync") + conversations, err := wa.Main.DB.Conversation.GetRecent(ctx, wa.UserLogin.ID, limit) if err != nil { log.Err(err).Msg("Failed to get recent conversations from database") return } - log.Info(). - Int("limit", limit). - Int("conversation_count", len(conversations)). - Int64("login_timestamp", loginTS.Unix()). - Msg("Creating portals from history sync") + log.Info().Int("conversation_count", len(conversations)).Msg("Creating portals from history sync") rateLimitErrors := 0 var wg sync.WaitGroup wg.Add(len(conversations)) @@ -384,19 +214,14 @@ func (wa *WhatsAppClient) createPortalsFromHistorySync(ctx context.Context) { if conv.ChatJID == types.StatusBroadcastJID && !wa.Main.Config.EnableStatusBroadcast { wg.Done() 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 wg.Done() continue } // TODO can the chat info fetch be avoided entirely? - select { - 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 - } - wrappedInfo, err := wa.getChatInfo(ctx, conv.ChatJID, conv, true) + time.Sleep(time.Duration(rateLimitErrors) * time.Second) + wrappedInfo, err := wa.getChatInfo(ctx, conv.ChatJID, conv) if errors.Is(err, whatsmeow.ErrNotInGroup) { log.Debug().Stringer("chat_jid", conv.ChatJID). 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). Int("error_count", rateLimitErrors). Msg("Ratelimit error getting chat info, retrying after sleep") - select { - 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 - } + time.Sleep(time.Duration(rateLimitErrors) * time.Minute) continue } else if err != nil { log.Err(err).Stringer("chat_jid", conv.ChatJID).Msg("Failed to get chat info") wg.Done() continue } - res := wa.UserLogin.QueueRemoteEvent(&simplevent.ChatResync{ + wa.Main.Bridge.QueueRemoteEvent(wa.UserLogin, &simplevent.ChatResync{ EventMeta: simplevent.EventMeta{ Type: bridgev2.RemoteEventChatResync, LogContext: func(c zerolog.Context) zerolog.Context { @@ -439,7 +259,7 @@ func (wa *WhatsAppClient) createPortalsFromHistorySync(ctx context.Context) { PortalKey: wa.makeWAPortalKey(conv.ChatJID), CreatePortal: true, 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 { 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, 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") go func() { @@ -473,67 +289,38 @@ func (wa *WhatsAppClient) FetchMessages(ctx context.Context, params bridgev2.Fet } var markRead bool 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.AnchorMessage != nil { 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 } - } else { - if params.AnchorMessage != nil { - endTime = ptr.Ptr(params.AnchorMessage.Timestamp) + } else if params.Cursor != "" { + endTimeUnix, err := strconv.ParseInt(string(params.Cursor), 10, 64) + if err != nil { + return nil, fmt.Errorf("failed to parse cursor: %w", err) } - if params.Cursor != "" { - endTimeUnix, err := strconv.ParseInt(string(params.Cursor), 10, 64) - if err != nil { - return nil, fmt.Errorf("failed to parse cursor: %w", err) - } - cursorTime := time.Unix(endTimeUnix, 0) - if endTime == nil || cursorTime.Before(*endTime) { - endTime = &cursorTime - } - } - } - 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 + endTime = ptr.Ptr(time.Unix(endTimeUnix, 0)) + } else if params.AnchorMessage != nil { + endTime = ptr.Ptr(params.AnchorMessage.Timestamp) } messages, err := wa.Main.DB.Message.GetBetween(ctx, wa.UserLogin.ID, portalJID, startTime, endTime, params.Count+1) if err != nil { 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) { - 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) - } + } else if len(messages) == 0 { return &bridgev2.FetchMessagesResponse{ HasMore: false, Forward: params.Forward, }, nil } + hasMore := false + oldestTS := messages[len(messages)-1].GetMessageTimestamp() + newestTS := messages[0].GetMessageTimestamp() if len(messages) > params.Count { - oldestTS := messages[len(messages)-1].GetMessageTimestamp() hasMore = true // 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. @@ -544,78 +331,17 @@ func (wa *WhatsAppClient) FetchMessages(ctx context.Context, params bridgev2.Fet } } } - resp, err := wa.convertHistorySyncMessages(ctx, params.Portal, portalJID, messages, true) - 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)) + convertedMessages := make([]*bridgev2.BackfillMessage, len(messages)) var mediaRequests []*wadb.MediaRequest for i, msg := range messages { evt, err := wa.Client.ParseWebMessage(portalJID, msg) if err != nil { - if explodeOnError { - // 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) - } - zerolog.Ctx(ctx).Warn().Err(err). - 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 - } + // 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) } + var mediaReq *wadb.MediaRequest isViewOnce := evt.IsViewOnce || evt.IsViewOnceV2 || evt.IsViewOnceV2Extension - converted, mediaReq := wa.convertHistorySyncMessage( - ctx, portal, &evt.Info, evt.Message, evt.RawMessage, isViewOnce, msg.Reactions, - ) - convertedMessages = append(convertedMessages, converted) + convertedMessages[i], mediaReq = wa.convertHistorySyncMessage(ctx, params.Portal, &evt.Info, evt.Message, isViewOnce, msg.Reactions) if mediaReq != nil { mediaRequests = append(mediaRequests, mediaReq) } @@ -624,10 +350,24 @@ func (wa *WhatsAppClient) convertHistorySyncMessages( return &bridgev2.FetchMessagesResponse{ Messages: convertedMessages, Cursor: networkid.PaginationCursor(strconv.FormatUint(oldestTS, 10)), + HasMore: hasMore, + Forward: endTime == nil, + MarkRead: markRead, + // TODO set remaining or total count CompleteCallback: func() { // TODO this only deletes after backfilling. If there's no need for backfill after a relogin, // 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 { go func(ctx context.Context) { for _, req := range mediaRequests { @@ -645,115 +385,22 @@ func (wa *WhatsAppClient) convertHistorySyncMessages( }, 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( - 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) { - // 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 intent := wa.Main.Bridge.Bot wrapped := &bridgev2.BackfillMessage{ - ConvertedMessage: wa.Main.MsgConv.ToMatrix(ctx, portal, wa.Client, intent, msg, rawMsg, info, isViewOnce, true, nil), - Sender: wa.makeEventSender(ctx, info.Sender), + ConvertedMessage: wa.Main.MsgConv.ToMatrix(ctx, portal, wa.Client, intent, msg, info, isViewOnce, nil), + Sender: wa.makeEventSender(info.Sender), ID: waid.MakeMessageID(info.Chat, info.Sender, info.ID), TxnID: networkid.TransactionID(waid.MakeMessageID(info.Chat, info.Sender, info.ID)), Timestamp: info.Timestamp, 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) - for _, reaction := range reactions { + for i, reaction := range reactions { var sender types.JID if reaction.GetKey().GetFromMe() { sender = wa.JID @@ -765,12 +412,12 @@ func (wa *WhatsAppClient) convertHistorySyncMessage( if sender.IsEmpty() { continue } - wrapped.Reactions = append(wrapped.Reactions, &bridgev2.BackfillReaction{ + wrapped.Reactions[i] = &bridgev2.BackfillReaction{ TargetPart: ptr.Ptr(networkid.PartID("")), Timestamp: time.UnixMilli(reaction.GetSenderTimestampMS()), - Sender: wa.makeEventSender(ctx, sender), + Sender: wa.makeEventSender(sender), Emoji: reaction.GetText(), - }) + } } return wrapped, mediaReq } diff --git a/pkg/connector/capabilities.go b/pkg/connector/capabilities.go index 3e6f658..1af3a9a 100644 --- a/pkg/connector/capabilities.go +++ b/pkg/connector/capabilities.go @@ -8,7 +8,6 @@ import ( "go.mau.fi/util/jsontime" "go.mau.fi/util/ptr" "maunium.net/go/mautrix/bridgev2" - "maunium.net/go/mautrix/bridgev2/database" "maunium.net/go/mautrix/event" "go.mau.fi/mautrix-whatsapp/pkg/waid" @@ -17,34 +16,6 @@ import ( var WhatsAppGeneralCaps = &bridgev2.NetworkGeneralCapabilities{ DisappearingMessages: 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 { @@ -52,7 +23,7 @@ func (wa *WhatsAppConnector) GetCapabilities() *bridgev2.NetworkGeneralCapabilit } func (wa *WhatsAppConnector) GetBridgeInfoVersion() (info, caps int) { - return 1, 8 + return 1, 1 } const WAMaxFileSize = 2000 * 1024 * 1024 @@ -67,7 +38,7 @@ func supportedIfFFmpeg() event.CapabilitySupportLevel { } func capID() string { - base := "fi.mau.whatsapp.capabilities.2026_05_12" + base := "fi.mau.whatsapp.capabilities.2025_01_10" if ffmpeg.Supported() { return base + "+ffmpeg" } @@ -95,8 +66,8 @@ var whatsappCaps = &event.RoomFeatures{ File: map[event.CapabilityMsgType]*event.FileFeatures{ event.MsgImage: { MimeTypes: map[string]event.CapabilitySupportLevel{ + "image/png": event.CapLevelFullySupported, "image/jpeg": event.CapLevelFullySupported, - "image/png": event.CapLevelPartialSupport, "image/webp": event.CapLevelPartialSupport, "image/gif": supportedIfFFmpeg(), }, @@ -126,10 +97,10 @@ var whatsappCaps = &event.RoomFeatures{ event.CapMsgSticker: { MimeTypes: map[string]event.CapabilitySupportLevel{ "image/webp": event.CapLevelFullySupported, + // TODO see if sending lottie is possible + //"video/lottie+json": event.CapLevelFullySupported, "image/png": 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, MaxSize: WAMaxFileSize, @@ -145,10 +116,9 @@ var whatsappCaps = &event.RoomFeatures{ }, event.MsgVideo: { MimeTypes: map[string]event.CapabilitySupportLevel{ - "video/mp4": event.CapLevelFullySupported, - "video/3gpp": event.CapLevelFullySupported, - "video/webm": supportedIfFFmpeg(), - "video/quicktime": supportedIfFFmpeg(), + "video/mp4": event.CapLevelFullySupported, + "video/3gpp": event.CapLevelFullySupported, + "video/webm": supportedIfFFmpeg(), }, Caption: event.CapLevelFullySupported, MaxCaptionLength: MaxTextLength, @@ -163,22 +133,12 @@ var whatsappCaps = &event.RoomFeatures{ 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, LocationMessage: event.CapLevelFullySupported, Poll: event.CapLevelFullySupported, Reply: event.CapLevelFullySupported, Edit: event.CapLevelFullySupported, + EditMaxCount: 10, EditMaxAge: ptr.Ptr(jsontime.S(EditMaxAge)), Delete: event.CapLevelFullySupported, DeleteForMe: false, @@ -187,20 +147,11 @@ var whatsappCaps = &event.RoomFeatures{ ReactionCount: 1, ReadReceipts: true, TypingNotifications: true, - DisappearingTimer: waDisappearingCap, - DeleteChat: true, } -var whatsappDMCaps *event.RoomFeatures var whatsappCAGCaps *event.RoomFeatures 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.ID = capID() + "+cag" whatsappCAGCaps.Reply = event.CapLevelUnsupported @@ -210,8 +161,6 @@ func init() { func (wa *WhatsAppClient) GetCapabilities(ctx context.Context, portal *bridgev2.Portal) *event.RoomFeatures { if portal.Metadata.(*waid.PortalMetadata).CommunityAnnouncementGroup { return whatsappCAGCaps - } else if portal.RoomType == database.RoomTypeDM { - return whatsappDMCaps } return whatsappCaps } diff --git a/pkg/connector/chatinfo.go b/pkg/connector/chatinfo.go index 5555566..81776dc 100644 --- a/pkg/connector/chatinfo.go +++ b/pkg/connector/chatinfo.go @@ -26,58 +26,56 @@ func (wa *WhatsAppClient) GetChatInfo(ctx context.Context, portal *bridgev2.Port if err != nil { 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 { case types.DefaultUserServer, types.HiddenUserServer, types.BotServer: - wrapped = wa.wrapDMInfo(ctx, portalJID) + wrapped = wa.wrapDMInfo(portalJID) case types.BroadcastServer: if portalJID == types.StatusBroadcastJID { - wrapped = wa.wrapStatusBroadcastInfo(ctx) + wrapped = wa.wrapStatusBroadcastInfo() } else { return nil, fmt.Errorf("broadcast list bridging is currently not supported") } case types.GroupServer: - info, err := wa.Client.GetGroupInfo(ctx, portalJID) + info, err := wa.Client.GetGroupInfo(portalJID) if err != nil { return nil, err } - wrapped = wa.wrapGroupInfo(ctx, info) + wrapped = wa.wrapGroupInfo(info) wrapped.ExtraUpdates = bridgev2.MergeExtraUpdaters(wrapped.ExtraUpdates, updatePortalLastSyncAt) case types.NewsletterServer: - info, err := wa.Client.GetNewsletterInfo(ctx, portalJID) + info, err := wa.Client.GetNewsletterInfo(portalJID) if err != nil { return nil, err } - wrapped = wa.wrapNewsletterInfo(ctx, info) + wrapped = wa.wrapNewsletterInfo(info) default: 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 } -func (wa *WhatsAppClient) addExtrasToWrapped(ctx context.Context, portalJID types.JID, wrapped *bridgev2.ChatInfo, conv *wadb.Conversation, isNew bool) { - if isNew { - if conv == nil { - var err error - conv, err = wa.Main.DB.Conversation.Get(ctx, wa.UserLogin.ID, portalJID) - if err != nil { - zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to get history sync conversation info") - } - } - if conv != nil { - wa.applyHistoryInfo(wrapped, conv) +func (wa *WhatsAppClient) addExtrasToWrapped(ctx context.Context, portalJID types.JID, wrapped *bridgev2.ChatInfo, conv *wadb.Conversation) { + if conv == nil { + var err error + conv, err = wa.Main.DB.Conversation.Get(ctx, wa.UserLogin.ID, portalJID) + if err != nil { + zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to get history sync conversation info") } } + if conv != nil { + wa.applyHistoryInfo(wrapped, conv) + } wa.applyChatSettings(ctx, portalJID, wrapped) } func updatePortalLastSyncAt(_ context.Context, portal *bridgev2.Portal) bool { 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() 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) { - chat, err := wa.GetStore().ChatSettings.GetChatSettings(ctx, chatID) + chat, err := wa.GetStore().ChatSettings.GetChatSettings(chatID) if err != nil { zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to get chat settings") return @@ -124,7 +122,7 @@ func (wa *WhatsAppClient) applyHistoryInfo(info *bridgev2.ChatInfo, conv *wadb.C } if info.Disappear == nil && ptr.Val(conv.EphemeralExpiration) > 0 { info.Disappear = &database.DisappearingSetting{ - Type: event.DisappearingTypeAfterSend, + Type: database.DisappearingTypeAfterRead, Timer: time.Duration(*conv.EphemeralExpiration) * time.Second, } if conv.EphemeralSettingTimestamp != nil { @@ -140,7 +138,7 @@ const UnnamedBroadcastName = "Unnamed broadcast list" const PrivateChatTopic = "WhatsApp private chat" 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{ Topic: ptr.Ptr(PrivateChatTopic), Members: &bridgev2.ChatMemberList{ @@ -148,17 +146,10 @@ func (wa *WhatsAppClient) wrapDMInfo(ctx context.Context, jid types.JID) *bridge TotalMemberCount: 2, OtherUserID: waid.MakeUserID(jid), MemberMap: map[networkid.UserID]bridgev2.ChatMember{ - waid.MakeUserID(jid): {EventSender: wa.makeEventSender(ctx, jid)}, - waid.MakeUserID(wa.JID): {EventSender: wa.makeEventSender(ctx, wa.JID)}, - }, - PowerLevels: &bridgev2.PowerLevelOverrides{ - Events: map[event.Type]int{ - event.StateRoomName: 0, - event.StateRoomAvatar: 0, - event.StateTopic: 0, - event.StateBeeperDisappearingTimer: 0, - }, + waid.MakeUserID(jid): {EventSender: wa.makeEventSender(jid)}, + waid.MakeUserID(wa.JID): {EventSender: wa.makeEventSender(wa.JID)}, }, + PowerLevels: nil, }, Type: ptr.Ptr(database.RoomTypeDM), } @@ -175,7 +166,7 @@ func (wa *WhatsAppClient) wrapDMInfo(ctx context.Context, jid types.JID) *bridge return info } -func (wa *WhatsAppClient) wrapStatusBroadcastInfo(ctx context.Context) *bridgev2.ChatInfo { +func (wa *WhatsAppClient) wrapStatusBroadcastInfo() *bridgev2.ChatInfo { userLocal := &bridgev2.UserLocalPortalInfo{} if wa.Main.Config.MuteStatusBroadcast { userLocal.MutedUntil = ptr.Ptr(event.MutedForever) @@ -189,7 +180,7 @@ func (wa *WhatsAppClient) wrapStatusBroadcastInfo(ctx context.Context) *bridgev2 Members: &bridgev2.ChatMemberList{ IsFull: false, 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), @@ -227,18 +218,7 @@ func setAddressingMode(mode types.AddressingMode) bridgev2.ExtraUpdater[*bridgev } } -func setTopicID(id, topic string) bridgev2.ExtraUpdater[*bridgev2.Portal] { - 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 { +func (wa *WhatsAppClient) wrapGroupInfo(info *types.GroupInfo) *bridgev2.ChatInfo { sendEventPL := defaultPL if info.IsAnnounce && !info.IsDefaultSubGroup { sendEventPL = adminPL @@ -251,7 +231,6 @@ func (wa *WhatsAppClient) wrapGroupInfo(ctx context.Context, info *types.GroupIn wa.makePortalAvatarFetcher("", types.EmptyJID, time.Time{}), setDefaultSubGroupFlag(info.IsDefaultSubGroup && info.IsAnnounce), setAddressingMode(info.AddressingMode), - setTopicID(info.TopicID, info.Topic), ) wrapped := &bridgev2.ChatInfo{ Name: ptr.Ptr(info.Name), @@ -271,22 +250,19 @@ func (wa *WhatsAppClient) wrapGroupInfo(ctx context.Context, info *types.GroupIn event.StateTopic: metaChangePL, event.EventReaction: defaultPL, event.EventRedaction: defaultPL, - - event.StateBeeperDisappearingTimer: metaChangePL, // TODO always allow poll responses }, }, }, - ExcludeChangesFromTimeline: true, Disappear: &database.DisappearingSetting{ - Type: event.DisappearingTypeAfterSend, + Type: database.DisappearingTypeAfterRead, Timer: time.Duration(info.DisappearingTimer) * time.Second, }, ExtraUpdates: extraUpdater, } for _, pcp := range info.Participants { member := bridgev2.ChatMember{ - EventSender: wa.makeEventSender(ctx, pcp.JID), + EventSender: wa.makeEventSender(pcp.JID), Membership: event.MembershipJoin, } if pcp.IsSuperAdmin { @@ -296,20 +272,7 @@ func (wa *WhatsAppClient) wrapGroupInfo(ctx context.Context, info *types.GroupIn } else { member.PowerLevel = ptr.Ptr(defaultPL) } - member.MemberEventExtra = map[string]any{ - "com.beeper.exclude_from_timeline": true, - } 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() { @@ -323,7 +286,7 @@ func (wa *WhatsAppClient) wrapGroupInfo(ctx context.Context, info *types.GroupIn 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 if evt.Name != nil || evt.Topic != nil || evt.Ephemeral != nil || evt.Unlink != nil || evt.Link != nil { changes = &bridgev2.ChatInfo{} @@ -332,11 +295,10 @@ func (wa *WhatsAppClient) wrapGroupInfoChange(ctx context.Context, evt *events.G } if evt.Topic != nil { changes.Topic = &evt.Topic.Topic - changes.ExtraUpdates = bridgev2.MergeExtraUpdaters(changes.ExtraUpdates, setTopicID(evt.Topic.TopicID, evt.Topic.Topic)) } if evt.Ephemeral != nil { changes.Disappear = &database.DisappearingSetting{ - Type: event.DisappearingTypeAfterSend, + Type: database.DisappearingTypeAfterRead, Timer: time.Duration(evt.Ephemeral.DisappearingTimer) * time.Second, } if !evt.Ephemeral.IsEphemeral { @@ -358,24 +320,24 @@ func (wa *WhatsAppClient) wrapGroupInfoChange(ctx context.Context, evt *events.G } for _, userID := range evt.Join { memberChanges.MemberMap[waid.MakeUserID(userID)] = bridgev2.ChatMember{ - EventSender: wa.makeEventSender(ctx, userID), + EventSender: wa.makeEventSender(userID), } } for _, userID := range evt.Promote { memberChanges.MemberMap[waid.MakeUserID(userID)] = bridgev2.ChatMember{ - EventSender: wa.makeEventSender(ctx, userID), + EventSender: wa.makeEventSender(userID), PowerLevel: ptr.Ptr(adminPL), } } for _, userID := range evt.Demote { memberChanges.MemberMap[waid.MakeUserID(userID)] = bridgev2.ChatMember{ - EventSender: wa.makeEventSender(ctx, userID), + EventSender: wa.makeEventSender(userID), PowerLevel: ptr.Ptr(defaultPL), } } for _, userID := range evt.Leave { memberChanges.MemberMap[waid.MakeUserID(userID)] = bridgev2.ChatMember{ - EventSender: wa.makeEventSender(ctx, userID), + EventSender: wa.makeEventSender(userID), Membership: event.MembershipLeave, } } @@ -421,7 +383,7 @@ func (wa *WhatsAppClient) makePortalAvatarFetcher(avatarID string, sender types. existingID = "" } var wrappedAvatar *bridgev2.Avatar - avatar, err := wa.Client.GetProfilePictureInfo(ctx, jid, &whatsmeow.GetProfilePictureParams{ + avatar, err := wa.Client.GetProfilePictureInfo(jid, &whatsmeow.GetProfilePictureParams{ ExistingID: existingID, IsCommunity: portal.RoomType == database.RoomTypeSpace, }) @@ -440,34 +402,25 @@ func (wa *WhatsAppClient) makePortalAvatarFetcher(avatarID string, sender types. return false } else if avatar == nil { 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 { wrappedAvatar = &bridgev2.Avatar{ ID: networkid.AvatarID(avatar.ID), 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 if !sender.IsEmpty() { - evtSender = wa.makeEventSender(ctx, sender) - } - senderIntent, ok := portal.GetIntentFor(ctx, evtSender, wa.UserLogin, bridgev2.RemoteEventChatInfoChange) - if !ok { - return false + evtSender = wa.makeEventSender(sender) } + senderIntent := portal.GetIntentFor(ctx, evtSender, wa.UserLogin, bridgev2.RemoteEventChatInfoChange) //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 var mutedUntil *time.Time if info.ViewerMeta != nil { @@ -485,22 +438,21 @@ func (wa *WhatsAppClient) wrapNewsletterInfo(ctx context.Context, info *types.Ne } } avatar := &bridgev2.Avatar{} - // TODO direct media for newsletter avatars if info.ThreadMeta.Picture != nil { avatar.ID = networkid.AvatarID(info.ThreadMeta.Picture.ID) 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 != "" { avatar.ID = networkid.AvatarID(info.ThreadMeta.Preview.ID) 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 { return nil, fmt.Errorf("failed to fetch full res avatar info: %w", err) } else if meta.ThreadMeta.Picture == nil { 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 { avatar.ID = "remove" @@ -517,7 +469,7 @@ func (wa *WhatsAppClient) wrapNewsletterInfo(ctx context.Context, info *types.Ne TotalMemberCount: info.ThreadMeta.SubscriberCount, MemberMap: map[networkid.UserID]bridgev2.ChatMember{ waid.MakeUserID(wa.JID): { - EventSender: wa.makeEventSender(ctx, wa.JID), + EventSender: wa.makeEventSender(wa.JID), PowerLevel: &ownPowerLevel, }, }, diff --git a/pkg/connector/client.go b/pkg/connector/client.go index 3f19bf7..d9a752f 100644 --- a/pkg/connector/client.go +++ b/pkg/connector/client.go @@ -25,10 +25,9 @@ import ( "time" "github.com/rs/zerolog" - "go.mau.fi/util/exsync" "go.mau.fi/whatsmeow" - "go.mau.fi/whatsmeow/appstate" waBinary "go.mau.fi/whatsmeow/binary" + "go.mau.fi/whatsmeow/proto/waHistorySync" "go.mau.fi/whatsmeow/proto/waWa6" "go.mau.fi/whatsmeow/store" "go.mau.fi/whatsmeow/types" @@ -38,24 +37,19 @@ import ( "maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2/networkid" "maunium.net/go/mautrix/bridgev2/status" - "maunium.net/go/mautrix/event" "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{ Main: wa, UserLogin: login, - MC: noopMCInstance, - historySyncWakeup: make(chan struct{}, 1), - resyncQueue: make(map[types.JID]resyncQueueItem), - directMediaRetries: make(map[networkid.MessageID]*directMediaRetry), - mediaRetryLock: semaphore.NewWeighted(wa.Config.HistorySync.MediaRequests.MaxAsyncHandle), - pushNamesSynced: exsync.NewEvent(), - createDedup: exsync.NewSet[types.MessageID](), - appStateFullSyncAttempted: make(map[appstate.WAPatchName]time.Time), + historySyncs: make(chan *waHistorySync.HistorySync, 64), + resyncQueue: make(map[types.JID]resyncQueueItem), + directMediaRetries: make(map[networkid.MessageID]*directMediaRetry), + mediaRetryLock: semaphore.NewWeighted(wa.Config.HistorySync.MediaRequests.MaxAsyncHandle), } login.Client = w @@ -66,7 +60,7 @@ func (wa *WhatsAppConnector) LoadUserLogin(ctx context.Context, login *bridgev2. var err error 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 { return err } @@ -74,18 +68,14 @@ func (wa *WhatsAppConnector) LoadUserLogin(ctx context.Context, login *bridgev2. if w.Device != nil { log := w.UserLogin.Log.With().Str("component", "whatsmeow").Logger() w.Client = whatsmeow.NewClient(w.Device, waLog.Zerolog(log)) - w.Client.AddEventHandlerWithSuccessStatus(w.handleWAEvent) - w.Client.SynchronousAck = true - w.Client.EnableDecryptedEventBuffer = bridgev2.PortalEventBuffer == 0 - w.Client.ManualHistorySyncDownload = true - w.Client.SendReportingTokens = true + w.Client.AddEventHandler(w.handleWAEvent) + if bridgev2.PortalEventBuffer == 0 { + w.Client.SynchronousAck = true + } w.Client.AutomaticMessageRerequestFromPhone = true w.Client.GetMessageForRetry = w.trackNotFoundRetry w.Client.PreRetryCallback = w.trackFoundRetry - w.Client.BackgroundEventCtx = w.UserLogin.Log.WithContext(wa.Bridge.BackgroundCtx) w.Client.SetForceActiveDeliveryReceipts(wa.Config.ForceActiveDeliveryReceipts) - w.Client.InitialAutoReconnect = wa.Config.InitialAutoReconnect - w.Client.UseRetryMessageStore = wa.Config.UseWhatsAppRetryStore } else { 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 Device *store.Device JID types.JID - MC mClient - historySyncWakeup chan struct{} + historySyncs chan *waHistorySync.HistorySync stopLoops atomic.Pointer[context.CancelFunc] resyncQueue map[types.JID]resyncQueueItem resyncQueueLock sync.Mutex @@ -114,22 +103,14 @@ type WhatsAppClient struct { directMediaRetries map[networkid.MessageID]*directMediaRetry directMediaLock sync.Mutex mediaRetryLock *semaphore.Weighted - offlineSyncWaiter atomic.Pointer[chan error] + offlineSyncWaiter chan error isNewLogin bool - pushNamesSynced *exsync.Event - lastPresence types.Presence - createDedup *exsync.Set[types.MessageID] - - appStateRecoveryLock sync.Mutex - appStateFullSyncAttempted map[appstate.WAPatchName]time.Time } var ( _ bridgev2.NetworkAPI = (*WhatsAppClient)(nil) _ bridgev2.PushableNetworkAPI = (*WhatsAppClient)(nil) _ bridgev2.BackgroundSyncingNetworkAPI = (*WhatsAppClient)(nil) - _ bridgev2.ChatViewingNetworkAPI = (*WhatsAppClient)(nil) - _ bridgev2.StickerImportingNetworkAPI = (*WhatsAppClient)(nil) ) var pushCfg = &bridgev2.PushConfig{ @@ -197,41 +178,23 @@ func (wa *WhatsAppClient) Connect(ctx context.Context) { wa.UserLogin.BridgeState.Send(state) return } - wa.UserLogin.BridgeState.Send(status.BridgeState{StateEvent: status.StateConnecting}) wa.Main.firstClientConnectOnce.Do(wa.Main.onFirstClientConnect) if err := wa.Main.updateProxy(ctx, wa.Client, false); err != nil { zerolog.Ctx(ctx).Err(err).Msg("Failed to update proxy") } - if ctx.Err() != nil { - return - } - wa.initMC() wa.startLoops() - wa.Client.BackgroundEventCtx = wa.UserLogin.Log.WithContext(wa.Main.Bridge.BackgroundCtx) - 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") + if err := wa.Client.Connect(); err != nil { state := status.BridgeState{ StateEvent: status.StateUnknownError, Error: WAConnectionFailed, - Info: map[string]any{ - "go_error": err.Error(), - }, } wa.UserLogin.BridgeState.Send(state) } } func (wa *WhatsAppClient) notifyOfflineSyncWaiter(err error) { - if ch := wa.offlineSyncWaiter.Load(); ch != nil { - select { - case *ch <- err: - default: - wa.UserLogin.Log.Warn(). - AnErr("dropped_error", err). - Msg("Offline sync waiter channel was full, dropping input") - } + if wa.offlineSyncWaiter != nil { + wa.offlineSyncWaiter <- err } } @@ -252,11 +215,8 @@ func (wa *WhatsAppClient) ConnectBackground(ctx context.Context, params *bridgev if wa.Client == nil { return bridgev2.ErrNotLoggedIn } - wa.Client.BackgroundEventCtx = wa.UserLogin.Log.WithContext(wa.Main.Bridge.BackgroundCtx) - ch := make(chan error, 1) - wa.offlineSyncWaiter.Store(&ch) - defer wa.offlineSyncWaiter.Store(nil) - wa.Main.backgroundConnectOnce.Do(wa.Main.onFirstBackgroundConnect) + wa.offlineSyncWaiter = make(chan error) + wa.Main.firstClientConnectOnce.Do(wa.Main.onFirstClientConnect) if err := wa.Main.updateProxy(ctx, wa.Client, false); err != nil { 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 } defer func() { - if cli := wa.Client; cli != nil { - cli.GetClientPayload = nil - } + wa.Client.GetClientPayload = nil }() - err := wa.Client.ConnectContext(ctx) + err := wa.Client.Connect() if err != nil { return err } @@ -278,7 +236,7 @@ func (wa *WhatsAppClient) ConnectBackground(ctx context.Context, params *bridgev select { case <-ctx.Done(): return ctx.Err() - case err = <-ch: + case err = <-wa.offlineSyncWaiter: if err == nil { var data wrappedPushNotificationData 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 { //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", Type: "get", To: types.ServerJID, @@ -303,6 +261,7 @@ func (wa *WhatsAppClient) sendPNData(ctx context.Context, pn string) error { Tag: "pn", Content: pn, }}, + Context: ctx, }) if err != nil { 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") //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", Content: []waBinary.Node{{ Tag: "cat", @@ -332,12 +291,11 @@ func (wa *WhatsAppClient) sendPNData(ctx context.Context, pn string) error { } func (wa *WhatsAppClient) startLoops() { - ctx, cancel := context.WithCancel(wa.Main.Bridge.BackgroundCtx) + ctx, cancel := context.WithCancel(context.Background()) oldStop := wa.stopLoops.Swap(&cancel) if oldStop != nil { (*oldStop)() } - ctx = wa.UserLogin.Log.WithContext(ctx) go wa.historySyncLoop(ctx) go wa.ghostResyncLoop(ctx) 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 } -func (wa *WhatsAppClient) callStopLoops() { +func (wa *WhatsAppClient) Disconnect() { if stopHistorySyncLoop := wa.stopLoops.Swap(nil); stopHistorySyncLoop != nil { (*stopHistorySyncLoop)() } -} - -func (wa *WhatsAppClient) Disconnect() { - wa.callStopLoops() if cli := wa.Client; cli != nil { cli.Disconnect() } @@ -370,7 +324,7 @@ func (wa *WhatsAppClient) Disconnect() { func (wa *WhatsAppClient) LogoutRemote(ctx context.Context) { if cli := wa.Client; cli != nil { - err := cli.Logout(ctx) + err := cli.Logout() if err != nil { 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") } - -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 -} diff --git a/pkg/connector/commands.go b/pkg/connector/commands.go index 037d24e..4c62db0 100644 --- a/pkg/connector/commands.go +++ b/pkg/connector/commands.go @@ -17,15 +17,12 @@ package connector import ( - "context" "errors" "fmt" "html" - "slices" "strings" "github.com/rs/zerolog" - "go.mau.fi/util/exslices" "go.mau.fi/whatsmeow" "go.mau.fi/whatsmeow/appstate" "go.mau.fi/whatsmeow/types" @@ -68,7 +65,7 @@ func fnAccept(ce *commands.Event) { ce.Reply("Login not found") } else if !login.Client.IsLoggedIn() { 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.Reply("Failed to accept group invite: %v", err) } else { @@ -117,12 +114,15 @@ func fnSync(ce *commands.Event) { }) ce.React("✅") case "groups": - groups, err := wa.Client.GetJoinedGroups(ce.Ctx) + groups, err := wa.Client.GetJoinedGroups() if err != nil { ce.Reply("Failed to get joined groups: %v", err) return } 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{ EventMeta: simplevent.EventMeta{ Type: bridgev2.RemoteEventChatResync, @@ -130,34 +130,19 @@ func fnSync(ce *commands.Event) { LogContext: logContext, CreatePortal: true, }, - GetChatInfoFunc: func(ctx context.Context, portal *bridgev2.Portal) (*bridgev2.ChatInfo, error) { - 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 - }, + ChatInfo: wrapped, }) } ce.Reply("Queued syncs for %d groups", len(groups)) case "contacts": - wa.resyncContacts(false, false) + wa.resyncContacts(false) ce.React("✅") case "contacts-with-avatars": - wa.resyncContacts(true, false) + wa.resyncContacts(true) ce.React("✅") case "appstate": - names := appstate.AllPatchNames[:] - if len(ce.Args) > 1 { - 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) + for _, name := range appstate.AllPatchNames { + err := wa.Client.FetchAppState(name, true, false) 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) return @@ -204,7 +189,7 @@ func fnInviteLink(ce *commands.Event) { ce.Reply("Can't get invite link to private chat") } else if portalJID.IsBroadcastList() { 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) } else { ce.Reply(link) @@ -234,14 +219,14 @@ func fnResolveLink(ce *commands.Event) { } wa := login.Client.(*WhatsAppClient) 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 { ce.Reply("Failed to get group info: %v", err) return } 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) { - target, err := wa.Client.ResolveBusinessMessageLink(ce.Ctx, ce.Args[0]) + target, err := wa.Client.ResolveBusinessMessageLink(ce.Args[0]) if err != nil { ce.Reply("Failed to get business info: %v", err) 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) } 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 { ce.Reply("Failed to get contact info: %v", err) return @@ -295,7 +280,7 @@ func fnJoin(ce *commands.Event) { wa := login.Client.(*WhatsAppClient) 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 { ce.Reply("Failed to join group: %v", err) 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.Reply("Successfully joined group `%s`, the portal should be created momentarily", jid) } 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 { ce.Reply("Failed to get channel info: %v", err) return } - err = wa.Client.FollowNewsletter(ce.Ctx, info.ID) + err = wa.Client.FollowNewsletter(info.ID) if err != nil { ce.Reply("Failed to follow channel: %v", err) return diff --git a/pkg/connector/config.go b/pkg/connector/config.go index 2445647..daffe99 100644 --- a/pkg/connector/config.go +++ b/pkg/connector/config.go @@ -4,7 +4,6 @@ import ( _ "embed" "strings" "text/template" - "time" up "go.mau.fi/util/configupgrade" "go.mau.fi/whatsmeow/types" @@ -49,15 +48,12 @@ type Config struct { DisableViewOnce bool `yaml:"disable_view_once"` ForceActiveDeliveryReceipts bool `yaml:"force_active_delivery_receipts"` 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"` HistorySync struct { - MaxInitialConversations int `yaml:"max_initial_conversations"` - RequestFullSync bool `yaml:"request_full_sync"` - DispatchWait time.Duration `yaml:"dispatch_wait"` + MaxInitialConversations int `yaml:"max_initial_conversations"` + RequestFullSync bool `yaml:"request_full_sync"` FullSyncConfig struct { DaysLimit uint32 `yaml:"days_limit"` SizeLimit uint32 `yaml:"size_mb_limit"` @@ -70,8 +66,6 @@ type Config struct { RequestLocalTime int `yaml:"request_local_time"` MaxAsyncHandle int64 `yaml:"max_async_handle"` } `yaml:"media_requests"` - - BackwardsOnDemand bool `yaml:"backwards_on_demand"` } `yaml:"history_sync"` displaynameTemplate *template.Template `yaml:"-"` @@ -118,8 +112,6 @@ func upgradeConfig(helper up.Helper) { helper.Copy(up.Bool, "disable_view_once") helper.Copy(up.Bool, "force_active_delivery_receipts") 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.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.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", "size_mb_limit") 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.Int, "history_sync", "media_requests", "request_local_time") helper.Copy(up.Int, "history_sync", "media_requests", "max_async_handle") - helper.Copy(up.Bool, "history_sync", "backwards_on_demand") } type DisplaynameParams struct { @@ -153,11 +143,11 @@ type DisplaynameParams struct { func (c *Config) FormatDisplayname(jid types.JID, phone string, contact types.ContactInfo) string { var nameBuf strings.Builder - if phone == "" && jid.Server == types.DefaultUserServer { + if phone == "" { phone = "+" + jid.User - } - if contact.RedactedPhone == "" && phone != "" { - contact.RedactedPhone = redactPhone(phone) + if jid.Server != types.DefaultUserServer { + phone = jid.User + } } err := c.displaynameTemplate.Execute(&nameBuf, &DisplaynameParams{ ContactInfo: contact, @@ -176,11 +166,6 @@ func (c *Config) FormatDisplayname(jid types.JID, phone string, contact types.Co 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) { return ExampleConfig, &wa.Config, &up.StructUpgrader{ SimpleUpgrader: up.SimpleUpgrader(upgradeConfig), diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index 91b1e11..72c2cbc 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -20,15 +20,10 @@ import ( "context" "encoding/hex" "fmt" - "net" - "net/http" "strings" "sync" "sync/atomic" - "time" - "github.com/lib/pq" - "github.com/rs/zerolog" "go.mau.fi/util/dbutil" "go.mau.fi/util/random" "go.mau.fi/whatsmeow" @@ -36,19 +31,16 @@ import ( "go.mau.fi/whatsmeow/store" "go.mau.fi/whatsmeow/store/sqlstore" whatsmeowUpgrades "go.mau.fi/whatsmeow/store/sqlstore/upgrades" - "go.mau.fi/whatsmeow/types" waLog "go.mau.fi/whatsmeow/util/log" "google.golang.org/protobuf/proto" "maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2/commands" - "maunium.net/go/mautrix/bridgev2/database" "maunium.net/go/mautrix/bridgev2/networkid" "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/id" "go.mau.fi/mautrix-whatsapp/pkg/connector/wadb" "go.mau.fi/mautrix-whatsapp/pkg/msgconv" - "go.mau.fi/mautrix-whatsapp/pkg/waid" ) type WhatsAppConnector struct { @@ -59,22 +51,18 @@ type WhatsAppConnector struct { DB *wadb.Database firstClientConnectOnce sync.Once - backgroundConnectOnce sync.Once mediaEditCache MediaEditCache mediaEditCacheLock sync.RWMutex stopMediaEditCacheLoop atomic.Pointer[context.CancelFunc] } -func init() { - sqlstore.PostgresArrayWrapper = pq.Array -} - var ( - _ bridgev2.NetworkConnector = (*WhatsAppConnector)(nil) - _ bridgev2.MaxFileSizeingNetwork = (*WhatsAppConnector)(nil) - _ bridgev2.StoppableNetwork = (*WhatsAppConnector)(nil) - _ bridgev2.NetworkResettingNetwork = (*WhatsAppConnector)(nil) + _ bridgev2.NetworkConnector = (*WhatsAppConnector)(nil) + _ bridgev2.MaxFileSizeingNetwork = (*WhatsAppConnector)(nil) + _ bridgev2.StoppableNetwork = (*WhatsAppConnector)(nil) + + _ bridgev2.TransactionIDGeneratingNetwork = (*WhatsAppConnector)(nil) ) 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.RequireFullSync = proto.Bool(wa.Config.HistorySync.RequestFullSync) 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)] if ok { @@ -142,7 +129,7 @@ func (wa *WhatsAppConnector) Init(bridge *bridgev2.Bridge) { } func (wa *WhatsAppConnector) Start(ctx context.Context) error { - err := wa.DeviceStore.Upgrade(ctx) + err := wa.DeviceStore.Upgrade() if err != nil { 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"} } - if !wa.Bridge.Background && wa.Bridge.DB.KV.Get(ctx, "whatsapp_lid_dms_deleted") == "false" { - wa.deleteLIDDMsMigration(ctx) - } - 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() { - if stop := wa.stopMediaEditCacheLoop.Swap(nil); stop != nil { + if stop := wa.stopMediaEditCacheLoop.Load(); stop != nil { (*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() { - wa.Bridge.Log.Debug().Msg("Fetching latest WhatsApp web version number") - 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, - }) + ver, err := whatsmeow.GetLatestVersion(nil) if err != nil { wa.Bridge.Log.Err(err).Msg("Failed to get latest WhatsApp web version number") } else { wa.Bridge.Log.Debug(). - Stringer("hardcoded_version", hardcodedWAVersion). + Stringer("hardcoded_version", store.GetWAVersion()). Stringer("latest_version", *ver). Msg("Got latest WhatsApp web version number") 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) 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. 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() - } -} diff --git a/pkg/connector/directmedia.go b/pkg/connector/directmedia.go index ac2fbd2..64bb5e4 100644 --- a/pkg/connector/directmedia.go +++ b/pkg/connector/directmedia.go @@ -21,7 +21,6 @@ import ( "encoding/json" "errors" "fmt" - "io" "net/http" "os" "sync" @@ -29,7 +28,6 @@ import ( "github.com/rs/zerolog" "go.mau.fi/util/exsync" - "go.mau.fi/util/ptr" "go.mau.fi/whatsmeow" "go.mau.fi/whatsmeow/proto/waMmsRetry" "go.mau.fi/whatsmeow/types/events" @@ -39,7 +37,6 @@ import ( "maunium.net/go/mautrix/bridgev2/networkid" "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/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) { - parsedID, err := waid.ParseMediaID(mediaID) + parsedID, receiverID, err := waid.ParseMediaID(mediaID) if err != nil { 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) - if parsedID.Message != nil { - 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()) + msg, err := wa.Bridge.DB.Message.GetFirstPartByID(ctx, receiverID, parsedID.String()) if err != nil { return nil, fmt.Errorf("failed to get message: %w", err) } 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) } var ul *bridgev2.UserLogin - if parsedID.UserLogin != "" { - ul = wa.Bridge.GetCachedUserLoginByID(parsedID.UserLogin) + if receiverID != "" { + ul = wa.Bridge.GetCachedUserLoginByID(receiverID) } else { logins, err := wa.Bridge.GetUserLoginsInPortal(ctx, msg.Room) if err != nil { @@ -187,67 +91,38 @@ func (wa *WhatsAppConnector) downloadMessageDirectMedia(ctx context.Context, par } } if ul == nil || !ul.Client.IsLoggedIn() { - return nil, bridgev2.ErrNotLoggedIn + return nil, fmt.Errorf("no logged in user found") } waClient := ul.Client.(*WhatsAppClient) if waClient.Client == nil { 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{ - Callback: func(f *os.File) (*mediaproxy.FileMeta, error) { - log := zerolog.Ctx(ctx) - err := waClient.Client.DownloadToFile(ctx, dm, f) - if keys != nil && (errors.Is(err, whatsmeow.ErrMediaDownloadFailedWith403) || errors.Is(err, whatsmeow.ErrMediaDownloadFailedWith404) || errors.Is(err, whatsmeow.ErrMediaDownloadFailedWith410) || errors.Is(err, whatsmeow.ErrNoURLPresent)) { + Callback: func(f *os.File) error { + err := waClient.Client.DownloadToFile(keys, f) + if errors.Is(err, whatsmeow.ErrMediaDownloadFailedWith403) || errors.Is(err, whatsmeow.ErrMediaDownloadFailedWith404) || errors.Is(err, whatsmeow.ErrMediaDownloadFailedWith410) { val := params["fi.mau.whatsapp.reload_media"] 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") - err = waClient.requestAndWaitDirectMedia(ctx, msgID, keys) + err = waClient.requestAndWaitDirectMedia(ctx, msg.ID, keys) if err != nil { 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") - err = waClient.Client.DownloadToFile(ctx, keys, f) + err = waClient.Client.DownloadToFile(keys, f) } 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") } else if err != nil { - return nil, err + return err } - - 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 + return nil }, + // TODO? + ContentType: "", }, nil } @@ -285,16 +160,12 @@ func (wa *WhatsAppClient) requestAndWaitDirectMedia(ctx context.Context, rawMsgI } switch state.resultType { case waMmsRetry.MediaRetryNotification_NOT_FOUND: - return mautrix.MNotFound.WithMessage("This media was not found on your 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) + return mautrix.MNotFound.WithMessage("Media not found on phone") 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): - 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(): return ctx.Err() } @@ -306,7 +177,7 @@ func (wa *WhatsAppClient) requestDirectMedia(ctx context.Context, rawMsgID netwo defer state.Unlock() if !state.requested { 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 { 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") return } - if state != nil { - state.resultType = retryData.GetResult() - } + state.resultType = retryData.GetResult() if retryData.GetResult() != waMmsRetry.MediaRetryNotification_SUCCESS { errorName := waMmsRetry.MediaRetryNotification_ResultType_name[int32(retryData.GetResult())] if retryData.GetDirectPath() == "" { diff --git a/pkg/connector/events.go b/pkg/connector/events.go index de5bdb8..7f5435b 100644 --- a/pkg/connector/events.go +++ b/pkg/connector/events.go @@ -73,7 +73,7 @@ func (evt *MessageInfoWrapper) GetTimestamp() time.Time { } 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 { @@ -124,50 +124,6 @@ func (evt *WAMessageEvent) AddLogContext(c zerolog.Context) zerolog.Context { 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) { if ph := evt.postHandle; ph != 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) } - 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.MsgEvent.RawMessage, &evt.Info, evt.isViewOnce(), false, previouslyConvertedPart, - ) + cm := evt.wa.Main.MsgConv.ToMatrix(ctx, portal, evt.wa.Client, intent, editedMsg, &evt.Info, evt.isViewOnce(), previouslyConvertedPart) if evt.isUndecryptableUpsertSubEvent && isFailedMedia(cm) { evt.postHandle = func() { 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 { if reactionMsg := evt.Message.GetReactionMessage(); reactionMsg != nil { - ctx := evt.wa.UserLogin.Log. - 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()) + return msgconv.KeyToMessageID(evt.wa.Client, evt.Info.Chat, evt.Info.Sender, reactionMsg.GetKey()) } else if protocolMsg := evt.Message.GetProtocolMessage(); protocolMsg != nil { - ctx := evt.wa.UserLogin.Log. - 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 msgconv.KeyToMessageID(evt.wa.Client, evt.Info.Chat, evt.Info.Sender, protocolMsg.GetKey()) } 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) { - evt.wa.EnqueuePortalResync(portal, false) - converted := evt.wa.Main.MsgConv.ToMatrix( - ctx, portal, evt.wa.Client, intent, evt.Message, evt.MsgEvent.RawMessage, &evt.Info, evt.isViewOnce(), false, nil, - ) + evt.wa.EnqueuePortalResync(portal) + converted := evt.wa.Main.MsgConv.ToMatrix(ctx, portal, evt.wa.Client, intent, evt.Message, &evt.Info, evt.isViewOnce(), nil) if isFailedMedia(converted) { evt.postHandle = func() { 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 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{ 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 @@ -416,7 +357,7 @@ func (evt *WAMediaRetry) getRealSender() types.JID { } 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 { diff --git a/pkg/connector/example-config.yaml b/pkg/connector/example-config.yaml index 564f25e..eb5fb2d 100644 --- a/pkg/connector/example-config.yaml +++ b/pkg/connector/example-config.yaml @@ -13,12 +13,11 @@ get_proxy_url: null proxy_only_login: false # Displayname template for WhatsApp users. -# {{.PushName}} - nickname set by the WhatsApp user -# {{.BusinessName}} - validated WhatsApp business name -# {{.Phone}} - phone number (international format) -# {{.RedactedPhone}} - phone number with middle digits replaced by "∙" -# {{.FullName}} - Name you set in the contacts list -displayname_template: '{{or .BusinessName .PushName .Phone .RedactedPhone "Unknown user"}} (WA)' +# {{.PushName}} - nickname set by the WhatsApp user +# {{.BusinessName}} - validated WhatsApp business name +# {{.Phone}} - phone number (international format) +# {{.FullName}} - Name you set in the contacts list +displayname_template: "{{or .BusinessName .PushName .Phone}} (WA)" # Should incoming calls send a message to the Matrix room? 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, # should it be automatically requested from the phone? 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. animated_sticker: @@ -94,10 +86,6 @@ history_sync: # Should the bridge request a full sync from the phone when logging in? # This bumps the size of history syncs from 3 months to 1 year. request_full_sync: false - # 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. # By default, (when the values are null or 0), the config isn't sent at all. full_sync_config: @@ -121,6 +109,3 @@ history_sync: request_local_time: 120 # Maximum number of media request responses to handle in parallel per user. 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 diff --git a/pkg/connector/handlematrix.go b/pkg/connector/handlematrix.go index b0963ec..8dfa5c9 100644 --- a/pkg/connector/handlematrix.go +++ b/pkg/connector/handlematrix.go @@ -1,13 +1,9 @@ package connector import ( - "bytes" "context" - "crypto/sha256" "errors" "fmt" - "image" - "image/jpeg" "strings" "time" @@ -15,11 +11,8 @@ import ( "go.mau.fi/util/ptr" "go.mau.fi/util/variationselector" "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/types" - "golang.org/x/image/draw" "google.golang.org/protobuf/proto" "maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2/database" @@ -31,21 +24,12 @@ import ( ) var ( - _ bridgev2.TypingHandlingNetworkAPI = (*WhatsAppClient)(nil) - _ bridgev2.EditHandlingNetworkAPI = (*WhatsAppClient)(nil) - _ bridgev2.ReactionHandlingNetworkAPI = (*WhatsAppClient)(nil) - _ bridgev2.RedactionHandlingNetworkAPI = (*WhatsAppClient)(nil) - _ bridgev2.ReadReceiptHandlingNetworkAPI = (*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) + _ bridgev2.TypingHandlingNetworkAPI = (*WhatsAppClient)(nil) + _ bridgev2.EditHandlingNetworkAPI = (*WhatsAppClient)(nil) + _ bridgev2.ReactionHandlingNetworkAPI = (*WhatsAppClient)(nil) + _ bridgev2.RedactionHandlingNetworkAPI = (*WhatsAppClient)(nil) + _ bridgev2.ReadReceiptHandlingNetworkAPI = (*WhatsAppClient)(nil) + _ bridgev2.PollHandlingNetworkAPI = (*WhatsAppClient)(nil) ) 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) msg.AddPendingToIgnore(networkid.TransactionID(wrappedMsgID)) 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) if err != nil { return nil, err } var pickedMessageID networkid.MessageID - if resp.Sender == wa.GetStore().GetLID() && chatJID.Server != types.DefaultUserServer { + if resp.Sender == wa.GetStore().GetLID() { pickedMessageID = wrappedMsgID2 msg.RemovePending(networkid.TransactionID(wrappedMsgID)) } else { @@ -173,7 +156,7 @@ func (wa *WhatsAppClient) HandleMatrixReaction(ctx context.Context, msg *bridgev } var req whatsmeow.SendRequestExtra 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 { 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) } 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 { 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 { - err = wa.updatePresence(ctx, types.PresenceAvailable) + err = wa.Client.SendPresence(types.PresenceAvailable) if err != nil { 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) HandleMatrixDisappearingTimer(ctx context.Context, msg *bridgev2.MatrixDisappearingTimer) (bool, error) { +func (wa *WhatsAppClient) HandleMatrixMembership(ctx context.Context, msg *bridgev2.MatrixMembershipChange) (bool, error) { portalJID, err := waid.ParsePortalID(msg.Portal.ID) if err != nil { 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 { switch msg.Type { case bridgev2.Invite: - return nil, fmt.Errorf("cannot invite additional user to dm") + return false, fmt.Errorf("cannot invite additional user to dm") 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: action = whatsmeow.ParticipantChangeRemove default: - return nil, nil + return false, nil } switch target := msg.Target.(type) { @@ -428,245 +377,17 @@ func (wa *WhatsAppClient) HandleMatrixMembership(ctx context.Context, msg *bridg case *bridgev2.UserLogin: ghost, err := target.Bridge.GetGhostByID(ctx, networkid.UserID(target.ID)) 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) 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) - 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) + _, err = wa.Client.UpdateGroupParticipants(portalJID, changes, action) if err != nil { 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 } - -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)) -} diff --git a/pkg/connector/handlewhatsapp.go b/pkg/connector/handlewhatsapp.go index 3abcede..0a83902 100644 --- a/pkg/connector/handlewhatsapp.go +++ b/pkg/connector/handlewhatsapp.go @@ -25,10 +25,7 @@ import ( "github.com/rs/zerolog" "go.mau.fi/util/ptr" - "go.mau.fi/whatsmeow" "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/events" "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 - ctx := log.WithContext(wa.Main.Bridge.BackgroundCtx) - success = true switch evt := rawEvt.(type) { case *events.Message: - success = wa.handleWAMessage(ctx, evt) + wa.handleWAMessage(evt) case *events.Receipt: - success = wa.handleWAReceipt(ctx, evt) + wa.handleWAReceipt(evt) case *events.ChatPresence: - wa.handleWAChatPresence(ctx, evt) + wa.handleWAChatPresence(evt) case *events.UndecryptableMessage: - success = wa.handleWAUndecryptableMessage(ctx, evt) + wa.handleWAUndecryptableMessage(evt) 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: - 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: // ignore case *events.IdentityChange: - wa.handleWAIdentityChange(ctx, evt) + wa.handleWAIdentityChange(evt) case *events.MarkChatAsRead: - success = wa.handleWAMarkChatAsRead(ctx, evt) + wa.handleWAMarkChatAsRead(evt) case *events.DeleteForMe: - success = wa.handleWADeleteForMe(ctx, evt) + wa.handleWADeleteForMe(evt) case *events.DeleteChat: - success = wa.handleWADeleteChat(ctx, evt) + wa.handleWADeleteChat(evt) case *events.Mute: - success = wa.handleWAMute(evt) + wa.handleWAMute(evt) case *events.Archive: - success = wa.handleWAArchive(evt) + wa.handleWAArchive(evt) case *events.Pin: - success = wa.handleWAPin(evt) + wa.handleWAPin(evt) 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: 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: - success = wa.handleWAGroupInfoChange(ctx, evt) + wa.handleWAGroupInfoChange(evt) case *events.JoinedGroup: - success = wa.handleWAJoinedGroup(ctx, evt) + wa.handleWAJoinedGroup(evt) case *events.NewsletterJoin: - success = wa.handleWANewsletterJoin(ctx, evt) + wa.handleWANewsletterJoin(evt) case *events.NewsletterLeave: - success = wa.handleWANewsletterLeave(evt) + wa.handleWANewsletterLeave(evt) case *events.Picture: - success = wa.handleWAPictureUpdate(ctx, evt) + go wa.handleWAPictureUpdate(evt) case *events.AppStateSyncComplete: - wa.handleWAAppStateSyncComplete(ctx, evt) - case *events.AppStateSyncError: - wa.handleWAAppStateSyncError(ctx, evt) + if len(wa.GetStore().PushName) > 0 && evt.Name == appstate.WAPatchCriticalBlock { + err := wa.Client.SendPresence(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) + } case *events.AppState: // Intentionally ignored case *events.PushNameSetting: // Send presence available when connecting and when the pushname is changed. // 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 { 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()) - 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()) + _, _, 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") } @@ -161,14 +160,27 @@ func (wa *WhatsAppClient) handleWAEvent(rawEvt any) (success bool) { wa.UserLogin.BridgeState.Send(status.BridgeState{StateEvent: status.StateConnected}) if len(wa.GetStore().PushName) > 0 { go func() { - err := wa.updatePresence(ctx, types.PresenceUnavailable) + err := wa.Client.SendPresence(types.PresenceUnavailable) if err != nil { 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: log.Info(). Int("message_count", evt.Messages). @@ -179,13 +191,10 @@ func (wa *WhatsAppClient) handleWAEvent(rawEvt any) (success bool) { case *events.OfflineSyncCompleted: if !wa.PhoneRecentlySeen(true) { log.Info(). - Int("evt_count", evt.Count). Time("phone_last_seen", wa.UserLogin.Metadata.(*waid.UserLoginMetadata).PhoneLastSeen.Time). Msg("Offline sync completed, but phone last seen date is still old") } else { - log.Info(). - Int("evt_count", evt.Count). - Msg("Offline sync completed") + log.Info().Msg("Offline sync completed") } wa.UserLogin.BridgeState.Send(status.BridgeState{StateEvent: status.StateConnected}) wa.notifyOfflineSyncWaiter(nil) @@ -243,78 +252,22 @@ func (wa *WhatsAppClient) handleWAEvent(rawEvt any) (success bool) { default: 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) { - if (info.Chat.Server == types.HiddenUserServer || info.Chat.Server == types.BroadcastServer) && - info.Sender.Server == types.HiddenUserServer && info.SenderAlt.IsEmpty() { - info.SenderAlt, _ = wa.GetStore().LIDs.GetPNForLID(ctx, info.Sender) - } - 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 +func (wa *WhatsAppClient) handleWAMessage(evt *events.Message) { + wa.UserLogin.Log.Trace(). + Any("info", evt.Info). + Any("payload", evt.Message). + Msg("Received WhatsApp message") if evt.Info.Chat == types.StatusBroadcastJID && !wa.Main.Config.EnableStatusBroadcast { return } parsedMessageType := getMessageType(evt.Message) + if parsedMessageType == "ignore" || strings.HasPrefix(parsedMessageType, "unknown_protocol_") { + return + } if encReact := evt.Message.GetEncReactionMessage(); encReact != nil { - decrypted, err := wa.Client.DecryptReaction(ctx, evt) + decrypted, err := wa.Client.DecryptReaction(evt) if err != nil { wa.UserLogin.Log.Err(err).Str("message_id", evt.Info.ID).Msg("Failed to decrypt reaction") return @@ -323,7 +276,7 @@ func (wa *WhatsAppClient) handleWAMessage(ctx context.Context, evt *events.Messa evt.Message.ReactionMessage = decrypted } if encComment := evt.Message.GetEncCommentMessage(); encComment != nil { - decrypted, err := wa.Client.DecryptComment(ctx, evt) + decrypted, err := wa.Client.DecryptComment(evt) if err != nil { wa.UserLogin.Log.Err(err).Str("message_id", evt.Info.ID).Msg("Failed to decrypt comment") } else { @@ -331,62 +284,7 @@ func (wa *WhatsAppClient) handleWAMessage(ctx context.Context, evt *events.Messa evt.Message = decrypted } } - if encMessage := evt.Message.GetSecretEncryptedMessage(); encMessage != nil { - 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{ + wa.Main.Bridge.QueueRemoteEvent(wa.UserLogin, &WAMessageEvent{ MessageInfoWrapper: &MessageInfoWrapper{ Info: evt.Info, wa: wa, @@ -396,11 +294,9 @@ func (wa *WhatsAppClient) handleWAMessage(ctx context.Context, evt *events.Messa parsedMessageType: parsedMessageType, }) - return res.Success } -func (wa *WhatsAppClient) handleWAUndecryptableMessage(ctx context.Context, evt *events.UndecryptableMessage) bool { - wa.rerouteWAMessage(ctx, "undecryptable message", &evt.Info.MessageSource, evt.Info.ID) +func (wa *WhatsAppClient) handleWAUndecryptableMessage(evt *events.UndecryptableMessage) { wa.UserLogin.Log.Debug(). Any("info", evt.Info). Bool("unavailable", evt.IsUnavailable). @@ -408,24 +304,21 @@ func (wa *WhatsAppClient) handleWAUndecryptableMessage(ctx context.Context, evt Msg("Received undecryptable WhatsApp message") wa.trackUndecryptable(evt) if evt.DecryptFailMode == events.DecryptFailHide { - return true + return } 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{ Info: evt.Info, wa: wa, }, Type: evt.UnavailableType, }) - return res.Success } -func (wa *WhatsAppClient) handleWAReceipt(ctx context.Context, evt *events.Receipt) (success bool) { - origChat := evt.Chat - wa.rerouteWAMessage(ctx, "receipt", &evt.MessageSource, evt.MessageIDs) +func (wa *WhatsAppClient) handleWAReceipt(evt *events.Receipt) { if evt.IsFromMe && evt.Sender.Device == 0 { wa.phoneSeen(evt.Timestamp) } @@ -438,47 +331,28 @@ func (wa *WhatsAppClient) handleWAReceipt(ctx context.Context, evt *events.Recei case types.ReceiptTypeSender: fallthrough default: - return true + return } targets := make([]networkid.MessageID, len(evt.MessageIDs)) messageSender := wa.JID if !evt.MessageSender.IsEmpty() { 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 { 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{ Type: evtType, PortalKey: wa.makeWAPortalKey(evt.Chat), - Sender: wa.makeEventSender(ctx, evt.Sender), + Sender: wa.makeEventSender(evt.Sender), Timestamp: evt.Timestamp, }, Targets: targets, }) - return res.Success } -func (wa *WhatsAppClient) handleWAChatPresence(ctx context.Context, 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() - } - } +func (wa *WhatsAppClient) handleWAChatPresence(evt *events.ChatPresence) { typingType := bridgev2.TypingTypeText timeout := 15 * time.Second if evt.Media == types.ChatPresenceMediaAudio { @@ -488,12 +362,12 @@ func (wa *WhatsAppClient) handleWAChatPresence(ctx context.Context, evt *events. timeout = 0 } - wa.UserLogin.QueueRemoteEvent(&simplevent.Typing{ + wa.Main.Bridge.QueueRemoteEvent(wa.UserLogin, &simplevent.Typing{ EventMeta: simplevent.EventMeta{ Type: bridgev2.RemoteEventTyping, LogContext: nil, PortalKey: wa.makeWAPortalKey(evt.Chat), - Sender: wa.makeEventSender(ctx, evt.Sender), + Sender: wa.makeEventSender(evt.Sender), Timestamp: time.Now(), }, Timeout: timeout, @@ -508,7 +382,7 @@ func (wa *WhatsAppClient) handleWALogout(reason events.ConnectFailureReason, onC } else if reason == events.ConnectFailureMainDeviceGone { errorCode = WAMainDeviceGone } - wa.Disconnect() + wa.Client.Disconnect() wa.Client = nil wa.JID = types.EmptyJID wa.UserLogin.Metadata.(*waid.UserLoginMetadata).WADeviceID = 0 @@ -520,36 +394,23 @@ func (wa *WhatsAppClient) handleWALogout(reason events.ConnectFailureReason, onC 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 { - return true + return } - if sender.Server == types.HiddenUserServer && senderAlt.Server == types.DefaultUserServer { - 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]{ + wa.UserLogin.QueueRemoteEvent(&simplevent.Message[string]{ EventMeta: simplevent.EventMeta{ Type: bridgev2.RemoteEventMessage, LogContext: nil, - PortalKey: wa.makeWAPortalKey(chat), - Sender: wa.makeEventSender(ctx, sender), + PortalKey: wa.makeWAPortalKey(sender), + Sender: wa.makeEventSender(sender), CreatePortal: true, Timestamp: ts, - StreamOrder: ts.Unix(), }, Data: callType, - ID: waid.MakeFakeMessageID(chat, sender, "call-"+id), + ID: waid.MakeFakeMessageID(sender, sender, "call-"+id), ConvertMessageFunc: convertCallStart, - }).Success + }) } 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{ MsgType: event.MsgText, Body: text, - BeeperActionMessage: &event.BeeperActionMessage{ - Type: event.BeeperActionMessageCall, - CallType: event.BeeperActionMessageCallType(callType), - }, }, }}, }, nil } -func (wa *WhatsAppClient) handleWAIdentityChange(ctx context.Context, evt *events.IdentityChange) { +func (wa *WhatsAppClient) handleWAIdentityChange(evt *events.IdentityChange) { if !wa.Main.Config.IdentityChangeNotices { return } @@ -581,7 +438,7 @@ func (wa *WhatsAppClient) handleWAIdentityChange(ctx context.Context, evt *event Type: bridgev2.RemoteEventMessage, LogContext: nil, PortalKey: wa.makeWAPortalKey(evt.JID), - Sender: wa.makeEventSender(ctx, evt.JID), + Sender: wa.makeEventSender(evt.JID), CreatePortal: false, Timestamp: evt.Timestamp, }, @@ -611,43 +468,39 @@ func convertIdentityChange(ctx context.Context, portal *bridgev2.Portal, intent }, nil } -func (wa *WhatsAppClient) handleWADeleteChat(ctx context.Context, evt *events.DeleteChat) bool { - chatJID := wa.maybeConvertJIDToLID(ctx, evt.JID) - return wa.UserLogin.QueueRemoteEvent(&simplevent.ChatDelete{ +func (wa *WhatsAppClient) handleWADeleteChat(evt *events.DeleteChat) { + wa.UserLogin.QueueRemoteEvent(&simplevent.ChatDelete{ EventMeta: simplevent.EventMeta{ Type: bridgev2.RemoteEventChatDelete, - PortalKey: wa.makeWAPortalKey(chatJID), + PortalKey: wa.makeWAPortalKey(evt.JID), Timestamp: evt.Timestamp, }, OnlyForMe: true, - Children: true, - }).Success + }) } -func (wa *WhatsAppClient) handleWADeleteForMe(ctx context.Context, evt *events.DeleteForMe) bool { - chatJID := wa.maybeConvertJIDToLID(ctx, evt.ChatJID) - return wa.UserLogin.QueueRemoteEvent(&simplevent.MessageRemove{ +func (wa *WhatsAppClient) handleWADeleteForMe(evt *events.DeleteForMe) { + wa.UserLogin.QueueRemoteEvent(&simplevent.MessageRemove{ EventMeta: simplevent.EventMeta{ Type: bridgev2.RemoteEventMessageRemove, - PortalKey: wa.makeWAPortalKey(chatJID), + PortalKey: wa.makeWAPortalKey(evt.ChatJID), Timestamp: evt.Timestamp, }, - TargetMessage: waid.MakeMessageID(chatJID, evt.SenderJID, evt.MessageID), + TargetMessage: waid.MakeMessageID(evt.ChatJID, evt.SenderJID, evt.MessageID), OnlyForMe: true, - }).Success + }) } -func (wa *WhatsAppClient) handleWAMarkChatAsRead(ctx context.Context, evt *events.MarkChatAsRead) bool { - chatJID := wa.maybeConvertJIDToLID(ctx, evt.JID) - return wa.UserLogin.QueueRemoteEvent(&simplevent.Receipt{ +func (wa *WhatsAppClient) handleWAMarkChatAsRead(evt *events.MarkChatAsRead) { + wa.UserLogin.QueueRemoteEvent(&simplevent.Receipt{ EventMeta: simplevent.EventMeta{ Type: bridgev2.RemoteEventReadReceipt, - PortalKey: wa.makeWAPortalKey(chatJID), - Sender: wa.makeEventSender(ctx, wa.JID), + PortalKey: wa.makeWAPortalKey(evt.JID), + Sender: wa.makeEventSender(wa.JID), Timestamp: evt.Timestamp, }, ReadUpTo: evt.Timestamp, - }).Success + }) } 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)). Stringer("jid", jid). Logger() - ctx := log.WithContext(wa.Main.Bridge.BackgroundCtx) + ctx := log.WithContext(context.Background()) ghost, err := wa.Main.Bridge.GetGhostByID(ctx, waid.MakeUserID(jid)) if err != nil { log.Err(err).Msg("Failed to get ghost") @@ -672,15 +525,13 @@ func (wa *WhatsAppClient) syncGhost(jid types.JID, reason string, pictureID *str } else { ghost.UpdateInfo(ctx, userInfo) log.Debug().Msg("Synced ghost info") - wa.syncAltGhostWithInfo(ctx, jid, userInfo) } go wa.syncRemoteProfile(ctx, ghost) } -func (wa *WhatsAppClient) handleWAPictureUpdate(ctx context.Context, evt *events.Picture) bool { - if evt.JID.Server == types.DefaultUserServer || evt.JID.Server == types.HiddenUserServer || evt.JID.Server == types.BotServer { - go wa.syncGhost(evt.JID, "picture event", &evt.PictureID) - return true +func (wa *WhatsAppClient) handleWAPictureUpdate(evt *events.Picture) { + if evt.JID.Server == types.DefaultUserServer || evt.JID.Server == types.BotServer { + wa.syncGhost(evt.JID, "picture event", &evt.PictureID) } else { var changes bridgev2.ChatInfo if evt.Remove { @@ -688,7 +539,7 @@ func (wa *WhatsAppClient) handleWAPictureUpdate(ctx context.Context, evt *events } else { changes.ExtraUpdates = wa.makePortalAvatarFetcher(evt.PictureID, evt.Author, evt.Timestamp) } - return wa.UserLogin.QueueRemoteEvent(&simplevent.ChatInfoChange{ + wa.UserLogin.QueueRemoteEvent(&simplevent.ChatInfoChange{ EventMeta: simplevent.EventMeta{ Type: bridgev2.RemoteEventChatInfoChange, 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) }, PortalKey: wa.makeWAPortalKey(evt.JID), - Sender: wa.makeEventSender(ctx, evt.Author), + Sender: wa.makeEventSender(evt.Author), Timestamp: evt.Timestamp, }, ChatInfoChange: &bridgev2.ChatInfoChange{ ChatInfo: &changes, }, - }).Success + }) } } -func (wa *WhatsAppClient) handleWAGroupInfoChange(ctx context.Context, evt *events.GroupInfo) bool { +func (wa *WhatsAppClient) handleWAGroupInfoChange(evt *events.GroupInfo) { eventMeta := simplevent.EventMeta{ Type: bridgev2.RemoteEventChatInfoChange, LogContext: nil, @@ -718,59 +569,56 @@ func (wa *WhatsAppClient) handleWAGroupInfoChange(ctx context.Context, evt *even Timestamp: evt.Timestamp, } if evt.Sender != nil { - eventMeta.Sender = wa.makeEventSender(ctx, *evt.Sender) + eventMeta.Sender = wa.makeEventSender(*evt.Sender) } if evt.Delete != nil { eventMeta.Type = bridgev2.RemoteEventChatDelete - return wa.UserLogin.QueueRemoteEvent(&simplevent.ChatDelete{EventMeta: eventMeta}).Success + wa.UserLogin.QueueRemoteEvent(&simplevent.ChatDelete{EventMeta: eventMeta}) } else { - return wa.UserLogin.QueueRemoteEvent(&simplevent.ChatInfoChange{ + wa.UserLogin.QueueRemoteEvent(&simplevent.ChatInfoChange{ EventMeta: eventMeta, - ChatInfoChange: wa.wrapGroupInfoChange(ctx, evt), - }).Success + ChatInfoChange: wa.wrapGroupInfoChange(evt), + }) } } -func (wa *WhatsAppClient) handleWAJoinedGroup(ctx context.Context, evt *events.JoinedGroup) bool { - if wa.createDedup.Pop(evt.CreateKey) { - return true - } - return wa.UserLogin.QueueRemoteEvent(&simplevent.ChatResync{ +func (wa *WhatsAppClient) handleWAJoinedGroup(evt *events.JoinedGroup) { + wa.Main.Bridge.QueueRemoteEvent(wa.UserLogin, &simplevent.ChatResync{ EventMeta: simplevent.EventMeta{ Type: bridgev2.RemoteEventChatResync, LogContext: nil, PortalKey: wa.makeWAPortalKey(evt.JID), CreatePortal: true, }, - ChatInfo: wa.wrapGroupInfo(ctx, &evt.GroupInfo), - }).Success + ChatInfo: wa.wrapGroupInfo(&evt.GroupInfo), + }) } -func (wa *WhatsAppClient) handleWANewsletterJoin(ctx context.Context, evt *events.NewsletterJoin) bool { - return wa.UserLogin.QueueRemoteEvent(&simplevent.ChatResync{ +func (wa *WhatsAppClient) handleWANewsletterJoin(evt *events.NewsletterJoin) { + wa.Main.Bridge.QueueRemoteEvent(wa.UserLogin, &simplevent.ChatResync{ EventMeta: simplevent.EventMeta{ Type: bridgev2.RemoteEventChatResync, LogContext: nil, PortalKey: wa.makeWAPortalKey(evt.ID), CreatePortal: true, }, - ChatInfo: wa.wrapNewsletterInfo(ctx, &evt.NewsletterMetadata), - }).Success + ChatInfo: wa.wrapNewsletterInfo(&evt.NewsletterMetadata), + }) } -func (wa *WhatsAppClient) handleWANewsletterLeave(evt *events.NewsletterLeave) bool { - return wa.UserLogin.QueueRemoteEvent(&simplevent.ChatDelete{ +func (wa *WhatsAppClient) handleWANewsletterLeave(evt *events.NewsletterLeave) { + wa.Main.Bridge.QueueRemoteEvent(wa.UserLogin, &simplevent.ChatDelete{ EventMeta: simplevent.EventMeta{ Type: bridgev2.RemoteEventChatDelete, LogContext: nil, PortalKey: wa.makeWAPortalKey(evt.ID), }, OnlyForMe: true, - }).Success + }) } -func (wa *WhatsAppClient) handleWAUserLocalPortalInfo(chatJID types.JID, ts time.Time, info *bridgev2.UserLocalPortalInfo) bool { - return wa.UserLogin.QueueRemoteEvent(&simplevent.ChatInfoChange{ +func (wa *WhatsAppClient) handleWAUserLocalPortalInfo(chatJID types.JID, ts time.Time, info *bridgev2.UserLocalPortalInfo) { + wa.UserLogin.QueueRemoteEvent(&simplevent.ChatInfoChange{ EventMeta: simplevent.EventMeta{ Type: bridgev2.RemoteEventChatInfoChange, PortalKey: wa.makeWAPortalKey(chatJID), @@ -781,136 +629,40 @@ func (wa *WhatsAppClient) handleWAUserLocalPortalInfo(chatJID types.JID, ts time UserLocal: info, }, }, - }).Success + }) } -func (wa *WhatsAppClient) handleWAMute(evt *events.Mute) bool { +func (wa *WhatsAppClient) handleWAMute(evt *events.Mute) { var mutedUntil time.Time if evt.Action.GetMuted() { mutedUntil = event.MutedForever - if evt.Action.GetMuteEndTimestamp() > 0 { + if evt.Action.GetMuteEndTimestamp() != 0 { mutedUntil = time.Unix(evt.Action.GetMuteEndTimestamp(), 0) } } else { mutedUntil = bridgev2.Unmuted } - return wa.handleWAUserLocalPortalInfo(evt.JID, evt.Timestamp, &bridgev2.UserLocalPortalInfo{ + wa.handleWAUserLocalPortalInfo(evt.JID, evt.Timestamp, &bridgev2.UserLocalPortalInfo{ MutedUntil: &mutedUntil, }) } -func (wa *WhatsAppClient) handleWAArchive(evt *events.Archive) bool { +func (wa *WhatsAppClient) handleWAArchive(evt *events.Archive) { var tag event.RoomTag if evt.Action.GetArchived() { tag = wa.Main.Config.ArchiveTag } - return wa.handleWAUserLocalPortalInfo(evt.JID, evt.Timestamp, &bridgev2.UserLocalPortalInfo{ + wa.handleWAUserLocalPortalInfo(evt.JID, evt.Timestamp, &bridgev2.UserLocalPortalInfo{ Tag: &tag, }) } -func (wa *WhatsAppClient) handleWAPin(evt *events.Pin) bool { +func (wa *WhatsAppClient) handleWAPin(evt *events.Pin) { var tag event.RoomTag if evt.Action.GetPinned() { tag = wa.Main.Config.PinnedTag } - return wa.handleWAUserLocalPortalInfo(evt.JID, evt.Timestamp, &bridgev2.UserLocalPortalInfo{ + wa.handleWAUserLocalPortalInfo(evt.JID, evt.Timestamp, &bridgev2.UserLocalPortalInfo{ 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") - } - }() -} diff --git a/pkg/connector/id.go b/pkg/connector/id.go index c07e431..3b5d23d 100644 --- a/pkg/connector/id.go +++ b/pkg/connector/id.go @@ -1,9 +1,6 @@ package connector import ( - "context" - - "github.com/rs/zerolog" "go.mau.fi/util/ptr" "go.mau.fi/whatsmeow/proto/waCommon" "go.mau.fi/whatsmeow/types" @@ -28,30 +25,15 @@ func (wa *WhatsAppClient) makeWAPortalKey(chatJID types.JID) networkid.PortalKey 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 { // Send as bot 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{ IsFromMe: id.User == wa.GetStore().GetJID().User || id.User == wa.GetStore().GetLID().User, 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 } - -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 -} diff --git a/pkg/connector/login.go b/pkg/connector/login.go index 88e7b75..cc04b6f 100644 --- a/pkg/connector/login.go +++ b/pkg/connector/login.go @@ -2,7 +2,6 @@ package connector import ( "context" - "errors" "fmt" "net/http" "sync/atomic" @@ -10,7 +9,6 @@ import ( "github.com/rs/zerolog" "go.mau.fi/util/exsync" - "go.mau.fi/util/jsontime" "go.mau.fi/whatsmeow" "go.mau.fi/whatsmeow/types/events" waLog "go.mau.fi/whatsmeow/util/log" @@ -64,21 +62,6 @@ var ( Err: "Unexpected event while waiting for login", 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) { @@ -121,7 +104,6 @@ type WALogin struct { var ( _ bridgev2.LoginProcessDisplayAndWait = (*WALogin)(nil) _ bridgev2.LoginProcessUserInput = (*WALogin)(nil) - _ bridgev2.LoginProcessWithOverride = (*WALogin)(nil) ) 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 } -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) { ctx, cancel := context.WithTimeout(ctx, LoginConnectWait) 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") 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 { 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 } 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, }, Metadata: &waid.UserLoginMetadata{ + WALID: wl.LoginSuccess.LID.User, WADeviceID: wl.LoginSuccess.ID.Device, - LoggedInAt: jsontime.UnixNow(), Timezone: wl.Timezone, HistorySyncPortalsNeedCreating: true, @@ -359,7 +320,7 @@ func (wl *WALogin) Wait(ctx context.Context) (*bridgev2.LoginStep, error) { } 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{ Type: bridgev2.LoginStepTypeComplete, diff --git a/pkg/connector/mclient.go b/pkg/connector/mclient.go deleted file mode 100644 index 0930617..0000000 --- a/pkg/connector/mclient.go +++ /dev/null @@ -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 . - -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") - } -} diff --git a/pkg/connector/mediarequest.go b/pkg/connector/mediarequest.go index 2c382d1..e0f4c5b 100644 --- a/pkg/connector/mediarequest.go +++ b/pkg/connector/mediarequest.go @@ -137,7 +137,7 @@ func (wa *WhatsAppClient) sendMediaRequest(ctx context.Context, req *wadb.MediaR req.Status = wadb.MediaBackfillRequestStatusRequestSkipped return } - err = wa.sendMediaRequestDirect(ctx, req.MessageID, req.MediaKey) + err = wa.sendMediaRequestDirect(req.MessageID, req.MediaKey) if err != nil { log.Err(err).Msg("Failed to send media retry request") 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) if err != nil { 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, MessageSource: types.MessageSource{ IsFromMe: msgID.Sender.User == wa.JID.User, diff --git a/pkg/connector/proxy.go b/pkg/connector/proxy.go index 5ae103d..770d66d 100644 --- a/pkg/connector/proxy.go +++ b/pkg/connector/proxy.go @@ -58,12 +58,7 @@ func (wa *WhatsAppConnector) updateProxy(ctx context.Context, client *whatsmeow. } if proxy, err := wa.getProxy(reason); err != nil { return fmt.Errorf("failed to get proxy address: %w", err) - } else if proxy == "" { - return nil - } else if err = client.SetProxyAddress(proxy, whatsmeow.SetProxyOptions{ - OnlyLogin: wa.Config.ProxyOnlyLogin, - NoMedia: wa.Config.ProxyOnlyLogin, - }); err != nil { + } else if err = client.SetProxyAddress(proxy); err != nil { return fmt.Errorf("failed to set proxy address: %w", err) } zerolog.Ctx(ctx).Debug().Msg("Enabled proxy") diff --git a/pkg/connector/startchat.go b/pkg/connector/startchat.go index a0b0692..4f4ce64 100644 --- a/pkg/connector/startchat.go +++ b/pkg/connector/startchat.go @@ -18,24 +18,15 @@ package connector import ( "context" - "crypto/sha256" "errors" "fmt" "strings" - "time" - "github.com/rs/zerolog" - "go.mau.fi/util/ptr" - "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" - "maunium.net/go/mautrix/id" - "go.mau.fi/mautrix-whatsapp/pkg/msgconv" "go.mau.fi/mautrix-whatsapp/pkg/waid" ) @@ -44,7 +35,6 @@ var ( _ bridgev2.ContactListingNetworkAPI = (*WhatsAppClient)(nil) _ bridgev2.UserSearchingNetworkAPI = (*WhatsAppClient)(nil) _ bridgev2.GhostDMCreatingNetworkAPI = (*WhatsAppClient)(nil) - _ bridgev2.GroupCreatingNetworkAPI = (*WhatsAppClient)(nil) _ bridgev2.IdentifierValidatingNetwork = (*WhatsAppConnector)(nil) ) @@ -62,11 +52,9 @@ func looksEmaily(str string) bool { return false } -func (wa *WhatsAppClient) validateIdentifer(ctx context.Context, number string) (types.JID, error) { - if strings.HasSuffix(number, "@"+types.BotServer) || strings.HasSuffix(number, "@"+types.HiddenUserServer) { +func (wa *WhatsAppClient) validateIdentifer(number string) (types.JID, error) { + if strings.HasSuffix(number, "@"+types.BotServer) { 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) { jid, _ := types.ParseJID(number) @@ -76,7 +64,7 @@ func (wa *WhatsAppClient) validateIdentifer(ctx context.Context, number string) return types.EmptyJID, ErrInputLooksLikeEmail } else if wa.Client == nil || !wa.Client.IsLoggedIn() { 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) } else if len(resp) == 0 { 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) { - origJID := waid.ParseUserID(ghost.ID) - jid, err := wa.startChatLIDToPN(ctx, origJID) - if err != nil { - return nil, err - } - return wa.makeCreateChatResponse(ctx, jid, origJID), nil + return &bridgev2.CreateChatResponse{PortalKey: wa.makeWAPortalKey(waid.ParseUserID(ghost.ID))}, nil } func (wa *WhatsAppClient) ResolveIdentifier(ctx context.Context, identifier string, startChat bool) (*bridgev2.ResolveIdentifierResponse, error) { - origJID, err := wa.validateIdentifer(ctx, identifier) - if err != nil { - return nil, err - } - jid, err := wa.startChatLIDToPN(ctx, origJID) + jid, err := wa.validateIdentifer(identifier) if err != nil { return nil, err } @@ -160,16 +113,16 @@ func (wa *WhatsAppClient) ResolveIdentifier(ctx context.Context, identifier stri return &bridgev2.ResolveIdentifierResponse{ Ghost: ghost, UserID: waid.MakeUserID(jid), - Chat: wa.makeCreateChatResponse(ctx, jid, origJID), + Chat: &bridgev2.CreateChatResponse{PortalKey: wa.makeWAPortalKey(jid)}, }, nil } 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) { - return wa.getContactList(ctx, strings.ToLower(query), false) + return wa.getContactList(ctx, strings.ToLower(query)) } 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) } -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() { 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 { return nil, err } resp := make([]*bridgev2.ResolveIdentifierResponse, 0, len(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) { continue } @@ -205,165 +155,3 @@ func (wa *WhatsAppClient) getContactList(ctx context.Context, filter string, onl } 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 = ¶ms.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 -} diff --git a/pkg/connector/userinfo.go b/pkg/connector/userinfo.go index aa84938..f4e0d33 100644 --- a/pkg/connector/userinfo.go +++ b/pkg/connector/userinfo.go @@ -2,12 +2,9 @@ package connector import ( "context" - "crypto/sha256" "errors" "fmt" "math/rand/v2" - "regexp" - "strconv" "time" "github.com/rs/zerolog" @@ -20,39 +17,31 @@ import ( "maunium.net/go/mautrix/bridgev2/networkid" "maunium.net/go/mautrix/bridgev2/simplevent" - "go.mau.fi/mautrix-whatsapp/pkg/connector/wadb" "go.mau.fi/mautrix-whatsapp/pkg/waid" ) -var ResyncMinInterval = 7 * 24 * time.Hour -var ResyncLoopInterval = 4 * time.Hour -var ResyncJitterSeconds = 3600 +const resyncMinInterval = 7 * 24 * time.Hour +const resyncLoopInterval = 4 * time.Hour 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 } wa.resyncQueueLock.Lock() jid := waid.ParseUserID(ghost.ID) if _, exists := wa.resyncQueue[jid]; !exists { wa.resyncQueue[jid] = resyncQueueItem{ghost: ghost} - nextResyncIn := time.Until(wa.nextResync).String() - if wa.nextResync.IsZero() { - nextResyncIn = "never" - } wa.UserLogin.Log.Debug(). Stringer("jid", jid). - Str("next_resync_in", nextResyncIn). + Stringer("next_resync_in", time.Until(wa.nextResync)). Msg("Enqueued resync for ghost") } wa.resyncQueueLock.Unlock() } -func (wa *WhatsAppClient) EnqueuePortalResync(portal *bridgev2.Portal, allowDM bool) { +func (wa *WhatsAppClient) EnqueuePortalResync(portal *bridgev2.Portal) { jid, _ := waid.ParsePortalID(portal.ID) - if portal.Metadata.(*waid.PortalMetadata).LastSync.Add(ResyncMinInterval).After(time.Now()) { - return - } else if !allowDM && jid.Server != types.GroupServer { + if jid.Server != types.GroupServer || portal.Metadata.(*waid.PortalMetadata).LastSync.Add(resyncMinInterval).After(time.Now()) { return } wa.resyncQueueLock.Lock() @@ -69,7 +58,7 @@ func (wa *WhatsAppClient) EnqueuePortalResync(portal *bridgev2.Portal, allowDM b func (wa *WhatsAppClient) ghostResyncLoop(ctx context.Context) { log := wa.UserLogin.Log.With().Str("action", "ghost resync loop").Logger() 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)) log.Info().Time("first_resync", wa.nextResync).Msg("Ghost resync queue starting") for { @@ -92,7 +81,7 @@ func (wa *WhatsAppClient) ghostResyncLoop(ctx context.Context) { func (wa *WhatsAppClient) rotateResyncQueue() map[types.JID]resyncQueueItem { wa.resyncQueueLock.Lock() defer wa.resyncQueueLock.Unlock() - wa.nextResync = time.Now().Add(ResyncLoopInterval) + wa.nextResync = time.Now().Add(resyncLoopInterval) if len(wa.resyncQueue) == 0 { return nil } @@ -119,7 +108,7 @@ func (wa *WhatsAppClient) doGhostResync(ctx context.Context, queue map[types.JID } else if item.portal != nil { lastSync = item.portal.Metadata.(*waid.PortalMetadata).LastSync.Time } - if lastSync.Add(ResyncMinInterval).After(time.Now()) { + if lastSync.Add(resyncMinInterval).After(time.Now()) { log.Debug(). Stringer("jid", jid). Time("last_sync", lastSync). @@ -134,7 +123,7 @@ func (wa *WhatsAppClient) doGhostResync(ctx context.Context, queue map[types.JID } } for _, portal := range portals { - wa.UserLogin.QueueRemoteEvent(&simplevent.ChatResync{ + wa.Main.Bridge.QueueRemoteEvent(wa.UserLogin, &simplevent.ChatResync{ EventMeta: simplevent.EventMeta{ Type: bridgev2.RemoteEventChatResync, LogContext: func(c zerolog.Context) zerolog.Context { @@ -149,7 +138,7 @@ func (wa *WhatsAppClient) doGhostResync(ctx context.Context, queue map[types.JID return } 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 { log.Err(err).Msg("Failed to get user info for background sync") return @@ -167,12 +156,11 @@ func (wa *WhatsAppClient) doGhostResync(ctx context.Context, queue map[types.JID continue } ghost.UpdateInfo(ctx, userInfo) - wa.syncAltGhostWithInfo(ctx, jid, userInfo) } } func (wa *WhatsAppClient) GetUserInfo(ctx context.Context, ghost *bridgev2.Ghost) (*bridgev2.UserInfo, error) { - if ghost.Name != "" && ghost.NameSet { + if ghost.Name != "" { wa.EnqueueGhostResync(ghost) 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) { - contact, err := wa.GetStore().Contacts.GetContact(ctx, jid) + contact, err := wa.GetStore().Contacts.GetContact(jid) if err != nil { return nil, err } @@ -191,33 +179,30 @@ 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 { if jid == types.MetaAIJID && contact.PushName == jid.User { contact.PushName = "Meta AI" - } else if jid == types.LegacyPSAJID || jid == types.PSAJID { + } else if jid == types.PSAJID { contact.PushName = "WhatsApp" } - var altJID types.JID - if jid.Server == types.DefaultUserServer || jid.Server == types.HiddenUserServer { - var err error - altJID, err = wa.GetStore().GetAltJID(ctx, jid) + var phone string + if jid.Server == types.DefaultUserServer { + phone = "+" + jid.User + } else if jid.Server == types.HiddenUserServer { + pnJID, err := wa.GetStore().LIDs.GetPNForLID(ctx, jid) if err != nil { - zerolog.Ctx(ctx).Err(err).Stringer("source_jid", jid).Msg("Failed to get alt JID") - } else if altJID.IsEmpty() { - zerolog.Ctx(ctx).Debug().Stringer("source_jid", jid).Msg("Alternate JID not found in contactToUserInfo") + zerolog.Ctx(ctx).Err(err).Stringer("lid", jid).Msg("Failed to get PN for LID") } else { - extraContact, err := wa.GetStore().Contacts.GetContact(ctx, altJID) + phone = "+" + pnJID.User + extraContact, err := wa.GetStore().Contacts.GetContact(pnJID) if err != nil { zerolog.Ctx(ctx).Err(err). - Stringer("source_jid", jid). - Stringer("alt_jid", altJID). - Msg("Failed to get contact info from alternate JID") + Stringer("lid", jid). + Stringer("pn_jid", pnJID). + Msg("Failed to get contact info from PN") } else { - // Phone contact info should only be stored for phone number JIDs - if altJID.Server == types.DefaultUserServer { - if contact.FirstName == "" { - contact.FirstName = extraContact.FirstName - } - if contact.FullName == "" { - contact.FullName = extraContact.FullName - } + if contact.FirstName == "" { + contact.FirstName = extraContact.FirstName + } + if contact.FullName == "" { + contact.FullName = extraContact.FullName } if contact.PushName == "" { contact.PushName = extraContact.PushName @@ -225,46 +210,17 @@ func (wa *WhatsAppClient) contactToUserInfo(ctx context.Context, jid types.JID, if contact.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{ Name: ptr.Ptr(wa.Main.Config.FormatDisplayname(jid, phone, contact)), IsBot: ptr.Ptr(jid.IsBot()), + Identifiers: []string{fmt.Sprintf("tel:+%s", jid.User)}, ExtraUpdates: updateGhostLastSyncAt, } if jid.Server == types.BotServer { ui.Identifiers = []string{} - } else if phone != "" { - ui.Identifiers = []string{fmt.Sprintf("tel:%s", phone)} } if getAvatar { 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 { 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() 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 { jid := waid.ParseUserID(ghost.ID) existingID := string(ghost.AvatarID) @@ -331,7 +242,7 @@ func (wa *WhatsAppClient) fetchGhostAvatar(ctx context.Context, ghost *bridgev2. existingID = "" } 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) { wrappedAvatar = &bridgev2.Avatar{ ID: "remove", @@ -347,90 +258,32 @@ func (wa *WhatsAppClient) fetchGhostAvatar(ctx context.Context, ghost *bridgev2. return false } else if avatar == nil { 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 { wrappedAvatar = &bridgev2.Avatar{ ID: networkid.AvatarID(avatar.ID), 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) } -func (wa *WhatsAppClient) resyncContacts(forceAvatarSync, automatic bool) { +func (wa *WhatsAppClient) resyncContacts(forceAvatarSync bool) { log := wa.UserLogin.Log.With().Str("action", "resync contacts").Logger() - ctx := log.WithContext(wa.Main.Bridge.BackgroundCtx) - if automatic && wa.isNewLogin { - 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) + ctx := log.WithContext(context.Background()) + contacts, err := wa.GetStore().Contacts.GetAllContacts() if err != nil { log.Err(err).Msg("Failed to get cached contacts") return } log.Info().Int("contact_count", len(contacts)).Msg("Resyncing displaynames with contact info") - for jid := range contacts { - if ctx.Err() != nil { - return - } + for jid, contact := range contacts { ghost, err := wa.Main.Bridge.GetGhostByID(ctx, waid.MakeUserID(jid)) if err != nil { - log.Err(err).Stringer("jid", jid).Msg("Failed to get ghost") - // Refetch contact info from the store to reduce the risk of races. - // This should always hit the cache. - } 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) + log.Err(err).Msg("Failed to get ghost") + } else if ghost != nil { + ghost.UpdateInfo(ctx, wa.contactToUserInfo(ctx, jid, contact, forceAvatarSync || ghost.AvatarID == "")) } } } - -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) -} diff --git a/pkg/connector/wadb/avatarcache.go b/pkg/connector/wadb/avatarcache.go deleted file mode 100644 index 8ea947f..0000000 --- a/pkg/connector/wadb/avatarcache.go +++ /dev/null @@ -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} -} diff --git a/pkg/connector/wadb/conversation.go b/pkg/connector/wadb/conversation.go index 85c7f44..29694d7 100644 --- a/pkg/connector/wadb/conversation.go +++ b/pkg/connector/wadb/conversation.go @@ -6,7 +6,6 @@ import ( "time" "go.mau.fi/util/dbutil" - "go.mau.fi/util/jsontime" "go.mau.fi/util/ptr" "go.mau.fi/whatsmeow/proto/waHistorySync" "go.mau.fi/whatsmeow/types" @@ -31,6 +30,7 @@ type Conversation struct { EphemeralSettingTimestamp *int64 MarkedAsUnread *bool UnreadCount *uint32 + Bridged bool } func parseHistoryTime(ts *uint64) time.Time { @@ -69,9 +69,9 @@ const ( INSERT INTO whatsapp_history_sync_conversation ( 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, - 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) DO UPDATE SET last_message_timestamp=CASE @@ -87,15 +87,16 @@ const ( 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), 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 = ` SELECT 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, - unread_count + unread_count, bridged 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 LIMIT $3 ` @@ -103,7 +104,7 @@ const ( SELECT 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, - unread_count + unread_count, bridged FROM whatsapp_history_sync_conversation WHERE bridge_id=$1 AND user_login_id=$2 AND chat_jid=$3 ` @@ -112,9 +113,9 @@ const ( DELETE FROM whatsapp_history_sync_conversation WHERE bridge_id=$1 AND user_login_id=$2 AND chat_jid=$3 ` - markConversationSynced = ` + markConversationBridged = ` 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 ` ) @@ -124,19 +125,17 @@ func (cq *ConversationQuery) Put(ctx context.Context, conv *Conversation) error return cq.Exec(ctx, upsertHistorySyncConversationQuery, conv.sqlVariables()...) } -func (cq *ConversationQuery) GetRecent( - ctx context.Context, loginID networkid.UserLoginID, limit int, notSyncedAfter jsontime.Unix, -) ([]*Conversation, error) { +func (cq *ConversationQuery) GetRecent(ctx context.Context, loginID networkid.UserLoginID, limit int) ([]*Conversation, error) { limitPtr := &limit // Negative limit on SQLite means unlimited, but Postgres prefers a NULL limit. if limit < 0 && cq.GetDB().Dialect == dbutil.Postgres { 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 { - return cq.Exec(ctx, markConversationSynced, cq.BridgeID, loginID, chatJID, loginTS) +func (cq *ConversationQuery) MarkBridged(ctx context.Context, loginID networkid.UserLoginID, chatJID types.JID) error { + return cq.Exec(ctx, markConversationBridged, cq.BridgeID, loginID, chatJID) } 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.MarkedAsUnread, c.UnreadCount, + c.Bridged, } } @@ -190,6 +190,7 @@ func (c *Conversation) Scan(row dbutil.Scannable) (*Conversation, error) { &c.EphemeralSettingTimestamp, &c.MarkedAsUnread, &c.UnreadCount, + &c.Bridged, ) if err != nil { return nil, err diff --git a/pkg/connector/wadb/database.go b/pkg/connector/wadb/database.go index 9d13568..f6f2655 100644 --- a/pkg/connector/wadb/database.go +++ b/pkg/connector/wadb/database.go @@ -14,8 +14,6 @@ type Database struct { Message *MessageQuery PollOption *PollOptionQuery MediaRequest *MediaRequestQuery - HSNotif *HistorySyncNotificationQuery - AvatarCache *AvatarCacheQuery } 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{} }), }, - HSNotif: &HistorySyncNotificationQuery{ - BridgeID: bridgeID, - Database: db, - }, - AvatarCache: &AvatarCacheQuery{ - QueryHelper: dbutil.MakeQueryHelper(db, func(_ *dbutil.QueryHelper[*AvatarCacheEntry]) *AvatarCacheEntry { - return &AvatarCacheEntry{} - }), - }, } } diff --git a/pkg/connector/wadb/hsnotif.go b/pkg/connector/wadb/hsnotif.go deleted file mode 100644 index 0221cdd..0000000 --- a/pkg/connector/wadb/hsnotif.go +++ /dev/null @@ -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 . - -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, ¬ifBytes) - 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, ¬if); err != nil { - return nil, 0, err - } - return ¬if, rowid, nil -} - -func (hsnq *HistorySyncNotificationQuery) Delete(ctx context.Context, rowid int) error { - _, err := hsnq.Exec(ctx, deleteHSNotificationQuery, rowid) - return err -} diff --git a/pkg/connector/wadb/message.go b/pkg/connector/wadb/message.go index 4b16002..f2450c1 100644 --- a/pkg/connector/wadb/message.go +++ b/pkg/connector/wadb/message.go @@ -116,12 +116,9 @@ func (mq *MessageQuery) GetBetween(ctx context.Context, loginID networkid.UserLo AsList() } -func (mq *MessageQuery) DeleteBetween(ctx context.Context, loginID networkid.UserLoginID, chatJID types.JID, before, after uint64) (int64, error) { - res, err := mq.Exec(ctx, deleteHistorySyncMessagesBetweenQuery, mq.BridgeID, loginID, chatJID, before, after) - if err != nil { - return 0, err - } - return res.RowsAffected() +func (mq *MessageQuery) DeleteBetween(ctx context.Context, loginID networkid.UserLoginID, chatJID types.JID, before, after uint64) error { + _, err := mq.Exec(ctx, deleteHistorySyncMessagesBetweenQuery, mq.BridgeID, loginID, chatJID, before, after) + return err } 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 } -func (mq *MessageQuery) DeleteAllInChat(ctx context.Context, loginID networkid.UserLoginID, chatJID types.JID) (int64, error) { - res, err := mq.Exec(ctx, deleteHistorySyncMessagesForPortalQuery, mq.BridgeID, loginID, chatJID) - if err != nil { - return 0, err - } - return res.RowsAffected() +func (mq *MessageQuery) DeleteAllInChat(ctx context.Context, loginID networkid.UserLoginID, chatJID types.JID) error { + _, err := mq.Exec(ctx, deleteHistorySyncMessagesForPortalQuery, mq.BridgeID, loginID, chatJID) + return err } func (mq *MessageQuery) ConversationHasMessages(ctx context.Context, loginID networkid.UserLoginID, chatJID types.JID) (exists bool, err error) { diff --git a/pkg/connector/wadb/upgrades/00-latest-schema.sql b/pkg/connector/wadb/upgrades/00-latest-schema.sql index 850f5b4..dbc41d1 100644 --- a/pkg/connector/wadb/upgrades/00-latest-schema.sql +++ b/pkg/connector/wadb/upgrades/00-latest-schema.sql @@ -1,4 +1,4 @@ --- v0 -> v9 (compatible with v3+): Latest revision +-- v0 -> v4 (compatible with v3+): Latest revision CREATE TABLE whatsapp_poll_option_id ( bridge_id TEXT NOT NULL, @@ -26,7 +26,8 @@ CREATE TABLE whatsapp_history_sync_conversation ( ephemeral_setting_timestamp BIGINT, marked_as_unread BOOLEAN, unread_count INTEGER, - synced_login_ts BIGINT, + + bridged BOOLEAN NOT NULL DEFAULT false, PRIMARY KEY (bridge_id, user_login_id, chat_jid), 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_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) -); diff --git a/pkg/connector/wadb/upgrades/05-history-sync-notification.sql b/pkg/connector/wadb/upgrades/05-history-sync-notification.sql deleted file mode 100644 index d444d43..0000000 --- a/pkg/connector/wadb/upgrades/05-history-sync-notification.sql +++ /dev/null @@ -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); diff --git a/pkg/connector/wadb/upgrades/06-conversation-bridged-ts.sql b/pkg/connector/wadb/upgrades/06-conversation-bridged-ts.sql deleted file mode 100644 index 8d8001b..0000000 --- a/pkg/connector/wadb/upgrades/06-conversation-bridged-ts.sql +++ /dev/null @@ -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; diff --git a/pkg/connector/wadb/upgrades/07-direct-media-avatar-cache.sql b/pkg/connector/wadb/upgrades/07-direct-media-avatar-cache.sql deleted file mode 100644 index 7dd9dba..0000000 --- a/pkg/connector/wadb/upgrades/07-direct-media-avatar-cache.sql +++ /dev/null @@ -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) -); diff --git a/pkg/connector/wadb/upgrades/08-may-need-lid-dm-deletion.sql b/pkg/connector/wadb/upgrades/08-may-need-lid-dm-deletion.sql deleted file mode 100644 index d5b4ca0..0000000 --- a/pkg/connector/wadb/upgrades/08-may-need-lid-dm-deletion.sql +++ /dev/null @@ -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'); diff --git a/pkg/connector/wadb/upgrades/09-may-need-lid-dm-deletion-again.sql b/pkg/connector/wadb/upgrades/09-may-need-lid-dm-deletion-again.sql deleted file mode 100644 index 5c32e65..0000000 --- a/pkg/connector/wadb/upgrades/09-may-need-lid-dm-deletion-again.sql +++ /dev/null @@ -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'); diff --git a/pkg/connector/wamsgtype.go b/pkg/connector/wamsgtype.go index 92e7f86..7a8547c 100644 --- a/pkg/connector/wamsgtype.go +++ b/pkg/connector/wamsgtype.go @@ -124,6 +124,10 @@ func getMessageType(waMsg *waE2E.Message) string { case waMsg.EncEventResponseMessage != nil: return "ignore" // these are ignored for now as they're not meant to be shown as new messages //return "encrypted event response" + case waMsg.CommentMessage != nil: + return "comment" + case waMsg.EncCommentMessage != nil: + return "encrypted comment" case waMsg.NewsletterAdminInviteMessage != nil: return "newsletter admin invite" case waMsg.SecretEncryptedMessage != nil: diff --git a/pkg/msgconv/from-matrix.go b/pkg/msgconv/from-matrix.go index 93de90c..9b9b529 100644 --- a/pkg/msgconv/from-matrix.go +++ b/pkg/msgconv/from-matrix.go @@ -19,18 +19,15 @@ package msgconv import ( "bytes" "context" - "encoding/base64" - "encoding/json" "errors" "fmt" "image" "image/color" - "image/jpeg" + "image/png" "net/http" "slices" "strconv" "strings" - "time" "github.com/rs/zerolog" "go.mau.fi/util/ffmpeg" @@ -40,6 +37,7 @@ import ( "go.mau.fi/whatsmeow" "go.mau.fi/whatsmeow/proto/waE2E" "go.mau.fi/whatsmeow/types" + "golang.org/x/image/webp" "google.golang.org/protobuf/proto" "maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2/database" @@ -50,13 +48,7 @@ import ( "go.mau.fi/mautrix-whatsapp/pkg/waid" ) -func (mc *MessageConverter) generateContextInfo( - ctx context.Context, - replyTo *database.Message, - portal *bridgev2.Portal, - perMessageTimer *event.BeeperDisappearingTimer, - roomMention bool, -) *waE2E.ContextInfo { +func (mc *MessageConverter) generateContextInfo(ctx context.Context, replyTo *database.Message, portal *bridgev2.Portal) *waE2E.ContextInfo { contextInfo := &waE2E.ContextInfo{} if replyTo != nil { msgID, err := waid.ParseMessageID(replyTo.ID) @@ -64,7 +56,6 @@ func (mc *MessageConverter) generateContextInfo( contextInfo.StanzaID = proto.String(msgID.ID) contextInfo.Participant = proto.String(msgID.Sender.String()) contextInfo.QuotedMessage = &waE2E.Message{Conversation: proto.String("")} - contextInfo.QuotedType = waE2E.ContextInfo_EXPLICIT.Enum() } else { zerolog.Ctx(ctx).Warn().Err(err). Stringer("reply_to_event_id", replyTo.MXID). @@ -72,21 +63,12 @@ func (mc *MessageConverter) generateContextInfo( Msg("Failed to parse reply to message ID") } } - var timer time.Duration - if perMessageTimer != nil { - 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 - if setAt > 0 && contextInfo.Expiration != nil { - contextInfo.EphemeralSettingTimestamp = ptr.Ptr(setAt) - } - if roomMention { - contextInfo.NonJIDMentions = proto.Uint32(1) + if portal.Disappear.Timer > 0 { + contextInfo.Expiration = ptr.Ptr(uint32(portal.Disappear.Timer.Seconds())) + setAt := portal.Metadata.(*waid.PortalMetadata).DisappearingTimerSetAt + if setAt > 0 { + contextInfo.EphemeralSettingTimestamp = ptr.Ptr(setAt) + } } return contextInfo } @@ -107,15 +89,11 @@ func (mc *MessageConverter) ToWhatsApp( } 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 { case event.MsgText, event.MsgNotice, event.MsgEmote: - var err error - message, err = mc.constructTextMessage(ctx, content, evt.Content.Raw, contextInfo) - if err != nil { - return nil, nil, err - } + message = mc.constructTextMessage(ctx, content, contextInfo) case event.MessageType(event.EventSticker.Type), event.MsgImage, event.MsgVideo, event.MsgAudio, event.MsgFile: uploaded, thumbnail, mime, err := mc.reuploadFileToWhatsApp(ctx, content) if err != nil { @@ -144,7 +122,7 @@ func (mc *MessageConverter) ToWhatsApp( return nil, nil, fmt.Errorf("failed to parse message ID: %w", err) } rootMsgInfo := MessageIDToInfo(client, parsedID) - message, err = client.EncryptComment(ctx, rootMsgInfo, message) + message, err = client.EncryptComment(rootMsgInfo, message) if err != nil { return nil, nil, fmt.Errorf("failed to encrypt comment: %w", err) } @@ -202,7 +180,6 @@ func (mc *MessageConverter) constructMediaMessage( FileSHA256: uploaded.FileSHA256, FileLength: proto.Uint64(uploaded.FileLength), URL: proto.String(uploaded.URL), - IsLottie: proto.Bool(mime == "application/was"), }, } case event.MsgAudio: @@ -272,14 +249,9 @@ func (mc *MessageConverter) constructMediaMessage( }, } case event.MsgFile: - fileName := content.FileName - if fileName == "" { - fileName = content.Body - } - msg := &waE2E.Message{ DocumentMessage: &waE2E.DocumentMessage{ - FileName: proto.String(fileName), + FileName: proto.String(content.FileName), Caption: proto.String(caption), JPEGThumbnail: thumbnail, @@ -321,16 +293,7 @@ func (mc *MessageConverter) parseText(ctx context.Context, content *event.Messag return } -func (mc *MessageConverter) constructTextMessage( - 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) - } +func (mc *MessageConverter) constructTextMessage(ctx context.Context, content *event.MessageEventContent, contextInfo *waE2E.ContextInfo) *waE2E.Message { text, mentions := mc.parseText(ctx, content) if len(mentions) > 0 { contextInfo.MentionedJID = mentions @@ -341,44 +304,7 @@ func (mc *MessageConverter) constructTextMessage( } mc.convertURLPreviewToWhatsApp(ctx, content, etm) - return &waE2E.Message{ExtendedTextMessage: etm}, nil -} - -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 + return &waE2E.Message{ExtendedTextMessage: etm} } 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) } -func (mc *MessageConverter) convertToJPEG(webpImage []byte) ([]byte, error) { - decoded, _, err := image.Decode(bytes.NewReader(webpImage)) +func (mc *MessageConverter) convertWebPtoPNG(webpImage []byte) ([]byte, error) { + webpDecoded, err := webp.Decode(bytes.NewReader(webpImage)) if err != nil { return nil, fmt.Errorf("failed to decode webp image: %w", err) } - var jpgBuffer bytes.Buffer - if err = jpeg.Encode(&jpgBuffer, decoded, &jpeg.Options{Quality: 80}); err != nil { + var pngBuffer bytes.Buffer + if err = png.Encode(&pngBuffer, webpDecoded); err != nil { 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) { @@ -484,17 +410,6 @@ func (mc *MessageConverter) convertToWebP(img []byte) ([]byte, int, error) { 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( ctx context.Context, content *event.MessageEventContent, ) (*whatsmeow.UploadResponse, []byte, string, error) { @@ -503,25 +418,7 @@ func (mc *MessageConverter) reuploadFileToWhatsApp( if content.FileName != "" { fileName = content.FileName } - var data []byte - 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) - } + data, err := mc.Bridge.Bot.DownloadMedia(ctx, content.URL, content.File) if err != nil { return nil, nil, "", fmt.Errorf("%w: %w", bridgev2.ErrMediaDownloadFailed, err) } @@ -539,39 +436,27 @@ func (mc *MessageConverter) reuploadFileToWhatsApp( case event.MessageType(event.EventSticker.Type): isSticker = true mediaType = whatsmeow.MediaImage - if mime == "video/lottie+json" { - // 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" { + if mime != "image/webp" || content.Info.Width != content.Info.Height { var size int data, size, err = mc.convertToWebP(data) if err != nil { - if mime != "image/webp" { - 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.Height = size - mime = "image/webp" + return nil, nil, "image/webp", fmt.Errorf("%w (to webp): %w", bridgev2.ErrMediaConvertFailed, err) } + content.Info.Width = size + content.Info.Height = size + mime = "image/webp" } case event.MsgImage: mediaType = whatsmeow.MediaImage switch mime { - case "image/jpeg": + case "image/jpeg", "image/png": // allowed - case "image/webp", "image/png": - data, err = mc.convertToJPEG(data) + case "image/webp": + data, err = mc.convertWebPtoPNG(data) if err != nil { return nil, nil, "image/webp", fmt.Errorf("%w (webp to png): %s", bridgev2.ErrMediaConvertFailed, err) } - mime = "image/jpeg" + mime = "image/png" default: return nil, nil, mime, fmt.Errorf("%w %s in image message", bridgev2.ErrUnsupportedMediaType, mime) } @@ -579,17 +464,13 @@ func (mc *MessageConverter) reuploadFileToWhatsApp( switch mime { case "video/mp4", "video/3gpp": // allowed - case "video/webm", "video/quicktime": - sourceFormat := "webm" - if mime == "video/quicktime" { - sourceFormat = "mov" - } - data, err = ffmpeg.ConvertBytes(ctx, data, ".mp4", []string{"-f", sourceFormat}, []string{ + case "video/webm": + data, err = ffmpeg.ConvertBytes(ctx, data, ".mp4", []string{"-f", "webm"}, []string{ "-pix_fmt", "yuv420p", "-c:v", "libx264", "-filter:v", "crop='floor(in_w/2)*2:floor(in_h/2)*2'", }, mime) 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" case "image/gif": diff --git a/pkg/msgconv/from-whatsapp.go b/pkg/msgconv/from-whatsapp.go index 11569e2..3bb824e 100644 --- a/pkg/msgconv/from-whatsapp.go +++ b/pkg/msgconv/from-whatsapp.go @@ -33,6 +33,7 @@ import ( "go.mau.fi/whatsmeow/types" _ "golang.org/x/image/webp" "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/database" "maunium.net/go/mautrix/bridgev2/networkid" "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/id" @@ -46,7 +47,6 @@ const ( contextKeyClient contextKey = iota contextKeyIntent contextKeyPortal - ContextKeyEditTargetID ) func getClient(ctx context.Context) *whatsmeow.Client { @@ -61,38 +61,14 @@ func getPortal(ctx context.Context) *bridgev2.Portal { return ctx.Value(contextKeyPortal).(*bridgev2.Portal) } -func getEditTargetID(ctx context.Context) types.MessageID { - editID, _ := ctx.Value(ContextKeyEditTargetID).(types.MessageID) - 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)) +func (mc *MessageConverter) getBasicUserInfo(ctx context.Context, user networkid.UserID) (id.UserID, string, error) { + ghost, err := mc.Bridge.GetGhostByID(ctx, user) if err != nil { return "", "", fmt.Errorf("failed to get ghost by ID: %w", err) } - var pnJID types.JID - if user.Server == types.DefaultUserServer { - 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 - } + login := mc.Bridge.GetCachedUserLoginByID(networkid.UserLoginID(user)) + if login != nil { + return login.UserMXID, 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") continue } - mxid, displayname, err := mc.getBasicUserInfo(ctx, parsed) + mxid, displayname, err := mc.getBasicUserInfo(ctx, waid.MakeUserID(parsed)) if err != nil { zerolog.Ctx(ctx).Err(err).Str("jid", jid).Msg("Failed to get user info") continue @@ -134,15 +110,10 @@ func (mc *MessageConverter) ToMatrix( client *whatsmeow.Client, intent bridgev2.MatrixAPI, waMsg *waE2E.Message, - rawWaMsg *waE2E.Message, info *types.MessageInfo, isViewOnce bool, - isBackfill bool, previouslyConvertedPart *bridgev2.ConvertedMessagePart, ) *bridgev2.ConvertedMessage { - if waMsg == nil { - waMsg = &waE2E.Message{} - } ctx = context.WithValue(ctx, contextKeyClient, client) ctx = context.WithValue(ctx, contextKeyIntent, intent) ctx = context.WithValue(ctx, contextKeyPortal, portal) @@ -179,12 +150,6 @@ func (mc *MessageConverter) ToMatrix( part, contextInfo = mc.convertPollUpdateMessage(ctx, info, waMsg.PollUpdateMessage) case waMsg.EventMessage != nil: 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: part, contextInfo = mc.convertMediaMessage(ctx, waMsg.ImageMessage, "photo", info, isViewOnce, previouslyConvertedPart) case waMsg.StickerMessage != nil: @@ -201,8 +166,6 @@ func (mc *MessageConverter) ToMatrix( part, contextInfo = mc.convertMediaMessage(ctx, waMsg.AudioMessage, typeName, info, isViewOnce, previouslyConvertedPart) case waMsg.DocumentMessage != nil: 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: part, contextInfo = mc.convertLocationMessage(ctx, waMsg.LocationMessage) case waMsg.LiveLocationMessage != nil: @@ -216,11 +179,11 @@ func (mc *MessageConverter) ToMatrix( case waMsg.GroupInviteMessage != nil: part, contextInfo = mc.convertGroupInviteMessage(ctx, info, waMsg.GroupInviteMessage) 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: part = failedCommentPart default: - part, contextInfo = mc.convertUnknownMessage(ctx, rawWaMsg) + part, contextInfo = mc.convertUnknownMessage(ctx, waMsg) } part.Content.Mentions = &event.Mentions{} @@ -237,25 +200,16 @@ func (mc *MessageConverter) ToMatrix( part.Extra["fi.mau.whatsapp.source_broadcast_list"] = info.Chat.String() } mc.addMentions(ctx, contextInfo.GetMentionedJID(), part.Content) - if contextInfo.GetNonJIDMentions() == 1 { - part.Content.Mentions.Room = true - } cm := &bridgev2.ConvertedMessage{ Parts: []*bridgev2.ConvertedMessagePart{part}, } if contextInfo.GetExpiration() > 0 { cm.Disappear.Timer = time.Duration(contextInfo.GetExpiration()) * time.Second - cm.Disappear.Type = event.DisappearingTypeAfterSend - } - if portal.Disappear.Timer != cm.Disappear.Timer && portal.Metadata.(*waid.PortalMetadata).DisappearingTimerSetAt < contextInfo.GetEphemeralSettingTimestamp() { - portal.UpdateDisappearingSetting(ctx, cm.Disappear, bridgev2.UpdateDisappearingSettingOpts{ - Sender: intent, - Timestamp: info.Timestamp, - Implicit: true, - Save: true, - SendNotice: true, - }) + cm.Disappear.Type = database.DisappearingTypeAfterRead + if portal.Disappear.Timer != cm.Disappear.Timer && portal.Metadata.(*waid.PortalMetadata).DisappearingTimerSetAt < contextInfo.GetEphemeralSettingTimestamp() { + portal.UpdateDisappearingSetting(ctx, cm.Disappear, intent, info.Timestamp, true, true) + } } if contextInfo.GetStanzaID() != "" { pcp, _ := types.ParseJID(contextInfo.GetParticipant()) @@ -263,27 +217,6 @@ func (mc *MessageConverter) ToMatrix( if chat.IsEmpty() { 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{ MessageID: waid.MakeMessageID(chat, pcp, contextInfo.GetStanzaID()), } diff --git a/pkg/msgconv/matrixpoll.go b/pkg/msgconv/matrixpoll.go index 0dc3213..b4b20b1 100644 --- a/pkg/msgconv/matrixpoll.go +++ b/pkg/msgconv/matrixpoll.go @@ -71,7 +71,7 @@ func (mc *MessageConverter) PollStartToWhatsApp( if maxAnswers >= len(content.PollStart.Answers) || 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 question, contextInfo.MentionedJID = mc.msc1767ToWhatsApp(ctx, content.PollStart.Question, content.Mentions) 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, }) return &waE2E.Message{PollUpdateMessage: pollUpdate}, err diff --git a/pkg/msgconv/msgconv.go b/pkg/msgconv/msgconv.go index 185ee0f..e4109c8 100644 --- a/pkg/msgconv/msgconv.go +++ b/pkg/msgconv/msgconv.go @@ -17,9 +17,6 @@ package msgconv import ( - "sync" - - "go.mau.fi/whatsmeow/types" "maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/format" @@ -46,16 +43,12 @@ type MessageConverter struct { DisableViewOnce bool DirectMedia bool OldMediaSuffix string - - stickerPackCache map[string]*types.StickerPack - stickerPackCacheLock sync.Mutex } func New(br *bridgev2.Bridge) *MessageConverter { mc := &MessageConverter{ - Bridge: br, - MaxFileSize: 50 * 1024 * 1024, - stickerPackCache: make(map[string]*types.StickerPack), + Bridge: br, + MaxFileSize: 50 * 1024 * 1024, } mc.HTMLParser = &format.HTMLParser{ PillConverter: mc.convertPill, diff --git a/pkg/msgconv/urlpreview.go b/pkg/msgconv/urlpreview.go index 4bce93b..62ac312 100644 --- a/pkg/msgconv/urlpreview.go +++ b/pkg/msgconv/urlpreview.go @@ -51,7 +51,7 @@ func (mc *MessageConverter) convertURLPreviewToBeeper(ctx context.Context, msg * var thumbnailData []byte if msg.ThumbnailDirectPath != nil { var err error - thumbnailData, err = getClient(ctx).DownloadThumbnail(ctx, msg) + thumbnailData, err = getClient(ctx).DownloadThumbnail(msg) if err != nil { 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 } if thumbnailData != nil { - output.ImageHeight = event.IntOrString(msg.GetThumbnailHeight()) - output.ImageWidth = event.IntOrString(msg.GetThumbnailWidth()) + output.ImageHeight = int(msg.GetThumbnailHeight()) + output.ImageWidth = int(msg.GetThumbnailWidth()) if output.ImageHeight == 0 || output.ImageWidth == 0 { src, _, err := image.Decode(bytes.NewReader(thumbnailData)) if err == nil { imageBounds := src.Bounds() - output.ImageWidth, output.ImageHeight = 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) var err error output.ImageURL, output.ImageEncryption, err = getIntent(ctx).UploadMedia( diff --git a/pkg/msgconv/wa-business.go b/pkg/msgconv/wa-business.go index da416d5..c3ed976 100644 --- a/pkg/msgconv/wa-business.go +++ b/pkg/msgconv/wa-business.go @@ -66,10 +66,10 @@ func (mc *MessageConverter) convertTemplateMessage(ctx context.Context, info *ty if addButtonText { 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 != "" { - content = strings.TrimSpace(fmt.Sprintf("%s\n\n%s", content, footer)) + content = fmt.Sprintf("%s\n\n%s", content, footer) } var convertedTitle *bridgev2.ConvertedMessagePart @@ -239,7 +239,7 @@ func (mc *MessageConverter) postProcessBusinessMessage(content string, headerMed converted.Content.Body += content contentHTML := parseWAFormattingToHTML(content, true) if contentHTML != event.TextToHTML(content) || converted.Content.FormattedBody != "" { - converted.Content.Format = event.FormatHTML + converted.Content.EnsureHasHTML() if converted.Content.FormattedBody != "" { converted.Content.FormattedBody += "

" } diff --git a/pkg/msgconv/wa-media.go b/pkg/msgconv/wa-media.go index c2a8624..dea7f54 100644 --- a/pkg/msgconv/wa-media.go +++ b/pkg/msgconv/wa-media.go @@ -17,6 +17,8 @@ package msgconv import ( + "archive/zip" + "bytes" "context" "encoding/json" "errors" @@ -24,18 +26,21 @@ import ( "io" "net/http" "os" + "path/filepath" + "strconv" "strings" "github.com/rs/zerolog" "go.mau.fi/util/exmime" "go.mau.fi/util/exslices" + "go.mau.fi/util/lottie" + "go.mau.fi/util/random" "go.mau.fi/whatsmeow" "go.mau.fi/whatsmeow/proto/waE2E" "go.mau.fi/whatsmeow/types" "maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2/database" "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" "go.mau.fi/mautrix-whatsapp/pkg/waid" ) @@ -49,15 +54,11 @@ func (mc *MessageConverter) convertMediaMessage( cachedPart *bridgev2.ConvertedMessagePart, ) (part *bridgev2.ConvertedMessagePart, contextInfo *waE2E.ContextInfo) { 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{ Type: event.EventMessage, Content: &event.MessageEventContent{ 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 } @@ -79,18 +80,12 @@ func (mc *MessageConverter) convertMediaMessage( Type: whatsmeow.GetMediaType(msg), SHA256: msg.GetFileSHA256(), EncSHA256: msg.GetFileEncSHA256(), - MimeType: msg.GetMimetype(), } if mc.DirectMedia { - if preparedMedia.Info.MimeType == "application/was" { - preparedMedia.Info.MimeType = "video/lottie+json" - preparedMedia.FileName = "sticker.json" - } preparedMedia.FillFileName() var err error portal := getPortal(ctx) - idOverride := getEditTargetID(ctx) - preparedMedia.URL, err = portal.Bridge.Matrix.GenerateContentURI(ctx, waid.MakeMediaID(messageInfo, idOverride, portal.Receiver)) + preparedMedia.URL, err = portal.Bridge.Matrix.GenerateContentURI(ctx, waid.MakeMediaID(messageInfo, portal.Receiver)) if err != nil { panic(fmt.Errorf("failed to generate content URI: %w", err)) } @@ -119,28 +114,6 @@ func (mc *MessageConverter) convertMediaMessage( 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" type FailedMediaKeys struct { @@ -150,7 +123,6 @@ type FailedMediaKeys struct { SHA256 []byte `json:"sha256"` EncSHA256 []byte `json:"enc_sha256"` DirectPath string `json:"direct_path,omitempty"` - MimeType string `json:"mime_type,omitempty"` } func (f *FailedMediaKeys) GetDirectPath() string { @@ -193,9 +165,7 @@ type PreparedMedia struct { } func (pm *PreparedMedia) FillFileName() *PreparedMedia { - if pm.Type == event.EventSticker { - pm.FileName = "" - } else if pm.FileName == "" { + if pm.FileName == "" { pm.FileName = strings.TrimPrefix(string(pm.MsgType), "m.") + exmime.ExtensionFromMimetype(pm.Info.MimeType) } return pm @@ -234,21 +204,6 @@ type MediaMessageWithDuration interface { 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 { extraInfo := map[string]any{} data := &PreparedMedia{ @@ -260,22 +215,6 @@ func prepareMediaMessage(rawMsg MediaMessage) *PreparedMedia { "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) { case *waE2E.ImageMessage: data.MsgType = event.MsgImage @@ -297,11 +236,12 @@ func prepareMediaMessage(rawMsg MediaMessage) *PreparedMedia { case *waE2E.StickerMessage: data.Type = event.EventSticker 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: data.MsgType = event.MsgVideo - pairedMediaType := msg.GetContextInfo().GetPairedMediaType() - if msg.GetGifPlayback() || pairedMediaType == waE2E.ContextInfo_MOTION_PHOTO_PARENT || pairedMediaType == waE2E.ContextInfo_MOTION_PHOTO_CHILD { + if msg.GetGifPlayback() { extraInfo["fi.mau.gif"] = true extraInfo["fi.mau.loop"] = true extraInfo["fi.mau.autoplay"] = true @@ -312,7 +252,22 @@ func prepareMediaMessage(rawMsg MediaMessage) *PreparedMedia { default: 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 } @@ -357,16 +312,13 @@ func (mc *MessageConverter) reuploadWhatsAppAttachment( ) error { client := getClient(ctx) intent := getIntent(ctx) - var roomID id.RoomID - if portal := getPortal(ctx); portal != nil { - roomID = portal.MXID - } + portal := getPortal(ctx) var thumbnailData []byte var thumbnailInfo *event.FileInfo if part.Info.Size > uploadFileThreshold { var err error - part.URL, part.File, err = intent.UploadMediaStream(ctx, roomID, -1, true, func(file io.Writer) (*bridgev2.FileStreamResult, error) { - err := client.DownloadToFile(ctx, message, file.(*os.File)) + part.URL, part.File, err = intent.UploadMediaStream(ctx, portal.MXID, -1, true, func(file io.Writer) (*bridgev2.FileStreamResult, error) { + err := client.DownloadToFile(message, file.(*os.File)) 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") } else if err != nil { @@ -387,7 +339,7 @@ func (mc *MessageConverter) reuploadWhatsAppAttachment( return err } } else { - data, err := client.Download(ctx, message) + data, err := client.Download(message) 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") } else if err != nil { @@ -398,14 +350,12 @@ func (mc *MessageConverter) reuploadWhatsAppAttachment( if err != nil { return err } - } else if part.Type == event.EventSticker && part.Info.MimeType == "image/webp" { - mc.fillWebPStickerInfo(ctx, part, data) } if part.Info.MimeType == "" { part.Info.MimeType = http.DetectContentType(data) } 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 { return fmt.Errorf("%w: %w", bridgev2.ErrMediaReuploadFailed, err) } @@ -414,7 +364,7 @@ func (mc *MessageConverter) reuploadWhatsAppAttachment( var err error part.Info.ThumbnailURL, part.Info.ThumbnailFile, err = intent.UploadMedia( ctx, - roomID, + portal.MXID, thumbnailData, "thumbnail"+exmime.ExtensionFromMimetype(thumbnailInfo.MimeType), thumbnailInfo.MimeType, @@ -428,6 +378,85 @@ func (mc *MessageConverter) reuploadWhatsAppAttachment( 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 { logLevel := zerolog.ErrorLevel var extra map[string]any diff --git a/pkg/msgconv/wa-misc.go b/pkg/msgconv/wa-misc.go index 7ca4427..2b11542 100644 --- a/pkg/msgconv/wa-misc.go +++ b/pkg/msgconv/wa-misc.go @@ -27,7 +27,6 @@ import ( "github.com/rs/zerolog" "go.mau.fi/util/exerrors" "go.mau.fi/util/ptr" - "go.mau.fi/whatsmeow/proto/waAICommonDeprecated" "go.mau.fi/whatsmeow/proto/waE2E" "go.mau.fi/whatsmeow/types" "google.golang.org/protobuf/proto" @@ -88,12 +87,10 @@ func (mc *MessageConverter) convertGroupInviteMessage(ctx context.Context, info template = inviteMsgBroken } else { inviteMeta = &waid.GroupInviteMeta{ - JID: groupJID, - Code: msg.GetInviteCode(), - Expiration: msg.GetInviteExpiration(), - Inviter: info.Sender.ToNonAD(), - GroupName: msg.GetGroupName(), - IsParentGroup: msg.GetGroupType() == waE2E.GroupInviteMessage_PARENT, + JID: groupJID, + Code: msg.GetInviteCode(), + Expiration: msg.GetInviteExpiration(), + Inviter: info.Sender.ToNonAD(), } extraAttrs = map[string]any{ GroupInviteMetaField: inviteMeta, @@ -117,11 +114,11 @@ func (mc *MessageConverter) convertGroupInviteMessage(ctx context.Context, info }, 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) portalMeta := portal.Metadata.(*waid.PortalMetadata) disappear := database.DisappearingSetting{ - Type: event.DisappearingTypeAfterSend, + Type: database.DisappearingTypeAfterRead, Timer: time.Duration(msg.GetEphemeralExpiration()) * time.Second, } if disappear.Timer == 0 { @@ -129,39 +126,26 @@ func (mc *MessageConverter) convertEphemeralSettingMessage(ctx context.Context, } dontBridge := portal.Disappear == disappear content := bridgev2.DisappearingMessageNotice(disappear.Timer, false) - if !isBackfill { - if msg.EphemeralSettingTimestamp == nil || portalMeta.DisappearingTimerSetAt < msg.GetEphemeralSettingTimestamp() { - portalMeta.DisappearingTimerSetAt = msg.GetEphemeralSettingTimestamp() - portal.UpdateDisappearingSetting(ctx, disappear, bridgev2.UpdateDisappearingSettingOpts{ - Sender: getIntent(ctx), - Timestamp: ts, - Implicit: false, - Save: true, - SendNotice: false, - }) - } else { - content.Body += ", but the change was ignored." + if msg.EphemeralSettingTimestamp == nil || portalMeta.DisappearingTimerSetAt < msg.GetEphemeralSettingTimestamp() { + portal.Disappear = disappear + portalMeta.DisappearingTimerSetAt = msg.GetEphemeralSettingTimestamp() + err := portal.Save(ctx) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to save portal after updating expiration timer") } + } else { + content.Body += ", but the change was ignored." } return &bridgev2.ConvertedMessagePart{ - Type: event.EventMessage, - 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, - }, - }, + Type: event.EventMessage, + Content: content, DontBridge: dontBridge, }, nil } const eventMessageTemplate = ` {{- if .Name -}} -

{{ .Name }} {{- if .IsCanceled -}} (Canceled){{- end -}}

+

{{ .Name }}

{{- end -}} {{- if .StartTime -}}

@@ -187,7 +171,6 @@ var eventMessageTplParsed = exerrors.Must(template.New("eventmessage").Parse(str type eventMessageParams struct { Name string - IsCanceled bool JoinLink string StartTimeISO string StartTime string @@ -200,7 +183,6 @@ type eventMessageParams struct { func (mc *MessageConverter) convertEventMessage(ctx context.Context, msg *waE2E.EventMessage) (*bridgev2.ConvertedMessagePart, *waE2E.ContextInfo) { params := &eventMessageParams{ Name: msg.GetName(), - IsCanceled: msg.GetIsCanceled(), JoinLink: msg.GetJoinLink(), Location: msg.GetLocation().GetName(), DescriptionHTML: template.HTML(parseWAFormattingToHTML(msg.GetDescription(), false)), @@ -232,53 +214,3 @@ func (mc *MessageConverter) convertEventMessage(ctx context.Context, msg *waE2E. Content: &content, }, 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() -} diff --git a/pkg/msgconv/wa-poll.go b/pkg/msgconv/wa-poll.go index 14388be..1087684 100644 --- a/pkg/msgconv/wa-poll.go +++ b/pkg/msgconv/wa-poll.go @@ -24,6 +24,7 @@ import ( "strings" "github.com/rs/zerolog" + "go.mau.fi/util/ptr" "go.mau.fi/whatsmeow" "go.mau.fi/whatsmeow/proto/waCommon" "go.mau.fi/whatsmeow/proto/waE2E" @@ -94,31 +95,7 @@ func (mc *MessageConverter) convertPollCreationMessage(ctx context.Context, msg }, msg.GetContextInfo() } -func rerouteMessageKey(ctx context.Context, chat, sender types.JID, groupLIDAddressing bool) types.JID { - 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 +func KeyToMessageID(client *whatsmeow.Client, chat, sender types.JID, key *waCommon.MessageKey) networkid.MessageID { sender = sender.ToNonAD() var err error if !key.GetFromMe() { @@ -132,21 +109,14 @@ func KeyToMessageID(ctx context.Context, client *whatsmeow.Client, chat, sender sender.Server = types.DefaultUserServer } } else if chat.Server == types.DefaultUserServer || chat.Server == types.BotServer { - if sender.User == client.Store.GetJID().User || sender.User == client.Store.GetLID().User { - // Message key is not from the sender, but message sender (containing key) is me, - // so message key sender is the other user in the DM + ownID := ptr.Val(client.Store.ID).ToNonAD() + if sender.User == ownID.User { sender = chat } else { - // Message key is not from the sender, but message sender (containing key) is not me, - // so message key sender is me - sender = client.Store.GetJID().ToNonAD() + sender = ownID } } else { - zerolog.Ctx(ctx).Warn(). - Stringer("chat", chat). - Stringer("sender", sender). - Any("key", key). - Msg("Failed to get message ID from key") + // TODO log somehow? return "" } } @@ -157,10 +127,6 @@ func KeyToMessageID(ctx context.Context, client *whatsmeow.Client, chat, sender chat = remoteJID } } - sender = rerouteMessageKey( - context.WithValue(ctx, contextKeyClient, client), - chat, sender, groupLIDAddressing, - ) 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) { 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, "") if err != nil { log.Err(err).Msg("Failed to get poll update target message") 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, Message: &waE2E.Message{PollUpdateMessage: msg}, }) diff --git a/pkg/msgconv/wa-sticker.go b/pkg/msgconv/wa-sticker.go deleted file mode 100644 index b9797a6..0000000 --- a/pkg/msgconv/wa-sticker.go +++ /dev/null @@ -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 . - -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) -} diff --git a/pkg/waid/dbmeta.go b/pkg/waid/dbmeta.go index 2785105..65760d2 100644 --- a/pkg/waid/dbmeta.go +++ b/pkg/waid/dbmeta.go @@ -20,30 +20,24 @@ import ( "crypto/ecdh" "crypto/rand" "encoding/json" - "time" "go.mau.fi/util/exerrors" "go.mau.fi/util/jsontime" "go.mau.fi/util/random" - "go.mau.fi/whatsmeow/appstate" "go.mau.fi/whatsmeow/types" ) type UserLoginMetadata struct { WADeviceID uint16 `json:"wa_device_id"` + WALID string `json:"wa_lid"` PhoneLastSeen jsontime.Unix `json:"phone_last_seen"` PhoneLastPinged jsontime.Unix `json:"phone_last_pinged"` Timezone string `json:"timezone"` PushKeys *PushKeys `json:"push_keys,omitempty"` APNSEncPubKey []byte `json:"apns_enc_pubkey,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"` - - MData json.RawMessage `json:"mdata,omitempty"` } type PushKeys struct { @@ -74,9 +68,6 @@ type GroupInviteMeta struct { Code string `json:"code"` Expiration int64 `json:"expiration,string"` Inviter types.JID `json:"inviter"` - - GroupName string `json:"group_name,omitempty"` - IsParentGroup bool `json:"is_parent_group,omitempty"` } type MessageMetadata struct { @@ -115,11 +106,9 @@ type ReactionMetadata struct { type PortalMetadata struct { DisappearingTimerSetAt int64 `json:"disappearing_timer_set_at,omitempty"` - TopicID string `json:"topic_id,omitempty"` LastSync jsontime.Unix `json:"last_sync,omitempty"` CommunityAnnouncementGroup bool `json:"is_cag,omitempty"` AddressingMode types.AddressingMode `json:"addressing_mode,omitempty"` - LIDMigrationAttempted bool `json:"lid_migration_attempted,omitempty"` } type GhostMetadata struct { diff --git a/pkg/waid/mediaid.go b/pkg/waid/mediaid.go index 093ece1..ab94977 100644 --- a/pkg/waid/mediaid.go +++ b/pkg/waid/mediaid.go @@ -17,6 +17,7 @@ package waid import ( + "bytes" "encoding/binary" "encoding/hex" "fmt" @@ -28,26 +29,12 @@ import ( "maunium.net/go/mautrix/bridgev2/networkid" ) -const ( - // 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 { +func MakeMediaID(messageInfo *types.MessageInfo, receiver networkid.UserLoginID) networkid.MediaID { compactChat := compactJID(messageInfo.Chat.ToNonAD()) compactSender := compactJID(messageInfo.Sender.ToNonAD()) receiverID := compactJID(ParseUserLoginID(receiver, 0)) - var compactID []byte - if idOverride != "" { - 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) + compactID := compactMsgID(messageInfo.ID) + mediaID := make([]byte, 0, 3+len(compactChat)+len(compactSender)+len(compactID)) mediaID = append(mediaID, byte(len(compactChat))) mediaID = append(mediaID, compactChat...) mediaID = append(mediaID, byte(len(compactSender))) @@ -59,131 +46,29 @@ func MakeMediaID(messageInfo *types.MessageInfo, idOverride types.MessageID, rec return mediaID } -func MakeAvatarMediaID(targetJID types.JID, id string, receiver networkid.UserLoginID, community bool) networkid.MediaID { - compactTarget := compactJID(targetJID.ToNonAD()) - receiverID := compactJID(ParseUserLoginID(receiver, 0)) - mediaID := make([]byte, 0, 4+len(compactTarget)+len(id)+len(receiverID)) - if community { - mediaID = append(mediaID, mediaIDTypeCommunityAvatar) - } else { - mediaID = append(mediaID, mediaIDTypeAvatar) +func ParseMediaID(mediaID networkid.MediaID) (*ParsedMessageID, networkid.UserLoginID, error) { + reader := bytes.NewReader(mediaID) + chatJID, err := readCompact(reader, parseCompactJID) + if err != nil { + return nil, "", fmt.Errorf("failed to parse chat JID: %w", err) } - 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:] + senderJID, err := readCompact(reader, parseCompactJID) + if err != nil { + return nil, "", fmt.Errorf("failed to parse sender JID: %w", err) } - var parsed ParsedMediaID - switch mediaIDType { - case mediaIDTypeMessage: - chatJID, err := readCompact(&mediaID, parseCompactJID) - if err != nil { - return nil, fmt.Errorf("failed to parse chat JID: %w", err) - } - senderJID, err := readCompact(&mediaID, parseCompactJID) - if err != nil { - return nil, fmt.Errorf("failed to parse sender JID: %w", err) - } - receiverID, err := readCompact(&mediaID, parseCompactJID) - if err != nil { - return nil, fmt.Errorf("failed to parse receiver JID: %w", err) - } - id, err := readCompact(&mediaID, parseCompactMsgID) - if err != nil { - return nil, fmt.Errorf("failed to parse message ID: %w", err) - } - parsed.Message = &ParsedMessageID{ - Chat: chatJID, - Sender: senderJID, - ID: id, - } - 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) + receiverID, err := readCompact(reader, parseCompactJID) + if err != nil { + return nil, "", fmt.Errorf("failed to parse receiver JID: %w", err) } - return &parsed, nil -} - -func parseString(data []byte) (string, error) { - return string(data), nil + id, err := readCompact(reader, parseCompactMsgID) + if err != nil { + return nil, "", fmt.Errorf("failed to parse message ID: %w", err) + } + return &ParsedMessageID{ + Chat: chatJID, + Sender: senderJID, + ID: id, + }, MakeUserLoginID(receiverID), nil } func isUpperHex(str string) bool { @@ -284,20 +169,16 @@ func parseCompactJID(jid []byte) (types.JID, error) { } } -func rawBytes(data []byte) ([]byte, error) { - return data, nil -} - -func readCompact[T any](data *networkid.MediaID, fn func(data []byte) (T, error)) (T, error) { +func readCompact[T any](reader *bytes.Reader, fn func(data []byte) (T, error)) (T, error) { var defVal T - if len(*data) < 1 { - return defVal, fmt.Errorf("%w (data too short to read length)", io.ErrUnexpectedEOF) + length, err := reader.ReadByte() + if err != nil { + return defVal, err } - length := int((*data)[0]) - if len(*data) < length+1 { - return defVal, fmt.Errorf("%w (wanted %d+1 bytes, only have %d)", io.ErrUnexpectedEOF, length, len(*data)) + data := make([]byte, length) + _, err = io.ReadFull(reader, data) + if err != nil { + return defVal, err } - dataToParse := (*data)[1 : length+1] - *data = (*data)[length+1:] - return fn(dataToParse) + return fn(data) }