1
0
Fork 0
mirror of https://github.com/mautrix/whatsapp.git synced 2026-05-14 17:56:53 -04:00

Compare commits

..

1 commit

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

View file

@ -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
---
<!-- Include relevant logs, the bridge version and other important details here -->
<!--
Remember to include relevant logs, the bridge version and any other details.
### Checklist
<!-- All items below are mandatory. Issues not following the rules may be closed without comment. -->
* [ ] This is an actual bug, not just a setup issue (see the [troubleshooting docs](https://docs.mau.fi/bridges/general/troubleshooting.html) or ask in the Matrix room for setup help).
* [ ] I am certain that sufficient information is included. Ask in the Matrix room first if not.
* [ ] The bug is still present on the main branch. The `!wa version` command output is: ``
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.
-->

View file

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

View file

@ -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

View file

@ -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

View file

@ -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:

View file

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

View file

@ -1,4 +1,4 @@
FROM golang:1-alpine3.23 AS builder
FROM golang:1-alpine3.21 AS builder
RUN apk add --no-cache git ca-certificates build-base su-exec olm-dev
@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

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

View file

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

View file

@ -1,23 +1,48 @@
package main
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)
}
}

View file

@ -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.11.4",
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)

View file

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

57
go.mod
View file

@ -1,53 +1,48 @@
module go.mau.fi/mautrix-whatsapp
go 1.25.0
go 1.23.0
toolchain go1.26.2
tool go.mau.fi/util/cmd/maubuild
toolchain go1.24.1
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.6
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-20250411192951-5ab78fadbf91
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.3-0.20250405234116-e675a3c09c38
)
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.24 // indirect
github.com/petermattis/goid v0.0.0-20250303134427-723919f7f203 // 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.8 // 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-20250305212735-054e65f0b394 // 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

118
go.sum
View file

@ -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.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/petermattis/goid v0.0.0-20250303134427-723919f7f203 h1:E7Kmf11E4K7B5hDti2K2NqPb1nlYlGYsu02S1JNd/Bs=
github.com/petermattis/goid v0.0.0-20250303134427-723919f7f203/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/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.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
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.6 h1:AEK13rfgtiZJL2YsNK+W4ihhYCuukcRom8WPP/w/L54=
go.mau.fi/util v0.8.6/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-20250411192951-5ab78fadbf91 h1:eziiUdmaGyhM7Fxn4IMRClBrX1b2sCAFGSBE2Z57yNg=
go.mau.fi/whatsmeow v0.0.0-20250411192951-5ab78fadbf91/go.mod h1:aj6MpceCuJ6m712lPEMAh8iAw2TkABx6TnPjMcdG0eE=
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-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw=
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM=
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.3-0.20250405234116-e675a3c09c38 h1:fIe2+kYndm3Mm/DwQ4FsODk2DjrLeEeW7tKtZjyERqM=
maunium.net/go/mautrix v0.23.3-0.20250405234116-e675a3c09c38/go.mod h1:pCYLHmo02Jauak/9VlTkbGPrBMvLXsGqTGMNOx+L2PE=

View file

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

View file

@ -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() {
wa.UserLogin.Log.Debug().Msg("Starting history sync loop")
for {
select {
case evt := <-wa.historySyncs:
dispatchTimer.Stop()
wa.handleWAHistorySync(ctx, evt)
dispatchTimer.Reset(historySyncDispatchWait)
case <-dispatchTimer.C:
timerPending.Store(false)
wa.createPortalsFromHistorySync(ctx)
case <-ctx.Done():
wa.UserLogin.Log.Debug().Msg("Stopping portal creation history sync loop")
wa.UserLogin.Log.Debug().Msg("Stopping history sync loop")
return
}
}
}()
for {
var resetTimer bool
select {
case <-wa.historySyncWakeup:
dispatchTimer.Stop()
notif, rowid, err := wa.Main.DB.HSNotif.GetNext(ctx, wa.UserLogin.ID)
if err != nil {
wa.UserLogin.Log.Err(err).Msg("Failed to get next history sync notification")
} else if notif == nil {
wa.UserLogin.Log.Debug().Msg("No more queued history sync notifications")
} else {
resetTimer = wa.downloadAndSaveWAHistorySyncData(ctx, notif, rowid)
// Continue waking up the loop until all queued notifications are processed
select {
case wa.historySyncWakeup <- struct{}{}:
default:
}
}
case <-ctx.Done():
wa.UserLogin.Log.Debug().Msg("Stopping main history sync loop")
return
}
if resetTimer {
timerPending.Store(true)
}
if timerPending.Load() {
dispatchTimer.Reset(wa.Main.Config.HistorySync.DispatchWait)
}
}
}
func (wa *WhatsAppClient) saveWAHistorySyncNotification(ctx context.Context, evt *waE2E.HistorySyncNotification) {
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)
})
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,58 +152,37 @@ 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()).
Uint64("last_message_ts", conv.GetLastMsgTimestamp()).
Bool("marked_unread", conv.GetMarkedAsUnread()).
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))
err = wa.Main.DB.Conversation.Put(ctx, wadb.NewConversation(wa.UserLogin.ID, jid, conv))
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 +191,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 +213,10 @@ 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 {
// 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,30 +236,21 @@ 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 {
return c.
Stringer("chat_jid", conv.ChatJID).
Time("latest_message_ts", conv.LastMessageTimestamp)
},
LogContext: nil,
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 +260,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 +280,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)
}
if params.Cursor != "" {
} 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)
}
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
}
if len(messages) > params.Count {
hasMore := false
oldestTS := messages[len(messages)-1].GetMessageTimestamp()
newestTS := messages[0].GetMessageTimestamp()
if len(messages) > params.Count {
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 +322,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
}
}
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 +341,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 +376,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 +403,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
}

View file

@ -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
//"image/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,
@ -148,7 +119,6 @@ var whatsappCaps = &event.RoomFeatures{
"video/mp4": event.CapLevelFullySupported,
"video/3gpp": event.CapLevelFullySupported,
"video/webm": supportedIfFFmpeg(),
"video/quicktime": 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
}

View file

@ -26,41 +26,40 @@ 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 {
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)
@ -71,13 +70,12 @@ func (wa *WhatsAppClient) addExtrasToWrapped(ctx context.Context, portalJID type
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,
},
},

View file

@ -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),
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),
pushNamesSynced: exsync.NewEvent(),
createDedup: exsync.NewSet[types.MessageID](),
appStateFullSyncAttempted: make(map[appstate.WAPatchName]time.Time),
}
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.AddEventHandler(w.handleWAEvent)
if bridgev2.PortalEventBuffer == 0 {
w.Client.SynchronousAck = true
w.Client.EnableDecryptedEventBuffer = bridgev2.PortalEventBuffer == 0
w.Client.ManualHistorySyncDownload = true
w.Client.SendReportingTokens = 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,16 @@ type WhatsAppClient struct {
directMediaRetries map[networkid.MessageID]*directMediaRetry
directMediaLock sync.Mutex
mediaRetryLock *semaphore.Weighted
offlineSyncWaiter atomic.Pointer[chan error]
isNewLogin bool
pushNamesSynced *exsync.Event
lastPresence types.Presence
createDedup *exsync.Set[types.MessageID]
offlineSyncWaiter chan error
appStateRecoveryLock sync.Mutex
appStateFullSyncAttempted map[appstate.WAPatchName]time.Time
lastPhoneOfflineWarning time.Time
isNewLogin bool
}
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 +180,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,25 +217,20 @@ 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")
}
wa.Client.GetClientPayload = func() *waWa6.ClientPayload {
payload := wa.GetStore().GetClientPayload()
payload := wa.Client.Store.GetClientPayload()
payload.ConnectReason = waWa6.ClientPayload_PUSH.Enum()
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 +238,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 +255,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 +263,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 +278,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,14 +293,14 @@ 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)
go wa.disconnectWarningLoop(ctx)
if mrc := wa.Main.Config.HistorySync.MediaRequests; mrc.AutoRequestMedia && mrc.RequestMethod == MediaRequestMethodLocalTime {
go wa.mediaRequestLoop(ctx)
}
@ -355,14 +316,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 +327,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")
}
@ -382,99 +339,3 @@ func (wa *WhatsAppClient) LogoutRemote(ctx context.Context) {
func (wa *WhatsAppClient) IsLoggedIn() bool {
return wa.Client != nil && wa.Client.IsLoggedIn()
}
func (wa *WhatsAppClient) syncRemoteProfile(ctx context.Context, ghost *bridgev2.Ghost) {
ownID := waid.MakeUserID(wa.GetStore().GetJID())
if ghost == nil {
var err error
ghost, err = wa.Main.Bridge.GetExistingGhostByID(ctx, ownID)
if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to get own ghost to sync remote profile")
return
} else if ghost == nil {
return
}
}
if ghost.ID != ownID {
return
}
name := wa.GetStore().BusinessName
if name == "" {
name = wa.GetStore().PushName
}
if name == "" || wa.UserLogin.RemoteProfile.Name == name && wa.UserLogin.RemoteProfile.Avatar == ghost.AvatarMXC {
return
}
wa.UserLogin.RemoteProfile.Name = name
wa.UserLogin.RemoteProfile.Avatar = ghost.AvatarMXC
err := wa.UserLogin.Save(ctx)
if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to save remote profile")
}
// FIXME this might be racy, should invent a proper way to send last state with info filled
if wa.Client.IsConnected() {
wa.UserLogin.BridgeState.Send(status.BridgeState{StateEvent: status.StateConnected})
}
zerolog.Ctx(ctx).Info().Msg("Remote profile updated")
}
func (wa *WhatsAppClient) HandleMatrixViewingChat(ctx context.Context, msg *bridgev2.MatrixViewingChat) error {
var presence types.Presence
if msg.Portal != nil {
presence = types.PresenceAvailable
} else {
presence = types.PresenceUnavailable
}
if wa.lastPresence != presence {
err := wa.updatePresence(ctx, presence)
if err != nil {
zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to set presence when viewing chat")
}
}
if msg.Portal == nil || msg.Portal.Metadata.(*waid.PortalMetadata).LastSync.Add(5*time.Minute).After(time.Now()) {
// If we resynced this portal within the last 5 minutes, don't do it again
return nil
}
// Reset, but don't save, portal last sync time for immediate sync now
msg.Portal.Metadata.(*waid.PortalMetadata).LastSync.Time = time.Time{}
// Enqueue for the sync, don't block on it completing
wa.EnqueuePortalResync(msg.Portal, true)
if msg.Portal.OtherUserID != "" {
// If this is a DM, also sync the ghost of the other user immediately
ghost, err := wa.Main.Bridge.GetExistingGhostByID(ctx, msg.Portal.OtherUserID)
if err != nil {
return fmt.Errorf("failed to get ghost for sync: %w", err)
} else if ghost == nil {
zerolog.Ctx(ctx).Warn().
Str("other_user_id", string(msg.Portal.OtherUserID)).
Msg("No ghost found for other user in portal")
} else {
// Reset, but don't save, portal last sync time for immediate sync now
ghost.Metadata.(*waid.GhostMetadata).LastSync.Time = time.Time{}
wa.EnqueueGhostResync(ghost)
}
}
return nil
}
func (wa *WhatsAppClient) updatePresence(ctx context.Context, presence types.Presence) error {
err := wa.Client.SendPresence(ctx, presence)
if err == nil {
wa.lastPresence = presence
}
return err
}
func (wa *WhatsAppClient) DownloadImagePack(ctx context.Context, url string) (*bridgev2.ImportedImagePack, error) {
return wa.Main.MsgConv.DownloadImagePack(ctx, wa.UserLogin.ID, wa.Client, url)
}
func (wa *WhatsAppClient) ListImagePacks(ctx context.Context) ([]*event.ImagePackMetadata, error) {
// TODO
return nil, nil
}

View file

@ -17,15 +17,12 @@
package connector
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

View file

@ -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"`
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 jid.Server != types.DefaultUserServer {
phone = jid.User
}
if contact.RedactedPhone == "" && phone != "" {
contact.RedactedPhone = redactPhone(phone)
}
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),

View file

@ -18,37 +18,24 @@ package connector
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"
"go.mau.fi/whatsmeow/proto/waCompanionReg"
"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 +46,16 @@ 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)
)
func (wa *WhatsAppConnector) SetMaxFileSize(maxSize int64) {
@ -128,12 +109,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 +122,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,147 +137,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)
}
func (wa *WhatsAppConnector) GenerateTransactionID(_ id.UserID, _ id.RoomID, _ event.Type) networkid.RawTransactionID {
// The "proper" way would be a hash of the user ID among other things, but the hash includes random bytes too,
// 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()
}
}

View file

@ -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()
}
if retryData.GetResult() != waMmsRetry.MediaRetryNotification_SUCCESS {
errorName := waMmsRetry.MediaRetryNotification_ResultType_name[int32(retryData.GetResult())]
if retryData.GetDirectPath() == "" {

View file

@ -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 {

View file

@ -16,9 +16,8 @@ proxy_only_login: false
# {{.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)'
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

View file

@ -1,25 +1,17 @@
package connector
import (
"bytes"
"context"
"crypto/sha256"
"errors"
"fmt"
"image"
"image/jpeg"
"strings"
"time"
"github.com/rs/zerolog"
"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"
@ -37,15 +29,6 @@ var (
_ 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)
)
func (wa *WhatsAppClient) HandleMatrixPollStart(ctx context.Context, msg *bridgev2.MatrixPollStart) (*bridgev2.MatrixMessageResponse, error) {
@ -90,12 +73,7 @@ func (wa *WhatsAppClient) handleConvertedMatrixMessage(ctx context.Context, msg
if req == nil {
req = &whatsmeow.SendRequestExtra{}
}
if strings.HasPrefix(string(msg.InputTransactionID), whatsmeow.WebMessageIDPrefix) {
req.ID = types.MessageID(msg.InputTransactionID)
} else {
req.ID = wa.Client.GenerateMessageID()
}
chatJID, err := waid.ParsePortalID(msg.Portal.ID)
if err != nil {
return nil, err
@ -104,16 +82,15 @@ func (wa *WhatsAppClient) handleConvertedMatrixMessage(ctx context.Context, msg
return nil, ErrBroadcastSendDisabled
}
wrappedMsgID := waid.MakeMessageID(chatJID, wa.JID, req.ID)
wrappedMsgID2 := waid.MakeMessageID(chatJID, wa.GetStore().GetLID(), req.ID)
wrappedMsgID2 := waid.MakeMessageID(chatJID, wa.Device.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.Device.GetLID() {
pickedMessageID = wrappedMsgID2
msg.RemovePending(networkid.TransactionID(wrappedMsgID))
} else {
@ -137,7 +114,7 @@ func (wa *WhatsAppClient) handleConvertedMatrixMessage(ctx context.Context, msg
func (wa *WhatsAppClient) PreHandleMatrixReaction(_ context.Context, msg *bridgev2.MatrixReaction) (bridgev2.MatrixReactionPreResponse, error) {
portalJID, err := waid.ParsePortalID(msg.Portal.ID)
if err != nil {
return bridgev2.MatrixReactionPreResponse{}, fmt.Errorf("failed to parse portal ID: %w", err)
return bridgev2.MatrixReactionPreResponse{}, err
} else if portalJID == types.StatusBroadcastJID {
return bridgev2.MatrixReactionPreResponse{}, ErrBroadcastReactionUnsupported
}
@ -145,7 +122,7 @@ func (wa *WhatsAppClient) PreHandleMatrixReaction(_ context.Context, msg *bridge
if portalJID.Server == types.HiddenUserServer ||
msg.Portal.Metadata.(*waid.PortalMetadata).CommunityAnnouncementGroup ||
msg.Portal.Metadata.(*waid.PortalMetadata).AddressingMode == types.AddressingModeLID {
sender = wa.GetStore().GetLID()
sender = wa.Device.GetLID()
}
return bridgev2.MatrixReactionPreResponse{
SenderID: waid.MakeUserID(sender),
@ -157,12 +134,12 @@ func (wa *WhatsAppClient) PreHandleMatrixReaction(_ context.Context, msg *bridge
func (wa *WhatsAppClient) HandleMatrixReaction(ctx context.Context, msg *bridgev2.MatrixReaction) (*database.Reaction, error) {
messageID, err := waid.ParseMessageID(msg.TargetMessage.ID)
if err != nil {
return nil, fmt.Errorf("failed to parse target message ID: %w", err)
return nil, err
}
portalJID, err := waid.ParsePortalID(msg.Portal.ID)
if err != nil {
return nil, fmt.Errorf("failed to parse portal ID: %w", err)
return nil, err
}
reactionMsg := &waE2E.Message{
ReactionMessage: &waE2E.ReactionMessage{
@ -173,7 +150,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)
}
@ -195,12 +172,12 @@ func (wa *WhatsAppClient) HandleMatrixReaction(ctx context.Context, msg *bridgev
func (wa *WhatsAppClient) HandleMatrixReactionRemove(ctx context.Context, msg *bridgev2.MatrixReactionRemove) error {
messageID, err := waid.ParseMessageID(msg.TargetReaction.MessageID)
if err != nil {
return fmt.Errorf("failed to parse target message ID: %w", err)
return err
}
portalJID, err := waid.ParsePortalID(msg.Portal.ID)
if err != nil {
return fmt.Errorf("failed to parse portal ID: %w", err)
return err
}
reactionMsg := &waE2E.Message{
@ -211,34 +188,23 @@ func (wa *WhatsAppClient) HandleMatrixReactionRemove(ctx context.Context, msg *b
},
}
extra := whatsmeow.SendRequestExtra{}
if strings.HasPrefix(string(msg.InputTransactionID), whatsmeow.WebMessageIDPrefix) {
extra.ID = types.MessageID(msg.InputTransactionID)
}
resp, err := wa.Client.SendMessage(ctx, portalJID, reactionMsg, extra)
resp, err := wa.Client.SendMessage(ctx, portalJID, reactionMsg)
zerolog.Ctx(ctx).Trace().Any("response", resp).Msg("WhatsApp reaction response")
return err
}
func (wa *WhatsAppClient) HandleMatrixEdit(ctx context.Context, edit *bridgev2.MatrixEdit) error {
log := zerolog.Ctx(ctx)
var editID types.MessageID
if strings.HasPrefix(string(edit.InputTransactionID), whatsmeow.WebMessageIDPrefix) {
editID = types.MessageID(edit.InputTransactionID)
} else {
editID = wa.Client.GenerateMessageID()
}
editID := wa.Client.GenerateMessageID()
messageID, err := waid.ParseMessageID(edit.EditTarget.ID)
if err != nil {
return fmt.Errorf("failed to parse target message ID: %w", err)
return err
}
portalJID, err := waid.ParsePortalID(edit.Portal.ID)
if err != nil {
return fmt.Errorf("failed to parse portal ID: %w", err)
return err
}
waMsg, _, err := wa.Main.MsgConv.ToWhatsApp(ctx, wa.Client, edit.Event, edit.Content, nil, nil, edit.Portal)
@ -263,22 +229,17 @@ func (wa *WhatsAppClient) HandleMatrixMessageRemove(ctx context.Context, msg *br
log := zerolog.Ctx(ctx)
messageID, err := waid.ParseMessageID(msg.TargetMessage.ID)
if err != nil {
return fmt.Errorf("failed to parse target message ID: %w", err)
return err
}
portalJID, err := waid.ParsePortalID(msg.Portal.ID)
if err != nil {
return fmt.Errorf("failed to parse portal ID: %w", err)
return err
}
revokeMessage := wa.Client.BuildRevoke(messageID.Chat, messageID.Sender, messageID.ID)
extra := whatsmeow.SendRequestExtra{}
if strings.HasPrefix(string(msg.InputTransactionID), whatsmeow.WebMessageIDPrefix) {
extra.ID = types.MessageID(msg.InputTransactionID)
}
resp, err := wa.Client.SendMessage(ctx, portalJID, revokeMessage, extra)
resp, err := wa.Client.SendMessage(ctx, portalJID, revokeMessage)
log.Trace().Any("response", resp).Msg("WhatsApp delete response")
return err
}
@ -292,7 +253,7 @@ func (wa *WhatsAppClient) HandleMatrixReadReceipt(ctx context.Context, receipt *
}
portalJID, err := waid.ParsePortalID(receipt.Portal.ID)
if err != nil {
return fmt.Errorf("failed to parse portal ID: %w", err)
return err
}
messages, err := receipt.Portal.Bridge.DB.Message.GetMessagesBetweenTimeQuery(ctx, receipt.Portal.PortalKey, receipt.LastRead, receipt.ReadUpTo)
if err != nil {
@ -312,7 +273,7 @@ func (wa *WhatsAppClient) HandleMatrixReadReceipt(ctx context.Context, receipt *
if err != nil {
continue
}
if parsed.Sender.User == wa.GetStore().GetLID().User || parsed.Sender.User == wa.JID.User {
if parsed.Sender.User == wa.Device.GetLID().User || parsed.Sender.User == wa.JID.User {
continue
}
var key types.JID
@ -323,7 +284,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 +314,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 +346,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 +355,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))
}

View file

@ -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,78 @@ 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")
}
} 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 +159,26 @@ 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)
}
wa.MC.OnConnect(store.GetWAVersion()[2], wa.Device.Platform)
meta := wa.UserLogin.Metadata.(*waid.UserLoginMetadata)
if meta.WALID == "" {
meta.WALID = wa.Client.Store.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")
}
}()
}
}
case *events.OfflineSyncPreview:
log.Info().
Int("message_count", evt.Messages).
@ -179,15 +189,12 @@ 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")
Msg("Offline sync completed, but phone last seen date is still old - sending phone offline bridge status")
wa.UserLogin.BridgeState.Send(status.BridgeState{StateEvent: status.StateTransientDisconnect, Error: WAPhoneOffline})
} 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)
case *events.LoggedOut:
wa.handleWALogout(evt.Reason, evt.OnConnect)
@ -243,78 +250,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 +274,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 +282,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 +292,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 +302,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 +329,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 +360,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 +380,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 +392,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 +422,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 +436,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 +466,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 +508,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 +523,12 @@ 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 +536,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 +547,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 +566,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 +626,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")
}
}()
}

View file

@ -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,
IsFromMe: id.User == wa.Client.Store.GetJID().User || id.User == wa.Client.Store.GetLID().User,
Sender: waid.MakeUserID(id),
SenderLogin: waid.MakeUserLoginID(senderLoginJID),
SenderLogin: waid.MakeUserLoginID(id),
}
}
@ -60,7 +42,7 @@ func (wa *WhatsAppClient) messageIDToKey(id *waid.ParsedMessageID) *waCommon.Mes
RemoteJID: ptr.Ptr(id.Chat.String()),
ID: ptr.Ptr(id.ID),
}
if id.Sender.User == wa.GetStore().GetJID().User || id.Sender.User == wa.GetStore().GetLID().User {
if id.Sender.User == wa.Client.Store.GetJID().User || id.Sender.User == wa.Client.Store.GetLID().User {
key.FromMe = ptr.Ptr(true)
}
if id.Chat.Server != types.MessengerServer && id.Chat.Server != types.DefaultUserServer && id.Chat.Server != types.HiddenUserServer && id.Chat.Server != types.BotServer {
@ -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
}

View file

@ -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,16 @@ 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()))
if contact, err := ul.Client.(*WhatsAppClient).GetStore().Contacts.GetContact(wl.LoginSuccess.ID); err != nil {
wl.Log.Err(err).Msg("Failed to get own contact after login")
} else {
contactInfo := ul.Client.(*WhatsAppClient).contactToUserInfo(ctx, wl.LoginSuccess.ID, contact, true)
if contactInfo.Name != nil {
ul.UserLogin.RemoteProfile.Username = *contactInfo.Name
}
}
return &bridgev2.LoginStep{
Type: bridgev2.LoginStepTypeComplete,

View file

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

View file

@ -137,7 +137,7 @@ func (wa *WhatsAppClient) sendMediaRequest(ctx context.Context, req *wadb.MediaR
req.Status = wadb.MediaBackfillRequestStatusRequestSkipped
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,

View file

@ -18,26 +18,50 @@ package connector
import (
"context"
"fmt"
"time"
"go.mau.fi/util/exfmt"
"go.mau.fi/util/jsontime"
"go.mau.fi/whatsmeow"
"go.mau.fi/whatsmeow/proto/waE2E"
"maunium.net/go/mautrix/bridgev2/status"
"maunium.net/go/mautrix/event"
"go.mau.fi/mautrix-whatsapp/pkg/waid"
)
var _ status.BridgeStateFiller = (*WhatsAppClient)(nil)
func (wa *WhatsAppClient) FillBridgeState(state status.BridgeState) status.BridgeState {
if !wa.PhoneRecentlySeen(false) && state.StateEvent == status.StateConnected {
// TODO transient disconnect is wrong, this should be bad credentials or connected
state.StateEvent = status.StateTransientDisconnect
state.Error = WAPhoneOffline
state.UserAction = status.UserActionOpenNative
func (wa *WhatsAppClient) disconnectWarningLoop(ctx context.Context) {
ticker := time.NewTicker(1 * time.Hour)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if wa.Client != nil && wa.Client.IsConnected() {
if !wa.PhoneRecentlySeen(true) {
go wa.sendPhoneOfflineWarning(ctx)
}
return state
}
}
}
}
func (wa *WhatsAppClient) sendPhoneOfflineWarning(ctx context.Context) {
if wa.UserLogin.User.ManagementRoom == "" || time.Since(wa.lastPhoneOfflineWarning) < 12*time.Hour {
// Don't spam the warning too much
return
}
wa.lastPhoneOfflineWarning = time.Now()
timeSinceSeen := time.Since(wa.UserLogin.Metadata.(*waid.UserLoginMetadata).PhoneLastSeen.Time).Round(time.Hour)
// TODO remove this manual message after bridge states are plumbed to the management room as messages
_, _ = wa.Main.Bridge.Bot.SendMessage(ctx, wa.UserLogin.User.ManagementRoom, event.EventMessage, &event.Content{
Parsed: &event.MessageEventContent{
MsgType: event.MsgText,
Body: fmt.Sprintf("Your phone hasn't been seen in %s. The server will force the bridge to log out if the phone is not active at least every 2 weeks.", exfmt.Duration(timeSinceSeen)),
},
}, nil)
}
const PhoneDisconnectWarningTime = 12 * 24 * time.Hour // 12 days
@ -104,10 +128,7 @@ func (wa *WhatsAppClient) phoneSeen(ts time.Time) {
// The last seen timestamp isn't going to be perfectly accurate in any case,
// so don't spam the database with an update every time there's an event.
return
}
hadBeenSeen := wa.PhoneRecentlySeen(false)
meta.PhoneLastSeen = jsontime.U(ts)
if !hadBeenSeen {
} else if !wa.PhoneRecentlySeen(false) {
isConnected := wa.IsLoggedIn() && wa.Client.IsConnected()
prevStateError := wa.UserLogin.BridgeState.GetPrev().Error
if prevStateError == WAPhoneOffline && isConnected {
@ -120,6 +141,7 @@ func (wa *WhatsAppClient) phoneSeen(ts time.Time) {
Msg("Saw phone after current bridge state said it has been offline, not sending new bridge state")
}
}
meta.PhoneLastSeen = jsontime.U(ts)
go func() {
err := wa.UserLogin.Save(ctx)
if err != nil {

View file

@ -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")

View file

@ -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 = &params.Topic.Topic
}
}
if changed {
err = portal.Save(ctx)
if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to save portal after post-creation updates")
}
}
return &bridgev2.CreateChatResponse{
PortalKey: wa.makeWAPortalKey(resp.JID),
Portal: portal,
PortalInfo: groupInfo,
FailedParticipants: failedParticipants,
}, nil
}

View file

@ -2,12 +2,9 @@ package connector
import (
"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,80 +179,48 @@ func (wa *WhatsAppClient) getUserInfo(ctx context.Context, jid types.JID, fetchA
func (wa *WhatsAppClient) contactToUserInfo(ctx context.Context, jid types.JID, contact types.ContactInfo, getAvatar bool) *bridgev2.UserInfo {
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.PushName == "" {
contact.PushName = extraContact.PushName
}
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.Device.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)
}

View file

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

View file

@ -6,7 +6,6 @@ import (
"time"
"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 {
@ -40,19 +40,15 @@ func parseHistoryTime(ts *uint64) time.Time {
return time.Unix(int64(*ts), 0)
}
func NewConversation(loginID networkid.UserLoginID, chatJID types.JID, conv *waHistorySync.Conversation, mostRecentMessage time.Time) *Conversation {
func NewConversation(loginID networkid.UserLoginID, chatJID types.JID, conv *waHistorySync.Conversation) *Conversation {
var pinned *bool
if conv.Pinned != nil {
pinned = ptr.Ptr(*conv.Pinned > 0)
}
lastMessageTS := parseHistoryTime(conv.LastMsgTimestamp)
if lastMessageTS.IsZero() {
lastMessageTS = mostRecentMessage
}
return &Conversation{
UserLoginID: loginID,
ChatJID: chatJID,
LastMessageTimestamp: lastMessageTS,
LastMessageTimestamp: parseHistoryTime(conv.LastMsgTimestamp),
Archived: conv.Archived,
Pinned: pinned,
MuteEndTime: parseHistoryTime(conv.MuteEndTime),
@ -69,9 +65,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 +83,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 +100,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 +109,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 +121,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 +167,7 @@ func (c *Conversation) sqlVariables() []any {
c.EphemeralSettingTimestamp,
c.MarkedAsUnread,
c.UnreadCount,
c.Bridged,
}
}
@ -190,6 +186,7 @@ func (c *Conversation) Scan(row dbutil.Scannable) (*Conversation, error) {
&c.EphemeralSettingTimestamp,
&c.MarkedAsUnread,
&c.UnreadCount,
&c.Bridged,
)
if err != nil {
return nil, err

View file

@ -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{}
}),
},
}
}

View file

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

View file

@ -116,12 +116,9 @@ func (mq *MessageQuery) GetBetween(ctx context.Context, loginID networkid.UserLo
AsList()
}
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) {

View file

@ -1,4 +1,4 @@
-- v0 -> v9 (compatible with v3+): Latest revision
-- v0 -> v4 (compatible with v3+): Latest revision
CREATE TABLE whatsapp_poll_option_id (
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)
);

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -124,6 +124,10 @@ func getMessageType(waMsg *waE2E.Message) string {
case waMsg.EncEventResponseMessage != nil:
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:

View file

@ -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(replyTo *database.Message, portal *bridgev2.Portal) (*waE2E.ContextInfo, error) {
contextInfo := &waE2E.ContextInfo{}
if replyTo != nil {
msgID, err := waid.ParseMessageID(replyTo.ID)
@ -64,31 +56,18 @@ 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).
Str("reply_to_message_id", string(replyTo.ID)).
Msg("Failed to parse reply to message ID")
return nil, err
}
}
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()))
}
if portal.Disappear.Timer > 0 {
contextInfo.Expiration = ptr.Ptr(uint32(portal.Disappear.Timer.Seconds()))
setAt := portal.Metadata.(*waid.PortalMetadata).DisappearingTimerSetAt
if setAt > 0 && contextInfo.Expiration != nil {
if setAt > 0 {
contextInfo.EphemeralSettingTimestamp = ptr.Ptr(setAt)
}
if roomMention {
contextInfo.NonJIDMentions = proto.Uint32(1)
}
return contextInfo
return contextInfo, nil
}
func (mc *MessageConverter) ToWhatsApp(
@ -107,15 +86,14 @@ func (mc *MessageConverter) ToWhatsApp(
}
message := &waE2E.Message{}
contextInfo := mc.generateContextInfo(ctx, replyTo, portal, content.BeeperDisappearingTimer, content.Mentions != nil && content.Mentions.Room)
switch content.MsgType {
case event.MsgText, event.MsgNotice, event.MsgEmote:
var err error
message, err = mc.constructTextMessage(ctx, content, evt.Content.Raw, contextInfo)
contextInfo, err := mc.generateContextInfo(replyTo, portal)
if err != nil {
return nil, nil, err
}
switch content.MsgType {
case event.MsgText, event.MsgNotice, event.MsgEmote:
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"
}
}
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":

View file

@ -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,39 +61,15 @@ 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) {
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
}
cm.Disappear.Type = database.DisappearingTypeAfterRead
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,
})
portal.UpdateDisappearingSetting(ctx, cm.Disappear, intent, info.Timestamp, true, true)
}
}
if contextInfo.GetStanzaID() != "" {
pcp, _ := types.ParseJID(contextInfo.GetParticipant())
@ -263,46 +217,10 @@ 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()),
}
}
if contextInfo.GetIsForwarded() {
hasCaption := part.Content.FileName != "" && part.Content.FileName != part.Content.Body
isMedia := part.Content.MsgType.IsMedia()
isText := part.Content.MsgType.IsText()
if isMedia && !hasCaption {
part.Content.FileName = part.Content.Body
part.Content.Body = "↷ Forwarded"
part.Content.Format = event.FormatHTML
part.Content.FormattedBody = "<p data-mx-forwarded-notice><em>↷ Forwarded</em></p>"
} else if isText || isMedia {
part.Content.EnsureHasHTML()
part.Content.Body = "↷ Forwarded\n\n" + part.Content.Body
part.Content.FormattedBody = "<p data-mx-forwarded-notice><em>↷ Forwarded</em></p>" + part.Content.FormattedBody
}
}
commentTarget := waMsg.GetEncCommentMessage().GetTargetMessageKey()
if commentTarget == nil {
commentTarget = waMsg.GetCommentMessage().GetTargetMessageKey()

View file

@ -71,7 +71,10 @@ 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, err := mc.generateContextInfo(replyTo, portal)
if err != nil {
return nil, nil, err
}
var question string
question, contextInfo.MentionedJID = mc.msc1767ToWhatsApp(ctx, content.PollStart.Question, content.Mentions)
if len(question) == 0 {
@ -140,7 +143,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

View file

@ -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),
}
mc.HTMLParser = &format.HTMLParser{
PillConverter: mc.convertPill,

View file

@ -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(

View file

@ -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 += "<br><br>"
}

View file

@ -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 = "image/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

View file

@ -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"
@ -92,8 +91,6 @@ func (mc *MessageConverter) convertGroupInviteMessage(ctx context.Context, info
Code: msg.GetInviteCode(),
Expiration: msg.GetInviteExpiration(),
Inviter: info.Sender.ToNonAD(),
GroupName: msg.GetGroupName(),
IsParentGroup: msg.GetGroupType() == waE2E.GroupInviteMessage_PARENT,
}
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() {
portal.Disappear = disappear
portalMeta.DisappearingTimerSetAt = msg.GetEphemeralSettingTimestamp()
portal.UpdateDisappearingSetting(ctx, disappear, bridgev2.UpdateDisappearingSettingOpts{
Sender: getIntent(ctx),
Timestamp: ts,
Implicit: false,
Save: true,
SendNotice: false,
})
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,
},
},
DontBridge: dontBridge,
}, nil
}
const eventMessageTemplate = `
{{- if .Name -}}
<h4>{{ .Name }} {{- if .IsCanceled -}}<span> (Canceled)</span>{{- end -}}</h4>
<h4>{{ .Name }}</h4>
{{- end -}}
{{- if .StartTime -}}
<p>
@ -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()
}

View file

@ -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},
})

View file

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

View file

@ -20,30 +20,24 @@ import (
"crypto/ecdh"
"crypto/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 {

View file

@ -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)
}
mediaID = append(mediaID, byte(len(compactTarget)))
mediaID = append(mediaID, compactTarget...)
mediaID = append(mediaID, byte(len(id)))
mediaID = append(mediaID, id...)
mediaID = append(mediaID, byte(len(receiverID)))
mediaID = append(mediaID, receiverID...)
return mediaID
}
type AvatarMediaInfo struct {
TargetJID types.JID
AvatarID string
Community bool
}
func MakeStickerPackMediaID(packID string, fileHash []byte, receiver networkid.UserLoginID) networkid.MediaID {
receiverID := compactJID(ParseUserLoginID(receiver, 0))
mediaID := make([]byte, 0, 4+len(packID)+len(fileHash)+len(receiverID))
mediaID = append(mediaID, mediaIDTypeStickerPackItem)
mediaID = append(mediaID, byte(len(packID)))
mediaID = append(mediaID, packID...)
mediaID = append(mediaID, byte(len(fileHash)))
mediaID = append(mediaID, fileHash...)
mediaID = append(mediaID, byte(len(receiverID)))
mediaID = append(mediaID, receiverID...)
return mediaID
}
type StickerPackMediaInfo struct {
PackID string
FileHash []byte
}
type ParsedMediaID struct {
Message *ParsedMessageID
Avatar *AvatarMediaInfo
Sticker *StickerPackMediaInfo
UserLogin networkid.UserLoginID
}
func ParseMediaID(mediaID networkid.MediaID) (*ParsedMediaID, error) {
mediaIDType := mediaIDTypeMessage
if mediaID[0] > 127 {
mediaIDType = int(mediaID[0])
mediaID = mediaID[1:]
}
var parsed ParsedMediaID
switch mediaIDType {
case mediaIDTypeMessage:
chatJID, err := readCompact(&mediaID, parseCompactJID)
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)
return nil, "", fmt.Errorf("failed to parse chat JID: %w", err)
}
senderJID, err := readCompact(&mediaID, parseCompactJID)
senderJID, err := readCompact(reader, parseCompactJID)
if err != nil {
return nil, fmt.Errorf("failed to parse sender JID: %w", err)
return nil, "", fmt.Errorf("failed to parse sender JID: %w", err)
}
receiverID, err := readCompact(&mediaID, parseCompactJID)
receiverID, err := readCompact(reader, parseCompactJID)
if err != nil {
return nil, fmt.Errorf("failed to parse receiver JID: %w", err)
return nil, "", fmt.Errorf("failed to parse receiver JID: %w", err)
}
id, err := readCompact(&mediaID, parseCompactMsgID)
id, err := readCompact(reader, parseCompactMsgID)
if err != nil {
return nil, fmt.Errorf("failed to parse message ID: %w", err)
return nil, "", fmt.Errorf("failed to parse message ID: %w", err)
}
parsed.Message = &ParsedMessageID{
return &ParsedMessageID{
Chat: chatJID,
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)
}
return &parsed, nil
}
func parseString(data []byte) (string, error) {
return string(data), nil
}, 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)
}